Compare commits

..
132 Commits
Author SHA1 Message Date
JoshandDax Raad 9991352663 feat: forward provider options from model config (#202)
publish / publish (push) Has been cancelled
Co-authored-by: Dax Raad <d@ironbay.co>
2025-06-20 15:03:41 -04:00
Dmytro YankovskyiandDax Raad 91c4da5dbd fix(#243): claude on aws bedrock (#241)
publish / publish (push) Has been cancelled
Co-authored-by: Dax Raad <d@ironbay.co>
2025-06-20 14:57:33 -04:00
niba 2fd0e7dd6b chore: use client_id everywhere (#260) 2025-06-20 14:56:33 -04:00
adamdottv d50b7ad481 docs: theme schema update 2025-06-20 13:51:32 -05:00
adamdottv df95c49401 docs: theme schema 2025-06-20 13:00:32 -05:00
adamdottv 8b73c52f00 chore(tui): rename theme colors 2025-06-20 13:00:31 -05:00
Jay V 5603098d17 docs: add config 2025-06-20 13:22:31 -04:00
Jay V f436a50125 docs: share header 2025-06-20 13:12:35 -04:00
Jay V e19e977591 docs: test 2025-06-20 13:02:05 -04:00
Jay V addbe295b1 docs: test 2025-06-20 12:59:32 -04:00
Jay V 9a573dedc6 docs: test 2025-06-20 12:56:00 -04:00
adamdottv 9ea0d71e8d fix(tui): async load messages on theme/session switch 2025-06-20 11:25:21 -05:00
adamdottv b1a3599017 fix(tui): input latency optimization 2025-06-20 11:08:08 -05:00
adamdottv 7b0329f67f fix(tui): fetch tool more defensive
publish / publish (push) Has been cancelled
2025-06-20 09:00:28 -05:00
adamdottv 311b9c74dd fix(tui): typeahead open/close perf 2025-06-20 08:20:10 -05:00
adamdottv f7e8dd2ff8 chore: fix typescript issues 2025-06-20 07:48:42 -05:00
adamdottv 40b1dd7ef2 fix(tui): insert newline correctly positioned 2025-06-20 07:42:04 -05:00
adamdottv 261e76e0a3 fix(tui): input feels laggy 2025-06-20 07:31:45 -05:00
Dax Raad a300bfaccb docs: remove opencode.json 2025-06-20 01:00:15 -04:00
Dax Raad 41dba0db08 config validation
publish / publish (push) Has been cancelled
2025-06-20 00:57:28 -04:00
Rohan Godha 6674c6083a fix: phantom input bug on wsl (#200) 2025-06-19 20:08:56 -05:00
Tom Watkins f6afa2c6bb docs: fix typo in config.mdx (#218) 2025-06-19 21:08:21 -04:00
Dax Raad b2fb0508ea fix for azure models not liking tool definitions
publish / publish (push) Has been cancelled
2025-06-19 18:28:42 -04:00
Jay V 93f4252bb1 docs: tweak lander 2025-06-19 18:19:35 -04:00
Jay 46ab9c16dd docs: Update README.md 2025-06-19 18:19:06 -04:00
Dax Raad d869df4fee remove unused permission timeout
publish / publish (push) Has been cancelled
2025-06-19 18:00:53 -04:00
Dax Raad b99d4650ec temporarily disable project details in system prompt
publish / publish (push) Has been cancelled
2025-06-19 17:37:23 -04:00
Frank 261bb7f110 Infra: fix DO tag 2025-06-19 17:20:13 -04:00
Dax Raad 0515fbb260 fix gopls download spewing into terminal
publish / publish (push) Has been cancelled
2025-06-19 17:08:58 -04:00
adamdottv 88211d8c5b fix(tui): upgrade notification
publish / publish (push) Has been cancelled
2025-06-19 16:03:45 -05:00
Jay V a812f95b9d docs: share 2025-06-19 16:57:42 -04:00
adamdottv 3728a12bee fix(tui): better help on home
publish / publish (push) Has been cancelled
2025-06-19 15:56:28 -05:00
Jay V af07e51213 docs: tweak 2025-06-19 16:40:15 -04:00
Jay V 3113788c92 docs: copy 2025-06-19 16:39:36 -04:00
Jay V efb5fe6d4e docs: styles 2025-06-19 16:38:37 -04:00
Jay V 54dd6c644d docs: adding to config 2025-06-19 16:36:17 -04:00
Dax Raad 39ad8f2667 ignore: do migration 2025-06-19 16:32:32 -04:00
Jay V c4a2c84e53 docs: readme
publish / publish (push) Has been cancelled
2025-06-19 16:29:20 -04:00
Jay V 44fe012812 docs: edits 2025-06-19 16:28:11 -04:00
Jay V f5e7f079ea Copy changes 2025-06-19 16:28:03 -04:00
adamdottv 15a8936806 fix(tui): better tool titles 2025-06-19 15:11:53 -05:00
adamdottv 4e4cff49c0 feat(tui): better task tool rendering
publish / publish (push) Has been cancelled
2025-06-19 15:02:13 -05:00
adamdottv 5540503bee fix(tui): sorted tool arg maps 2025-06-19 14:07:33 -05:00
adamdottv 193718034b fix: typescript error 2025-06-19 13:57:25 -05:00
adamdottv 72108c0296 fix(tui): sorted tool arg maps 2025-06-19 13:56:09 -05:00
Dax Raad ec1c9f8cd1 use production share url
publish / publish (push) Has been cancelled
2025-06-19 14:21:00 -04:00
Dax Raad a85b0a370e ci: share 2025-06-19 13:26:15 -04:00
Dax Raadandopencode e7784d2864 add schema descriptions to config fields
Enhance configuration schema with descriptive text for all fields to improve developer experience and auto-generated documentation.

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

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-19 13:12:13 -04:00
Dax Raad 97c4815444 fix task agent performance issues
publish / publish (push) Has been cancelled
2025-06-19 13:00:57 -04:00
Dax Raad 7d1a1663c8 allow selecting model and continuing previous session for opencode run 2025-06-19 13:00:57 -04:00
adamdottv 24c0ce6e53 fix(tui): vscode and mac terminal colors 2025-06-19 11:46:08 -05:00
adamdottv 4cdc86612c fix(tui): overlay border backgrounds 2025-06-19 11:41:30 -05:00
Jay V f1f3f8d12c ignore: share version 2025-06-19 12:20:30 -04:00
adamdottv e78d3b54bf chore: cleanup logs 2025-06-19 10:52:45 -05:00
adamdottv f8a7cd372d fix(tui): toast placement and overlay rendering 2025-06-19 10:45:10 -05:00
adamdottv f48eac638d feat(tui): more toast messages 2025-06-19 10:41:59 -05:00
adamdottv e1f12f93eb feat(tui): toast messages 2025-06-19 10:12:29 -05:00
Dax Raad 7ca8334a8b fix webfetch tool when returning html as text
publish / publish (push) Has been cancelled
2025-06-19 10:43:54 -04:00
Dax Raad f1a2b2eba4 support token caching for anthropic via openrouter
publish / publish (push) Has been cancelled
2025-06-19 10:32:14 -04:00
adamdottv 4b132656df feat(tui): copy share url to clipboard
publish / publish (push) Has been cancelled
2025-06-19 09:06:25 -05:00
Dax Raad 26bab00dab remove opencode_ prefixes from tool names. unfortunately this will break
publish / publish (push) Has been cancelled
all old sessions and share links. we'll be more backwards compatible in
the future once we're more stable.
2025-06-19 09:59:44 -04:00
adamdottv 568c04753e feat(tui): expand input to fit message
publish / publish (push) Has been cancelled
2025-06-19 08:45:27 -05:00
Dax Raad 4a06e164d2 ensure session.info is synced when shared
publish / publish (push) Has been cancelled
2025-06-19 09:41:11 -04:00
adamdottv c57b52c300 fix: include schema in converted toml config 2025-06-19 06:02:02 -05:00
Guillermo Antony Cava Nuñez 0b8f48f17f Fixes tool tip layering (#199) 2025-06-19 00:23:29 -04:00
Dax Raad 3862184ccb hooks 2025-06-19 00:20:03 -04:00
Frank 8619c50976 Update SST 2025-06-18 23:38:06 -04:00
Josh bb6b56b72a fix: incorrect command on main screen for exiting application (#201)
publish / publish (push) Has been cancelled
2025-06-18 23:19:43 -04:00
Dax Raad 1252b65166 stop loading models.dev format from global config
publish / publish (push) Has been cancelled
2025-06-18 23:08:51 -04:00
Dax Raad 6840276dad docs: update README 2025-06-18 23:03:54 -04:00
Dax Raad bd8c3cd0f1 BREAKING CONFIG CHANGE
publish / publish (push) Has been cancelled
We have changed the config format yet again - but this should be the
final time. You can see the readme for more details but the summary is

- got rid of global providers config
- got rid of global toml
- global config is now in `~/.config/opencode/config.json`
- it will be merged with any project level config
2025-06-18 23:01:19 -04:00
Dax Raad e5e9b3e3c0 rework config 2025-06-18 23:01:19 -04:00
Frank 1e8a681de9 Render version 2025-06-18 22:26:51 -04:00
Jay V a834bedc17 ignore: share copy link 2025-06-18 20:18:10 -04:00
Dax Raad 6a3392385e support global config 2025-06-18 18:56:52 -04:00
Jay V 6a00e063c4 ignore: share logo 2025-06-18 18:33:51 -04:00
Jay V 73a0ce2b7d ignore: share 2025-06-18 18:22:19 -04:00
Jay V 4d1afd01fa ignore: share 2025-06-18 18:21:44 -04:00
Jay V 801d5f47bd ignore: share favicon 2025-06-18 18:10:22 -04:00
Jay V b6caae9708 ignore: share 2025-06-18 18:01:34 -04:00
adamdottv 183ca64ef9 feat(tui): show provider next to model 2025-06-18 16:09:49 -05:00
adamdottv 8c32cfe829 chore: tui style tweaks 2025-06-18 15:59:58 -05:00
Jay V 73dcc88da1 ignore: share 2025-06-18 16:54:33 -04:00
Jay V 14bded65dc ignore: share 2025-06-18 16:54:33 -04:00
adamdottv 87d1d3fb62 fix(tui): file completion quirks 2025-06-18 15:51:26 -05:00
Frank e054454109 Api: only return session messages 2025-06-18 16:20:34 -04:00
Dax Raad a6142cf975 ignore: types 2025-06-18 16:20:03 -04:00
Jay V 69332e5fa3 ignore: share 2025-06-18 16:15:22 -04:00
Jay V 20201ba3c4 ignore: share 2025-06-18 16:15:11 -04:00
Dax Raad 658067186a ignore: share page stuff 2025-06-18 16:13:33 -04:00
adamdottv ac777b77cf fix(tui): modal visuals 2025-06-18 15:12:24 -05:00
Dax Raad 5944ae2023 share types 2025-06-18 15:34:13 -04:00
Jay V 2f10961ba8 ignore: share 2025-06-18 15:32:40 -04:00
adamdottv fae97978a3 chore: cleanup logs 2025-06-18 14:18:46 -05:00
Dax Raadandopencode 3423415e49 docs: improve keybinds configuration format in README
publish / publish (push) Has been cancelled
Update keybinds configuration example to use proper TOML table syntax instead of dot notation for better readability and standard TOML formatting.

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

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-18 15:10:46 -04:00
adamdottv 1d0bfc2b2a fix(tui): help dialog sorting 2025-06-18 14:06:20 -05:00
adamdottv bd46cf0f86 feat(tui): configurable keybinds and mouse scroll 2025-06-18 13:56:51 -05:00
Dax Raad d4157d9a96 ctrl+c should gracefully clean up pending sessions
publish / publish (push) Has been cancelled
2025-06-18 14:11:49 -04:00
Jay V 6e4ef585d8 ignore: share error styles 2025-06-18 14:10:14 -04:00
Dax Raad e05c3b7a76 fix panic when invalid config
publish / publish (push) Has been cancelled
2025-06-18 14:03:16 -04:00
Dax Raad f99904bc1c track version on session info
publish / publish (push) Has been cancelled
2025-06-18 13:40:36 -04:00
Jay V b796d6763f ignore: share page styles 2025-06-18 12:53:48 -04:00
Dax Raad c1250abdf8 implemented diff trimming 2025-06-18 11:20:40 -04:00
Dax Raad ebe51534a1 allow setting options in global provider store
publish / publish (push) Has been cancelled
2025-06-18 11:06:16 -04:00
Dax Raad b8bbee4718 fix issue with provider cache
publish / publish (push) Has been cancelled
2025-06-18 10:56:23 -04:00
Dax Raad 8f852b396f fix deploys 2025-06-18 10:47:07 -04:00
Dax Raad ae4d089c06 remove call to npm causing noticible delay when starting chat
publish / publish (push) Has been cancelled
2025-06-18 10:35:41 -04:00
Dax Raad 5110fbdaf9 fix issue when running opencode in empty directory
publish / publish (push) Has been cancelled
2025-06-18 10:29:09 -04:00
Dax Raad e6ddb474fc ignore: sync
publish / publish (push) Has been cancelled
2025-06-18 08:36:25 -04:00
SBSTN 0dc71774ce Add Everforest Theme (#170) 2025-06-18 05:55:38 -05:00
Dax Raad b470466e30 integrate cache read/write data
publish / publish (push) Has been cancelled
2025-06-17 20:51:39 -04:00
Jay V d1f9311931 ignore: share page polish 2025-06-17 20:26:12 -04:00
Dax Raadandopencode 1c58023df9 improve anthropic oauth token caching and authentication handling
publish / publish (push) Has been cancelled
🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-17 13:23:15 -04:00
Dax Raad 4e0aa58b7e ignore: fix 2025-06-17 13:04:26 -04:00
Dax Raad 23ee34b35f state 2025-06-17 12:29:28 -04:00
Dax Raad 674c9a5220 support disabling providers from automatically being added
publish / publish (push) Has been cancelled
2025-06-17 12:23:04 -04:00
Dax Raad 54c86ed43a docs: readme 2025-06-17 12:17:45 -04:00
Dax Raad 676d75ee75 docs: update README
publish / publish (push) Has been cancelled
2025-06-17 12:14:38 -04:00
Dax Raad 70dc0a12f2 docs: readme 2025-06-17 12:12:33 -04:00
Dax Raad d579c5e8aa support global config for providers 2025-06-17 12:10:44 -04:00
Dax Raad ee91f31313 fix issue with tool schemas and google
publish / publish (push) Has been cancelled
2025-06-17 11:27:07 -04:00
Dax Raad 57b3051024 fix agent getting caught in summary loop
publish / publish (push) Has been cancelled
2025-06-17 10:50:03 -04:00
Dax Raad ae5cf3cc23 ci: fix
publish / publish (push) Has been cancelled
2025-06-17 10:38:01 -04:00
Dax Raadandopencode 68e1b3c46c Fix TypeScript compilation errors and consolidate version handling
publish / publish (push) Has been cancelled
🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-17 10:27:49 -04:00
adamdottv 2d68814abc feat: better collapsed tool call visuals
publish / publish (push) Has been cancelled
2025-06-17 08:35:18 -05:00
adamdottv a5da5127fa chore: consolidate chat page into tui.go 2025-06-17 07:09:04 -05:00
Dax Raadandopencode b5a4439704 Add autoshare configuration and improve run command UI
Enables automatic session sharing via global config or flag, enhances UI with logo display and provider/model info positioning.

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

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-17 01:45:32 -04:00
Dax Raad 9c5616521d do not autoupgrade snapshot builds
publish / publish (push) Has been cancelled
2025-06-17 01:18:32 -04:00
Dax Raad 3fe163416d autoupgrade
publish / publish (push) Has been cancelled
2025-06-17 01:05:05 -04:00
Dax d054f88130 Improve upgrade command with installation method detection (#158) 2025-06-17 00:07:17 -04:00
Jay b929b4f4b9 docs: Update README.md 2025-06-16 21:01:38 -04:00
Jay V 4c0c83b02d docs: readme 2025-06-16 20:10:19 -04:00
136 changed files with 8507 additions and 3326 deletions
+3 -2
View File
@@ -3,7 +3,8 @@ name: deploy
on: on:
push: push:
branches: branches:
- dontlook - dev
- production
workflow_dispatch: workflow_dispatch:
concurrency: ${{ github.workflow }}-${{ github.ref }} concurrency: ${{ github.workflow }}-${{ github.ref }}
@@ -20,6 +21,6 @@ jobs:
- run: bun install - run: bun install
- run: bun sst deploy --stage=dev - run: bun sst deploy --stage=${{ github.ref_name }}
env: env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
-2
View File
@@ -5,5 +5,3 @@ node_modules
.env .env
.idea .idea
.vscode .vscode
app.log
gopls.log
+43 -103
View File
@@ -1,8 +1,22 @@
[![OpenCode Terminal UI](screenshot.png)](https://github.com/sst/opencode) <p align="center">
<a href="https://opencode.ai">
<picture>
<source srcset="packages/web/src/assets/logo-dark.svg" media="(prefers-color-scheme: dark)">
<source srcset="packages/web/src/assets/logo-light.svg" media="(prefers-color-scheme: light)">
<img src="packages/web/src/assets/logo-light.svg" alt="opencode logo">
</picture>
</a>
</p>
<p align="center">AI coding agent, built for the terminal.</p>
<p align="center">
<a href="https://opencode.ai/docs"><img alt="View docs" src="https://img.shields.io/badge/view-docs-blue?style=flat-square" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://github.com/sst/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/sst/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
AI coding agent, built for the terminal. [![opencode Terminal UI](packages/web/src/assets/themes/opencode.png)](https://opencode.ai)
⚠️ **Note:** version 0.1.x is a full rewrite, and we do not have proper documentation for it yet. Should have this out week of June 17th 2025 📚 ---
### Installation ### Installation
@@ -16,109 +30,31 @@ brew install sst/tap/opencode # macOS
paru -S opencode-bin # Arch Linux 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. For more info on how to configure opencode [**head over to our docs**](https://opencode.ai/docs).
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). Be sure to specify the npm package to use to load the provider.
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"ollama": {
"npm": "@ai-sdk/openai-compatible",
"options": {
"baseURL": "http://localhost:11434/v1"
},
"models": {
"llama2": {
"name": "llama2"
}
}
}
}
}
```
### Contributing ### Contributing
To run opencode locally you need To run opencode locally you need.
- bun - Bun
- golang 1.24.x - Golang 1.24.x
To run And run.
``` ```bash
$ bun install $ bun install
$ cd packages/opencode $ bun run packages/opencode/src/index.ts
$ bun run src/index.ts
``` ```
### FAQ ### 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" ```json title="opencode.json"
{ {
@@ -127,9 +63,7 @@ OpenRouter is not yet in the models.dev database, but you can configure it manua
"openrouter": { "openrouter": {
"npm": "@openrouter/ai-sdk-provider", "npm": "@openrouter/ai-sdk-provider",
"name": "OpenRouter", "name": "OpenRouter",
"options": { "options": {},
"apiKey": "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
},
"models": { "models": {
"anthropic/claude-3.5-sonnet": { "anthropic/claude-3.5-sonnet": {
"name": "Claude 3.5 Sonnet" "name": "Claude 3.5 Sonnet"
@@ -140,19 +74,25 @@ OpenRouter is not yet in the models.dev database, but you can configure it manua
} }
``` ```
#### How is this different than claude code? And then to configure an api key you can do `opencode auth login` and select "Other -> 'openrouter'"
It is very similar to claude code in terms of capability - here are the key differences: #### How is this different than Claude Code?
It's very similar to Claude Code in terms of capability. Here are the key differences:
- 100% open source - 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. - Not coupled to any provider. Although Anthropic is recommended, opencode can be used with OpenAI, Google or even local models. As models evolve the gaps between them will close and pricing will drop so being provider agnostic is important.
- TUI focus - opencode is built by neovim users and the creators of https://terminal.shop - we are going to push the limits of what's possible in the terminal - 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.
- client/server architecture - this means the tui frontend is just the first of many. For example, opencode can run on your computer and you can drive it remotely from a mobile app - A client/server architecture. This for example can allow opencode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients.
#### Windows Support #### What about Windows support?
There are some minor problems blocking opencode from working on windows. We will fix them soon - would need to use wsl for now. There are some minor problems blocking opencode from working on windows. We are working on on them now. You'll need to use WSL for now.
#### What's the other repo? #### What's the other repo?
If you're looking for opencode built by adam and dax and frank and jay you are in the right place. Any other similarly named projects have no relation to this one. 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)
+69 -12
View File
@@ -5,7 +5,7 @@
"name": "opencode", "name": "opencode",
"devDependencies": { "devDependencies": {
"prettier": "3.5.3", "prettier": "3.5.3",
"sst": "3.17.4", "sst": "3.17.6",
}, },
}, },
"packages/function": { "packages/function": {
@@ -43,8 +43,11 @@
"yargs": "18.0.0", "yargs": "18.0.0",
"zod": "catalog:", "zod": "catalog:",
"zod-openapi": "4.2.4", "zod-openapi": "4.2.4",
"zod-validation-error": "3.5.2",
}, },
"devDependencies": { "devDependencies": {
"@ai-sdk/amazon-bedrock": "2.2.10",
"@ai-sdk/anthropic": "1.2.12",
"@tsconfig/bun": "1.0.7", "@tsconfig/bun": "1.0.7",
"@types/bun": "latest", "@types/bun": "latest",
"@types/turndown": "5.0.5", "@types/turndown": "5.0.5",
@@ -74,10 +77,11 @@
"sharp": "0.32.5", "sharp": "0.32.5",
"shiki": "3.4.2", "shiki": "3.4.2",
"solid-js": "1.9.7", "solid-js": "1.9.7",
"toolbeam-docs-theme": "0.2.4", "toolbeam-docs-theme": "0.3.0",
}, },
"devDependencies": { "devDependencies": {
"@types/node": "catalog:", "@types/node": "catalog:",
"opencode": "workspace:*",
"typescript": "catalog:", "typescript": "catalog:",
}, },
}, },
@@ -86,6 +90,9 @@
"sharp", "sharp",
"esbuild", "esbuild",
], ],
"patchedDependencies": {
"ai@4.3.16": "patches/ai@4.3.16.patch",
},
"overrides": { "overrides": {
"zod": "3.24.2", "zod": "3.24.2",
}, },
@@ -96,6 +103,10 @@
"zod": "3.24.2", "zod": "3.24.2",
}, },
"packages": { "packages": {
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@2.2.10", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-icLGO7Q0NinnHIPgT+y1QjHVwH4HwV+brWbvM+FfCG2Afpa89PyKa3Ret91kGjZpBgM/xnj1B7K5eM+rRlsXQA=="],
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@1.2.12", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-YSzjlko7JvuiyQFmI9RN1tNZdEiZxc+6xld/0tq/VkJaHpEzGAb1yiNxxvmYVcjvfu/PcvCxAAYXmTYQQ63IHQ=="],
"@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="], "@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="], "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="],
@@ -130,6 +141,12 @@
"@astrojs/underscore-redirects": ["@astrojs/underscore-redirects@0.6.1", "", {}, "sha512-4bMLrs2KW+8/vHEE5Ffv2HbxCbbgXO+2N6MpoCsMXUlUoi7pgEEx8kbkzMXJ2dZtWF3gvwm9lvgjnFeanC2LGg=="], "@astrojs/underscore-redirects": ["@astrojs/underscore-redirects@0.6.1", "", {}, "sha512-4bMLrs2KW+8/vHEE5Ffv2HbxCbbgXO+2N6MpoCsMXUlUoi7pgEEx8kbkzMXJ2dZtWF3gvwm9lvgjnFeanC2LGg=="],
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
"@aws-sdk/types": ["@aws-sdk/types@3.821.0", "", { "dependencies": { "@smithy/types": "^4.3.1", "tslib": "^2.6.2" } }, "sha512-Znroqdai1a90TlxGaJ+FK1lwC0fHpo97Xjsp5UKGR5JODYm7f9+/fF17ebO1KdoBr/Rm0UIFiF5VmI8ts9F1eA=="],
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@babel/compat-data": ["@babel/compat-data@7.27.3", "", {}, "sha512-V42wFfx1ymFte+ecf6iXghnnP8kWTO+ZLXIyZq+1LAXHHvTZdVxicn4yiVYdYMGaCO3tmqub11AorKkv+iodqw=="], "@babel/compat-data": ["@babel/compat-data@7.27.3", "", {}, "sha512-V42wFfx1ymFte+ecf6iXghnnP8kWTO+ZLXIyZq+1LAXHHvTZdVxicn4yiVYdYMGaCO3tmqub11AorKkv+iodqw=="],
@@ -410,6 +427,18 @@
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
"@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.0.4", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.3.1", "@smithy/util-hex-encoding": "^4.0.0", "tslib": "^2.6.2" } }, "sha512-7XoWfZqWb/QoR/rAU4VSi0mWnO2vu9/ltS6JZ5ZSZv0eovLVfDfu0/AX4ub33RsJTOth3TiFWSHS5YdztvFnig=="],
"@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.0.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-saYhF8ZZNoJDTvJBEWgeBccCg+yvp1CX+ed12yORU3NilJScfc6gfch2oVb4QgxZrGUx3/ZJlb+c/dJbyupxlw=="],
"@smithy/types": ["@smithy/types@4.3.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-UqKOQBL2x6+HWl3P+3QqFD4ncKq0I8Nuz9QItGv5WuKuMHuuwlhvqcZCoXGfc+P1QmfJE7VieykoYYmrOoFJxA=="],
"@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.0.0", "", { "dependencies": { "@smithy/is-array-buffer": "^4.0.0", "tslib": "^2.6.2" } }, "sha512-9TOQ7781sZvddgO8nxueKi3+yGvkY35kotA0Y6BWRajAv8jjmigQ1sBwz0UX47pQMYXJPahSKEKYFgt+rXdcug=="],
"@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.0.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Yk5mLhHtfIgW2W2WQZWSg5kuMZCVbvhFmC7rV4IO2QqnZdbEFPmQnCcGMAX2z/8Qj3B9hYYNjZOhWym+RwhePw=="],
"@smithy/util-utf8": ["@smithy/util-utf8@4.0.0", "", { "dependencies": { "@smithy/util-buffer-from": "^4.0.0", "tslib": "^2.6.2" } }, "sha512-b+zebfKCfRdgNJDknHCob3O7FpeYQN6ZG6YLExMcasDHsCXlsXCEuiPZeLnJLpwa5dvPetGlnGCiMHuLwGvFow=="],
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
"@swc/helpers": ["@swc/helpers@0.5.17", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A=="], "@swc/helpers": ["@swc/helpers@0.5.17", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A=="],
@@ -1418,23 +1447,23 @@
"split2": ["split2@3.2.2", "", { "dependencies": { "readable-stream": "^3.0.0" } }, "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg=="], "split2": ["split2@3.2.2", "", { "dependencies": { "readable-stream": "^3.0.0" } }, "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg=="],
"sst": ["sst@3.17.4", "", { "dependencies": { "aws-sdk": "2.1692.0", "aws4fetch": "1.0.18", "jose": "5.2.3", "opencontrol": "0.0.6", "openid-client": "5.6.4" }, "optionalDependencies": { "sst-darwin-arm64": "3.17.4", "sst-darwin-x64": "3.17.4", "sst-linux-arm64": "3.17.4", "sst-linux-x64": "3.17.4", "sst-linux-x86": "3.17.4", "sst-win32-arm64": "3.17.4", "sst-win32-x64": "3.17.4", "sst-win32-x86": "3.17.4" }, "bin": { "sst": "bin/sst.mjs" } }, "sha512-WpAws1ASJIilKC9/DGBhZ5wk2I4gtlzHXKpuwPC25bHWjqllv1jZiehIYhhN0PpV2pV8xCvqzyN8Gdm3J4EWQg=="], "sst": ["sst@3.17.6", "", { "dependencies": { "aws-sdk": "2.1692.0", "aws4fetch": "1.0.18", "jose": "5.2.3", "opencontrol": "0.0.6", "openid-client": "5.6.4" }, "optionalDependencies": { "sst-darwin-arm64": "3.17.6", "sst-darwin-x64": "3.17.6", "sst-linux-arm64": "3.17.6", "sst-linux-x64": "3.17.6", "sst-linux-x86": "3.17.6", "sst-win32-arm64": "3.17.6", "sst-win32-x64": "3.17.6", "sst-win32-x86": "3.17.6" }, "bin": { "sst": "bin/sst.mjs" } }, "sha512-p+AcqwfYQUdkxeRjCikQoTMviPCBiGoU7M0vcV6GDVmVis8hzhVw4EFfHTafZC+aWfy1Ke2UQi66vZlEVWuEqA=="],
"sst-darwin-arm64": ["sst-darwin-arm64@3.17.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-IJansQWlPdiaQNsJw3FQ+Q/ZXN1hzrq2Q31xG4l2HhA1doj1C3y+6s57vu4cTRDFo2OwBlC4+zlQBJHsOYGhrA=="], "sst-darwin-arm64": ["sst-darwin-arm64@3.17.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6tb7KlcPR7PTi3ofQv8dX/n6Jf7pNP9VfrnYL4HBWnWrcYaZeJ5MWobILfIJ/y2jHgoqmg9e5C3266Eds0JQyw=="],
"sst-darwin-x64": ["sst-darwin-x64@3.17.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-mHd26/AtaQ79ajqzsutRhgEjkCxX+bXgW4KJIN0AGT3110fo2OL0x2UXmfX+sxSWOFHvJQsjFjFm4CLtQSxyBg=="], "sst-darwin-x64": ["sst-darwin-x64@3.17.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-lFakq6/EgTuBSjbl8Kry4pfgAPEIyn6o7ZkyRz3hz5331wUaX88yfjs3tL9JQ8Ey6jBUYxwhP/Q1n7fzIG046g=="],
"sst-linux-arm64": ["sst-linux-arm64@3.17.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-unaNWOY3oEI/jUUG47/2Gbreaoi/D/rLsTPeKyYEWhWEBWCojns7LfMQs1bgW0qjBGmazB2IJD4NVYhYqYQxqQ=="], "sst-linux-arm64": ["sst-linux-arm64@3.17.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-SdTxXMbTEdiwOqp37w31kXv97vHqSx3oK9h/76lKg7V9k5JxPJ6JMefPLhoKWwK0Zh6AndY2zo2oRoEv4SIaDw=="],
"sst-linux-x64": ["sst-linux-x64@3.17.4", "", { "os": "linux", "cpu": "x64" }, "sha512-zoErI6dVoRxWcmoVVrzNJWKEqfUF/MyQInEkGROGY2YsFFzOM5RD5Dsdm9q6oDGwx+NxFAhQWc8/8C+OmoW1nA=="], "sst-linux-x64": ["sst-linux-x64@3.17.6", "", { "os": "linux", "cpu": "x64" }, "sha512-qneh7uWDiTUYx8X1Y3h2YVw3SJ0ybBBlRrVybIvCM09JqQ8+qq/XjKXGzA/3/EF0Jr7Ug8cARSn9CwxhdQGN7Q=="],
"sst-linux-x86": ["sst-linux-x86@3.17.4", "", { "os": "linux", "cpu": "none" }, "sha512-7ZHS2rxzxVAxMFW3u5+GMRGGACaBMuLht8JYxqruD8mFVqk9UaPQgrFKIHGKWHLBJLVnF2AdwmlHOcEKP+UJWA=="], "sst-linux-x86": ["sst-linux-x86@3.17.6", "", { "os": "linux", "cpu": "none" }, "sha512-pU3D5OeqnmfxGqN31DxuwWnc1OayxhkErnITHhZ39D0MTiwbIgCapH26FuLW8B08/uxJWG8djUlOboCRhSBvWA=="],
"sst-win32-arm64": ["sst-win32-arm64@3.17.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-q4cedr6WD3NqeQkDvmAsIgMgPIjziIWy81wA3ZmnY6UT0jFgFus23ppLIi6F4BFJfOygvAP2PeGrRR3o8giclw=="], "sst-win32-arm64": ["sst-win32-arm64@3.17.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-Rr3RTYWAsH9sM9CbM/sAZCk7dB1OsSAljjJuuHMvdSAYW3RDpXEza0PBJGxnBID2eOrpswEchzMPL2d8LtL7oA=="],
"sst-win32-x64": ["sst-win32-x64@3.17.4", "", { "os": "win32", "cpu": "x64" }, "sha512-sSQL041YCusZ8/0ynYGe9DCmPYVZOFsemXKUA9tX4IGSDqXae1FN0Sj7HQ17JyY24UUirY1zR7LFk+7KrP6wiA=="], "sst-win32-x64": ["sst-win32-x64@3.17.6", "", { "os": "win32", "cpu": "x64" }, "sha512-yZ3roxwI0Wve9PFzdrrF1kfzCmIMFCCoa8qKeXY7LxCJ4QQIqHbCOccLK1Wv/MIU/mcZHWXTQVCLHw77uaa0GQ=="],
"sst-win32-x86": ["sst-win32-x86@3.17.4", "", { "os": "win32", "cpu": "none" }, "sha512-WhjsD2dkA2fbQ03CgwIJb+2p0osll2PTXlr7HC3L+H8wG2DgLFPjoE+6N8n6r2dVMVaDzuNwy/7J8hRB29blaw=="], "sst-win32-x86": ["sst-win32-x86@3.17.6", "", { "os": "win32", "cpu": "none" }, "sha512-zV7TJWPJN9PmIXr15iXFSs0tbGsa52oBR3+xiKrUj2qj9XsZe7HBFwskRnHyiFq0durZY9kk9ZtoVlpuUuzr1g=="],
"stacktracey": ["stacktracey@2.1.8", "", { "dependencies": { "as-table": "^1.0.36", "get-source": "^2.0.12" } }, "sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw=="], "stacktracey": ["stacktracey@2.1.8", "", { "dependencies": { "as-table": "^1.0.36", "get-source": "^2.0.12" } }, "sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw=="],
@@ -1488,7 +1517,7 @@
"token-types": ["token-types@6.0.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA=="], "token-types": ["token-types@6.0.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA=="],
"toolbeam-docs-theme": ["toolbeam-docs-theme@0.2.4", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-W5mdbcgRpTBDFyEdcU81USs3MFZoXMInpSznc/AFZCwqz8atk4iBNDIlhvihpGHY54Nf5crKmZwJjxVojkHFvA=="], "toolbeam-docs-theme": ["toolbeam-docs-theme@0.3.0", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-qlBkKRp8HVYV7p7jaG9lT2lvQY7c8b9czZ0tnsJUrN2TBTtEyFJymCdkhhpZNC9U4oGZ7lLk0glRJHrndWvVsg=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
@@ -1648,12 +1677,24 @@
"zod-to-ts": ["zod-to-ts@1.2.0", "", { "peerDependencies": { "typescript": "^4.9.4 || ^5.0.2", "zod": "^3" } }, "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA=="], "zod-to-ts": ["zod-to-ts@1.2.0", "", { "peerDependencies": { "typescript": "^4.9.4 || ^5.0.2", "zod": "^3" } }, "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA=="],
"zod-validation-error": ["zod-validation-error@3.5.2", "", { "peerDependencies": { "zod": "^3.25.0" } }, "sha512-mdi7YOLtram5dzJ5aDtm1AG9+mxRma1iaMrZdYIpFO7epdKBUwLHIxTF8CPDeCQ828zAXYtizrKlEJAtzgfgrw=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@ai-sdk/amazon-bedrock/aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
"@ampproject/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], "@ampproject/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
"@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.2", "", { "dependencies": { "@astrojs/internal-helpers": "0.6.1", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.1.0", "js-yaml": "^4.1.0", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.2.1", "smol-toml": "^1.3.1", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.1", "vfile": "^6.0.3" } }, "sha512-bO35JbWpVvyKRl7cmSJD822e8YA8ThR/YbUsciWNA7yTcqpIAL2hJDToWP5KcZBWxGT6IOdOkHSXARSNZc4l/Q=="], "@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.2", "", { "dependencies": { "@astrojs/internal-helpers": "0.6.1", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.1.0", "js-yaml": "^4.1.0", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.2.1", "smol-toml": "^1.3.1", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.1", "vfile": "^6.0.3" } }, "sha512-bO35JbWpVvyKRl7cmSJD822e8YA8ThR/YbUsciWNA7yTcqpIAL2hJDToWP5KcZBWxGT6IOdOkHSXARSNZc4l/Q=="],
"@aws-crypto/crc32/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@aws-crypto/util/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@aws-sdk/types/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/generator/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], "@babel/generator/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
@@ -1674,6 +1715,18 @@
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"@smithy/eventstream-codec/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@smithy/is-array-buffer/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@smithy/types/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@smithy/util-buffer-from/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@smithy/util-hex-encoding/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@smithy/util-utf8/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@swc/helpers/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@swc/helpers/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@@ -1744,6 +1797,8 @@
"@astrojs/mdx/@astrojs/markdown-remark/@astrojs/prism": ["@astrojs/prism@3.3.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ=="], "@astrojs/mdx/@astrojs/markdown-remark/@astrojs/prism": ["@astrojs/prism@3.3.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ=="],
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -1810,6 +1865,8 @@
"wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.4", "", { "os": "win32", "cpu": "x64" }, "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ=="], "wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.4", "", { "os": "win32", "cpu": "x64" }, "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ=="],
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"args/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], "args/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
+3 -13
View File
@@ -23,25 +23,15 @@ export const api = new sst.cloudflare.Worker("Api", {
}, },
]) ])
args.migrations = { args.migrations = {
oldTag: "v1", // Note: when releasing the next tag, make sure all stages use tag v2
newTag: "v1", oldTag: $app.stage === "production" ? "" : "v1",
newTag: $app.stage === "production" ? "" : "v1",
//newSqliteClasses: ["SyncServer"], //newSqliteClasses: ["SyncServer"],
} }
}, },
}, },
}) })
// 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", { new sst.cloudflare.x.Astro("Web", {
domain, domain,
path: "packages/web", path: "packages/web",
+1 -14
View File
@@ -1,16 +1,3 @@
{ {
"$schema": "https://opencode.ai/config.json", "$schema": "https://opencode.ai/config.json"
"provider": {
"ollama": {
"npm": "@ai-sdk/openai-compatible",
"options": {
"baseURL": "http://localhost:11434/v1"
},
"models": {
"qwen3": {},
"deepseek-r1": {},
"llama2": {}
}
}
}
} }
+7 -3
View File
@@ -5,7 +5,8 @@
"type": "module", "type": "module",
"packageManager": "bun@1.2.14", "packageManager": "bun@1.2.14",
"scripts": { "scripts": {
"typecheck": "bun run --filter='*' typecheck" "typecheck": "bun run --filter='*' typecheck",
"postinstall": "./scripts/hooks"
}, },
"workspaces": { "workspaces": {
"packages": [ "packages": [
@@ -20,7 +21,7 @@
}, },
"devDependencies": { "devDependencies": {
"prettier": "3.5.3", "prettier": "3.5.3",
"sst": "3.17.4" "sst": "3.17.6"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@@ -37,5 +38,8 @@
"esbuild", "esbuild",
"protobufjs", "protobufjs",
"sharp" "sharp"
] ],
"patchedDependencies": {
"ai@4.3.16": "patches/ai@4.3.16.patch"
}
} }
+7 -9
View File
@@ -19,9 +19,9 @@ export class SyncServer extends DurableObject<Env> {
this.ctx.acceptWebSocket(server) this.ctx.acceptWebSocket(server)
const data = await this.ctx.storage.list() const data = await this.ctx.storage.list()
for (const [key, content] of data.entries()) { Array.from(data.entries())
server.send(JSON.stringify({ key, content })) .filter(([key, _]) => key.startsWith("session/"))
} .map(([key, content]) => server.send(JSON.stringify({ key, content })))
return new Response(null, { return new Response(null, {
status: 101, status: 101,
@@ -71,11 +71,9 @@ export class SyncServer extends DurableObject<Env> {
public async getData() { public async getData() {
const data = await this.ctx.storage.list() const data = await this.ctx.storage.list()
const messages = [] return Array.from(data.entries())
for (const [key, content] of data.entries()) { .filter(([key, _]) => key.startsWith("session/"))
messages.push({ key, content }) .map(([key, content]) => ({ key, content }))
}
return messages
} }
private async getSecret() { private async getSecret() {
@@ -122,7 +120,7 @@ export default {
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
secret, secret,
url: "https://dev.opencode.ai/s/" + short, url: "https://opencode.ai/s/" + short,
}), }),
{ {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
+160 -13
View File
@@ -2,13 +2,155 @@
"type": "object", "type": "object",
"properties": { "properties": {
"$schema": { "$schema": {
"type": "string" "type": "string",
"description": "JSON schema reference for configuration validation"
},
"theme": {
"type": "string",
"description": "Theme name to use for the interface"
},
"keybinds": {
"type": "object",
"properties": {
"leader": {
"type": "string",
"description": "Leader key for keybind combinations"
},
"help": {
"type": "string",
"description": "Show help dialog"
},
"editor_open": {
"type": "string",
"description": "Open external editor"
},
"session_new": {
"type": "string",
"description": "Create a new session"
},
"session_list": {
"type": "string",
"description": "List all sessions"
},
"session_share": {
"type": "string",
"description": "Share current session"
},
"session_interrupt": {
"type": "string",
"description": "Interrupt current session"
},
"session_compact": {
"type": "string",
"description": "Toggle compact mode for session"
},
"tool_details": {
"type": "string",
"description": "Show tool details"
},
"model_list": {
"type": "string",
"description": "List available models"
},
"theme_list": {
"type": "string",
"description": "List available themes"
},
"project_init": {
"type": "string",
"description": "Initialize project configuration"
},
"input_clear": {
"type": "string",
"description": "Clear input field"
},
"input_paste": {
"type": "string",
"description": "Paste from clipboard"
},
"input_submit": {
"type": "string",
"description": "Submit input"
},
"input_newline": {
"type": "string",
"description": "Insert newline in input"
},
"history_previous": {
"type": "string",
"description": "Navigate to previous history item"
},
"history_next": {
"type": "string",
"description": "Navigate to next history item"
},
"messages_page_up": {
"type": "string",
"description": "Scroll messages up by one page"
},
"messages_page_down": {
"type": "string",
"description": "Scroll messages down by one page"
},
"messages_half_page_up": {
"type": "string",
"description": "Scroll messages up by half page"
},
"messages_half_page_down": {
"type": "string",
"description": "Scroll messages down by half page"
},
"messages_previous": {
"type": "string",
"description": "Navigate to previous message"
},
"messages_next": {
"type": "string",
"description": "Navigate to next message"
},
"messages_first": {
"type": "string",
"description": "Navigate to first message"
},
"messages_last": {
"type": "string",
"description": "Navigate to last message"
},
"app_exit": {
"type": "string",
"description": "Exit the application"
}
},
"additionalProperties": false,
"description": "Custom keybind configurations"
},
"autoshare": {
"type": "boolean",
"description": "Share newly created sessions automatically"
},
"autoupdate": {
"type": "boolean",
"description": "Automatically update to the latest version"
},
"disabled_providers": {
"type": "array",
"items": {
"type": "string"
},
"description": "Disable providers that are loaded automatically"
},
"model": {
"type": "string",
"description": "Model to use in the format of provider/model, eg anthropic/claude-2"
}, },
"provider": { "provider": {
"type": "object", "type": "object",
"additionalProperties": { "additionalProperties": {
"type": "object", "type": "object",
"properties": { "properties": {
"api": {
"type": "string"
},
"name": { "name": {
"type": "string" "type": "string"
}, },
@@ -50,18 +192,16 @@
"output": { "output": {
"type": "number" "type": "number"
}, },
"inputCached": { "cache_read": {
"type": "number" "type": "number"
}, },
"outputCached": { "cache_write": {
"type": "number" "type": "number"
} }
}, },
"required": [ "required": [
"input", "input",
"output", "output"
"inputCached",
"outputCached"
], ],
"additionalProperties": false "additionalProperties": false
}, },
@@ -97,7 +237,8 @@
"models" "models"
], ],
"additionalProperties": false "additionalProperties": false
} },
"description": "Custom provider configurations and model overrides"
}, },
"mcp": { "mcp": {
"type": "object", "type": "object",
@@ -108,19 +249,22 @@
"properties": { "properties": {
"type": { "type": {
"type": "string", "type": "string",
"const": "local" "const": "local",
"description": "Type of MCP server connection"
}, },
"command": { "command": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
} },
"description": "Command and arguments to run the MCP server"
}, },
"environment": { "environment": {
"type": "object", "type": "object",
"additionalProperties": { "additionalProperties": {
"type": "string" "type": "string"
} },
"description": "Environment variables to set when running the MCP server"
} }
}, },
"required": [ "required": [
@@ -134,10 +278,12 @@
"properties": { "properties": {
"type": { "type": {
"type": "string", "type": "string",
"const": "remote" "const": "remote",
"description": "Type of MCP server connection"
}, },
"url": { "url": {
"type": "string" "type": "string",
"description": "URL of the remote MCP server"
} }
}, },
"required": [ "required": [
@@ -147,7 +293,8 @@
"additionalProperties": false "additionalProperties": false
} }
] ]
} },
"description": "MCP (Model Context Protocol) server configurations"
} }
}, },
"additionalProperties": false, "additionalProperties": false,
+5 -5
View File
@@ -9,12 +9,11 @@
"dev": "bun run ./src/index.ts" "dev": "bun run ./src/index.ts"
}, },
"exports": { "exports": {
"./*": [ "./*": "./src/*.ts"
"./src/*.ts",
"./src/*/index.ts"
]
}, },
"devDependencies": { "devDependencies": {
"@ai-sdk/amazon-bedrock": "2.2.10",
"@ai-sdk/anthropic": "1.2.12",
"@tsconfig/bun": "1.0.7", "@tsconfig/bun": "1.0.7",
"@types/bun": "latest", "@types/bun": "latest",
"@types/turndown": "5.0.5", "@types/turndown": "5.0.5",
@@ -44,6 +43,7 @@
"xdg-basedir": "5.1.0", "xdg-basedir": "5.1.0",
"yargs": "18.0.0", "yargs": "18.0.0",
"zod": "catalog:", "zod": "catalog:",
"zod-openapi": "4.2.4" "zod-openapi": "4.2.4",
"zod-validation-error": "3.5.2"
} }
} }
+2 -2
View File
@@ -80,9 +80,9 @@ function main() {
// Create symlink to the actual binary // Create symlink to the actual binary
fs.symlinkSync(binaryPath, binScript) fs.symlinkSync(binaryPath, binScript)
console.log(`OpenCode binary symlinked: ${binScript} -> ${binaryPath}`) console.log(`opencode binary symlinked: ${binScript} -> ${binaryPath}`)
} catch (error) { } catch (error) {
console.error("Failed to create OpenCode binary symlink:", error.message) console.error("Failed to create opencode binary symlink:", error.message)
process.exit(1) process.exit(1)
} }
} }
+5 -7
View File
@@ -18,6 +18,7 @@ export namespace App {
data: z.string(), data: z.string(),
root: z.string(), root: z.string(),
cwd: z.string(), cwd: z.string(),
state: z.string(),
}), }),
time: z.object({ time: z.object({
initialized: z.number().optional(), initialized: z.number().optional(),
@@ -32,7 +33,7 @@ export namespace App {
const APP_JSON = "app.json" const APP_JSON = "app.json"
async function create(input: { cwd: string; version: string }) { async function create(input: { cwd: string }) {
log.info("creating", { log.info("creating", {
cwd: input.cwd, cwd: input.cwd,
}) })
@@ -49,9 +50,7 @@ export namespace App {
const stateFile = Bun.file(path.join(data, APP_JSON)) const stateFile = Bun.file(path.join(data, APP_JSON))
const state = (await stateFile.json().catch(() => ({}))) as { const state = (await stateFile.json().catch(() => ({}))) as {
initialized: number initialized: number
version: string
} }
state.version = input.version
await stateFile.write(JSON.stringify(state)) await stateFile.write(JSON.stringify(state))
const services = new Map< const services = new Map<
@@ -70,13 +69,13 @@ export namespace App {
git: git !== undefined, git: git !== undefined,
path: { path: {
config: Global.Path.config, config: Global.Path.config,
state: Global.Path.state,
data, data,
root: git ?? input.cwd, root: git ?? input.cwd,
cwd: input.cwd, cwd: input.cwd,
}, },
} }
const result = { const result = {
version: input.version,
services, services,
info, info,
} }
@@ -108,7 +107,7 @@ export namespace App {
} }
export async function provide<T>( export async function provide<T>(
input: { cwd: string; version: string }, input: { cwd: string },
cb: (app: Info) => Promise<T>, cb: (app: Info) => Promise<T>,
) { ) {
const app = await create(input) const app = await create(input)
@@ -124,12 +123,11 @@ export namespace App {
} }
export async function initialize() { export async function initialize() {
const { info, version } = ctx.use() const { info } = ctx.use()
info.time.initialized = Date.now() info.time.initialized = Date.now()
await Bun.write( await Bun.write(
path.join(info.path.data, APP_JSON), path.join(info.path.data, APP_JSON),
JSON.stringify({ JSON.stringify({
version,
initialized: Date.now(), initialized: Date.now(),
}), }),
) )
+5 -2
View File
@@ -8,7 +8,7 @@ export namespace AuthAnthropic {
const pkce = await generatePKCE() const pkce = await generatePKCE()
const url = new URL("https://claude.ai/oauth/authorize", import.meta.url) const url = new URL("https://claude.ai/oauth/authorize", import.meta.url)
url.searchParams.set("code", "true") url.searchParams.set("code", "true")
url.searchParams.set("client_id", "9d1c250a-e61b-44d9-88ed-5944d1962f5e") url.searchParams.set("client_id", CLIENT_ID)
url.searchParams.set("response_type", "code") url.searchParams.set("response_type", "code")
url.searchParams.set( url.searchParams.set(
"redirect_uri", "redirect_uri",
@@ -38,7 +38,7 @@ export namespace AuthAnthropic {
code: splits[0], code: splits[0],
state: splits[1], state: splits[1],
grant_type: "authorization_code", grant_type: "authorization_code",
client_id: "9d1c250a-e61b-44d9-88ed-5944d1962f5e", client_id: CLIENT_ID,
redirect_uri: "https://console.anthropic.com/oauth/code/callback", redirect_uri: "https://console.anthropic.com/oauth/code/callback",
code_verifier: verifier, code_verifier: verifier,
}), }),
@@ -48,6 +48,7 @@ export namespace AuthAnthropic {
await Auth.set("anthropic", { await Auth.set("anthropic", {
type: "oauth", type: "oauth",
refresh: json.refresh_token as string, refresh: json.refresh_token as string,
access: json.access_token as string,
expires: Date.now() + json.expires_in * 1000, expires: Date.now() + json.expires_in * 1000,
}) })
} }
@@ -55,6 +56,7 @@ export namespace AuthAnthropic {
export async function access() { export async function access() {
const info = await Auth.get("anthropic") const info = await Auth.get("anthropic")
if (!info || info.type !== "oauth") return if (!info || info.type !== "oauth") return
if (info.access && info.expires > Date.now()) return info.access
const response = await fetch( const response = await fetch(
"https://console.anthropic.com/v1/oauth/token", "https://console.anthropic.com/v1/oauth/token",
{ {
@@ -74,6 +76,7 @@ export namespace AuthAnthropic {
await Auth.set("anthropic", { await Auth.set("anthropic", {
type: "oauth", type: "oauth",
refresh: json.refresh_token as string, refresh: json.refresh_token as string,
access: json.access_token as string,
expires: Date.now() + json.expires_in * 1000, expires: Date.now() + json.expires_in * 1000,
}) })
return json.access_token as string return json.access_token as string
+1
View File
@@ -7,6 +7,7 @@ export namespace Auth {
export const Oauth = z.object({ export const Oauth = z.object({
type: z.literal("oauth"), type: z.literal("oauth"),
refresh: z.string(), refresh: z.string(),
access: z.string(),
expires: z.number(), expires: z.number(),
}) })
+2 -2
View File
@@ -9,14 +9,14 @@ import { map, pipe, sortBy, values } from "remeda"
export const AuthCommand = cmd({ export const AuthCommand = cmd({
command: "auth", command: "auth",
describe: "manage credentials", describe: "Manage credentials",
builder: (yargs) => builder: (yargs) =>
yargs yargs
.command(AuthLoginCommand) .command(AuthLoginCommand)
.command(AuthLogoutCommand) .command(AuthLogoutCommand)
.command(AuthListCommand) .command(AuthListCommand)
.demandCommand(), .demandCommand(),
async handler() {}, async handler() { },
}) })
export const AuthListCommand = cmd({ export const AuthListCommand = cmd({
@@ -2,7 +2,6 @@ import { Server } from "../../server/server"
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import type { CommandModule } from "yargs" import type { CommandModule } from "yargs"
import { Config } from "../../config/config"
export const GenerateCommand = { export const GenerateCommand = {
command: "generate", command: "generate",
+70 -38
View File
@@ -6,68 +6,102 @@ import { Session } from "../../session"
import { Share } from "../../share/share" import { Share } from "../../share/share"
import { Message } from "../../session/message" import { Message } from "../../session/message"
import { UI } from "../ui" import { UI } from "../ui"
import { VERSION } from "../version" import { cmd } from "./cmd"
import { Flag } from "../../flag/flag"
const COLOR = [ import { Config } from "../../config/config"
UI.Style.TEXT_SUCCESS_BOLD,
UI.Style.TEXT_INFO_BOLD,
UI.Style.TEXT_HIGHLIGHT_BOLD,
UI.Style.TEXT_WARNING_BOLD,
]
const TOOL: Record<string, [string, string]> = { const TOOL: Record<string, [string, string]> = {
opencode_todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD], todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
opencode_todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD], todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD],
opencode_bash: ["Bash", UI.Style.TEXT_DANGER_BOLD], bash: ["Bash", UI.Style.TEXT_DANGER_BOLD],
opencode_edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD], edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD],
opencode_glob: ["Glob", UI.Style.TEXT_INFO_BOLD], glob: ["Glob", UI.Style.TEXT_INFO_BOLD],
opencode_grep: ["Grep", UI.Style.TEXT_INFO_BOLD], grep: ["Grep", UI.Style.TEXT_INFO_BOLD],
opencode_list: ["List", UI.Style.TEXT_INFO_BOLD], list: ["List", UI.Style.TEXT_INFO_BOLD],
opencode_read: ["Read", UI.Style.TEXT_HIGHLIGHT_BOLD], read: ["Read", UI.Style.TEXT_HIGHLIGHT_BOLD],
opencode_write: ["Write", UI.Style.TEXT_SUCCESS_BOLD], write: ["Write", UI.Style.TEXT_SUCCESS_BOLD],
websearch: ["Search", UI.Style.TEXT_DIM_BOLD],
} }
export const RunCommand = { export const RunCommand = cmd({
command: "run [message..]", command: "run [message..]",
describe: "Run OpenCode with a message", describe: "Run opencode with a message",
builder: (yargs: Argv) => { builder: (yargs: Argv) => {
return yargs return yargs
.positional("message", { .positional("message", {
describe: "Message to send", describe: "message to send",
type: "string", type: "string",
array: true, array: true,
default: [], default: [],
}) })
.option("continue", {
alias: ["c"],
describe: "Continue the last session",
type: "boolean",
})
.option("session", { .option("session", {
alias: ["s"],
describe: "Session ID to continue", describe: "Session ID to continue",
type: "string", type: "string",
}) })
.option("share", {
type: "boolean",
describe: "share the session",
})
.option("model", {
type: "string",
alias: ["m"],
describe: "Model to use in the format of provider/model",
})
}, },
handler: async (args: { handler: async (args) => {
message: string[]
session?: string
printLogs?: boolean
}) => {
const message = args.message.join(" ") const message = args.message.join(" ")
await App.provide( await App.provide(
{ {
cwd: process.cwd(), cwd: process.cwd(),
version: VERSION,
}, },
async () => { async () => {
await Share.init() await Share.init()
const session = args.session const session = await (async () => {
? await Session.get(args.session) if (args.continue) {
: await Session.create() const first = await Session.list().next()
if (first.done) return
return first.value
}
UI.println(UI.Style.TEXT_HIGHLIGHT_BOLD + "◍ OpenCode", VERSION) if (args.session) return Session.get(args.session)
return Session.create()
})()
if (!session) {
UI.error("Session not found")
return
}
UI.empty()
UI.println(UI.logo())
UI.empty() UI.empty()
UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message) UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message)
UI.empty() UI.empty()
UI.println(
UI.Style.TEXT_INFO_BOLD + const cfg = await Config.get()
"~ https://dev.opencode.ai/s/" + if (cfg.autoshare || Flag.OPENCODE_AUTO_SHARE || args.share) {
await Session.share(session.id)
UI.println(
UI.Style.TEXT_INFO_BOLD +
"~ https://opencode.ai/s/" +
session.id.slice(-8), session.id.slice(-8),
)
}
UI.empty()
const { providerID, modelID } = args.model
? Provider.parseModel(args.model)
: await Provider.defaultModel()
UI.println(
UI.Style.TEXT_NORMAL_BOLD + "@ ",
UI.Style.TEXT_NORMAL + `${providerID}/${modelID}`,
) )
UI.empty() UI.empty()
@@ -75,8 +109,8 @@ export const RunCommand = {
UI.println( UI.println(
color + `|`, color + `|`,
UI.Style.TEXT_NORMAL + UI.Style.TEXT_NORMAL +
UI.Style.TEXT_DIM + UI.Style.TEXT_DIM +
` ${type.padEnd(7, " ")}`, ` ${type.padEnd(7, " ")}`,
"", "",
UI.Style.TEXT_NORMAL + title, UI.Style.TEXT_NORMAL + title,
) )
@@ -113,8 +147,6 @@ export const RunCommand = {
printEvent(UI.Style.TEXT_NORMAL_BOLD, "Text", part.text) printEvent(UI.Style.TEXT_NORMAL_BOLD, "Text", part.text)
} }
}) })
const { providerID, modelID } = await Provider.defaultModel()
await Session.chat({ await Session.chat({
sessionID: session.id, sessionID: session.id,
providerID, providerID,
@@ -130,4 +162,4 @@ export const RunCommand = {
}, },
) )
}, },
} })
+7 -5
View File
@@ -1,6 +1,5 @@
import { App } from "../../app/app" import { App } from "../../app/app"
import { LSP } from "../../lsp" import { LSP } from "../../lsp"
import { VERSION } from "../version"
import { cmd } from "./cmd" import { cmd } from "./cmd"
export const ScrapCommand = cmd({ export const ScrapCommand = cmd({
@@ -8,9 +7,12 @@ export const ScrapCommand = cmd({
builder: (yargs) => builder: (yargs) =>
yargs.positional("file", { type: "string", demandOption: true }), yargs.positional("file", { type: "string", demandOption: true }),
async handler(args) { async handler(args) {
await App.provide({ cwd: process.cwd(), version: VERSION }, async (app) => { await App.provide(
await LSP.touchFile(args.file, true) { cwd: process.cwd() },
console.log(await LSP.diagnostics()) async () => {
}) await LSP.touchFile(args.file, true)
console.log(await LSP.diagnostics())
},
)
}, },
}) })
+15 -163
View File
@@ -1,120 +1,14 @@
import type { Argv } from "yargs" import type { Argv } from "yargs"
import { UI } from "../ui" import { UI } from "../ui"
import { VERSION } from "../version"
import path from "path"
import fs from "fs/promises"
import os from "os"
import * as prompts from "@clack/prompts" import * as prompts from "@clack/prompts"
import { Global } from "../../global" import { Installation } from "../../installation"
const API = "https://api.github.com/repos/sst/opencode"
interface Release {
tag_name: string
name: string
assets: Array<{
name: string
browser_download_url: string
}>
}
function asset(): string {
const platform = os.platform()
const arch = os.arch()
if (platform === "darwin") {
return arch === "arm64"
? "opencode-darwin-arm64.zip"
: "opencode-darwin-x64.zip"
}
if (platform === "linux") {
return arch === "arm64"
? "opencode-linux-arm64.zip"
: "opencode-linux-x64.zip"
}
if (platform === "win32") {
return "opencode-windows-x64.zip"
}
throw new Error(`Unsupported platform: ${platform}-${arch}`)
}
function compare(current: string, latest: string): number {
const a = current.replace(/^v/, "")
const b = latest.replace(/^v/, "")
const aParts = a.split(".").map(Number)
const bParts = b.split(".").map(Number)
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
const aPart = aParts[i] || 0
const bPart = bParts[i] || 0
if (aPart < bPart) return -1
if (aPart > bPart) return 1
}
return 0
}
async function latest(): Promise<Release> {
const response = await fetch(`${API}/releases/latest`)
if (!response.ok) {
throw new Error(`Failed to fetch latest release: ${response.statusText}`)
}
return response.json()
}
async function specific(version: string): Promise<Release> {
const tag = version.startsWith("v") ? version : `v${version}`
const response = await fetch(`${API}/releases/tags/${tag}`)
if (!response.ok) {
throw new Error(`Failed to fetch release ${tag}: ${response.statusText}`)
}
return response.json()
}
async function download(url: string): Promise<string> {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to download: ${response.statusText}`)
}
const buffer = await response.arrayBuffer()
const temp = path.join(Global.Path.cache, `opencode-update-${Date.now()}.zip`)
await Bun.write(temp, buffer)
const extractDir = path.join(
Global.Path.cache,
`opencode-extract-${Date.now()}`,
)
await fs.mkdir(extractDir, { recursive: true })
const proc = Bun.spawn(["unzip", "-o", temp, "-d", extractDir], {
stdout: "pipe",
stderr: "pipe",
})
const result = await proc.exited
if (result !== 0) {
throw new Error("Failed to extract update")
}
await fs.unlink(temp)
const binary = path.join(extractDir, "opencode")
await fs.chmod(binary, 0o755)
return binary
}
export const UpgradeCommand = { export const UpgradeCommand = {
command: "upgrade [target]", command: "upgrade [target]",
describe: "Upgrade opencode to the latest version or a specific version", describe: "upgrade opencode to the latest version or a specific version",
builder: (yargs: Argv) => { builder: (yargs: Argv) => {
return yargs.positional("target", { return yargs.positional("target", {
describe: "Specific version to upgrade to (e.g., '0.1.48' or 'v0.1.48')", describe: "specific version to upgrade to (e.g., '0.1.48' or 'v0.1.48')",
type: "string", type: "string",
}) })
}, },
@@ -123,70 +17,28 @@ export const UpgradeCommand = {
UI.println(UI.logo(" ")) UI.println(UI.logo(" "))
UI.empty() UI.empty()
prompts.intro("Upgrade") prompts.intro("Upgrade")
const method = await Installation.method()
if (!process.execPath.includes(path.join(".opencode", "bin")) && false) { if (method === "unknown") {
prompts.log.error( prompts.log.error(
`opencode is installed to ${process.execPath} and seems to be managed by a package manager`, `opencode is installed to ${process.execPath} and seems to be managed by a package manager`,
) )
prompts.outro("Done") prompts.outro("Done")
return return
} }
const target = args.target ?? (await Installation.latest())
const release = args.target prompts.log.info(`From ${Installation.VERSION}${target}`)
? await specific(args.target).catch(() => {})
: await latest().catch(() => {})
if (!release) {
prompts.log.error("Failed to fetch release information")
prompts.outro("Done")
return
}
const target = release.tag_name
if (VERSION !== "dev" && compare(VERSION, target) >= 0) {
prompts.log.success(`Already up to date`)
prompts.outro("Done")
return
}
prompts.log.info(`From ${VERSION}${target}`)
const name = asset()
const found = release.assets.find((a) => a.name === name)
if (!found) {
prompts.log.error(`No binary found for platform: ${name}`)
prompts.outro("Done")
return
}
const spinner = prompts.spinner() const spinner = prompts.spinner()
spinner.start("Downloading update...") spinner.start("Upgrading...")
const err = await Installation.upgrade(method, target).catch((err) => err)
const downloadPath = await download(found.browser_download_url).catch( if (err) {
() => {}, spinner.stop("Upgrade failed")
) if (err instanceof Installation.UpgradeFailedError)
if (!downloadPath) { prompts.log.error(err.data.stderr)
spinner.stop("Download failed") else if (err instanceof Error) prompts.log.error(err.message)
prompts.log.error("Download failed")
prompts.outro("Done") prompts.outro("Done")
return return
} }
spinner.stop("Upgrade complete")
spinner.stop("Download complete")
const renamed = await fs
.rename(downloadPath, process.execPath)
.catch(() => {})
if (renamed === undefined) {
prompts.log.error("Install failed")
await fs.unlink(downloadPath).catch(() => {})
prompts.outro("Done")
return
}
prompts.log.success(`Successfully upgraded to ${target}`)
prompts.outro("Done") prompts.outro("Done")
}, },
} }
+13
View File
@@ -0,0 +1,13 @@
import { Config } from "../config/config"
export function FormatError(input: unknown) {
if (Config.JsonError.isInstance(input))
return `Config file at ${input.data.path} is not valid JSON`
if (Config.InvalidError.isInstance(input))
return [
`Config file at ${input.data.path} is invalid`,
...(input.data.issues?.map(
(issue) => "↳ " + issue.message + " " + issue.path.join("."),
) ?? []),
].join("\n")
}
+4
View File
@@ -71,4 +71,8 @@ export namespace UI {
}) })
}) })
} }
export function error(message: string) {
println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message)
}
} }
-6
View File
@@ -1,6 +0,0 @@
declare global {
const OPENCODE_VERSION: string
}
export const VERSION =
typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "dev"
+193 -31
View File
@@ -1,56 +1,159 @@
import { Log } from "../util/log" import { Log } from "../util/log"
import path from "path"
import { z } from "zod" import { z } from "zod"
import { App } from "../app/app" import { App } from "../app/app"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
import { ModelsDev } from "../provider/models" import { ModelsDev } from "../provider/models"
import { mergeDeep } from "remeda"
import { Global } from "../global"
import fs from "fs/promises"
import { lazy } from "../util/lazy"
import { NamedError } from "../util/error"
export namespace Config { export namespace Config {
const log = Log.create({ service: "config" }) const log = Log.create({ service: "config" })
export const state = App.state("config", async (app) => { export const state = App.state("config", async (app) => {
let result: Info = {} let result = await global()
for (const file of ["opencode.jsonc", "opencode.json"]) { for (const file of ["opencode.jsonc", "opencode.json"]) {
const [resolved] = await Filesystem.findUp( const found = await Filesystem.findUp(file, app.path.cwd, app.path.root)
file, for (const resolved of found.toReversed()) {
app.path.cwd, result = mergeDeep(result, await load(resolved))
app.path.root,
)
if (!resolved) continue
try {
result = await import(resolved).then((mod) => Info.parse(mod.default))
log.info("found", { path: resolved })
break
} catch (e) {
if (e instanceof z.ZodError) {
for (const issue of e.issues) {
log.info(issue.message)
}
throw e
}
continue
} }
} }
log.info("loaded", result) log.info("loaded", result)
return result return result
}) })
export const McpLocal = z.object({ export const McpLocal = z
type: z.literal("local"), .object({
command: z.string().array(), type: z.literal("local").describe("Type of MCP server connection"),
environment: z.record(z.string(), z.string()).optional(), command: z
}) .string()
.array()
.describe("Command and arguments to run the MCP server"),
environment: z
.record(z.string(), z.string())
.optional()
.describe("Environment variables to set when running the MCP server"),
})
.strict()
.openapi({
ref: "Config.McpLocal",
})
export const McpRemote = z.object({ export const McpRemote = z
type: z.literal("remote"), .object({
url: z.string(), type: z.literal("remote").describe("Type of MCP server connection"),
}) url: z.string().describe("URL of the remote MCP server"),
})
.strict()
.openapi({
ref: "Config.McpRemote",
})
export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote]) export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
export type Mcp = z.infer<typeof Mcp> export type Mcp = z.infer<typeof Mcp>
export const Keybinds = z
.object({
leader: z
.string()
.optional()
.describe("Leader key for keybind combinations"),
help: z.string().optional().describe("Show help dialog"),
editor_open: z.string().optional().describe("Open external editor"),
session_new: z.string().optional().describe("Create a new session"),
session_list: z.string().optional().describe("List all sessions"),
session_share: z.string().optional().describe("Share current session"),
session_interrupt: z
.string()
.optional()
.describe("Interrupt current session"),
session_compact: z
.string()
.optional()
.describe("Toggle compact mode for session"),
tool_details: z.string().optional().describe("Show tool details"),
model_list: z.string().optional().describe("List available models"),
theme_list: z.string().optional().describe("List available themes"),
project_init: z
.string()
.optional()
.describe("Initialize project configuration"),
input_clear: z.string().optional().describe("Clear input field"),
input_paste: z.string().optional().describe("Paste from clipboard"),
input_submit: z.string().optional().describe("Submit input"),
input_newline: z.string().optional().describe("Insert newline in input"),
history_previous: z
.string()
.optional()
.describe("Navigate to previous history item"),
history_next: z
.string()
.optional()
.describe("Navigate to next history item"),
messages_page_up: z
.string()
.optional()
.describe("Scroll messages up by one page"),
messages_page_down: z
.string()
.optional()
.describe("Scroll messages down by one page"),
messages_half_page_up: z
.string()
.optional()
.describe("Scroll messages up by half page"),
messages_half_page_down: z
.string()
.optional()
.describe("Scroll messages down by half page"),
messages_previous: z
.string()
.optional()
.describe("Navigate to previous message"),
messages_next: z.string().optional().describe("Navigate to next message"),
messages_first: z
.string()
.optional()
.describe("Navigate to first message"),
messages_last: z.string().optional().describe("Navigate to last message"),
app_exit: z.string().optional().describe("Exit the application"),
})
.strict()
.openapi({
ref: "Config.Keybinds",
})
export const Info = z export const Info = z
.object({ .object({
$schema: z.string().optional(), $schema: z
.string()
.optional()
.describe("JSON schema reference for configuration validation"),
theme: z
.string()
.optional()
.describe("Theme name to use for the interface"),
keybinds: Keybinds.optional().describe("Custom keybind configurations"),
autoshare: z
.boolean()
.optional()
.describe("Share newly created sessions automatically"),
autoupdate: z
.boolean()
.optional()
.describe("Automatically update to the latest version"),
disabled_providers: z
.array(z.string())
.optional()
.describe("Disable providers that are loaded automatically"),
model: z
.string()
.describe(
"Model to use in the format of provider/model, eg anthropic/claude-2",
)
.optional(),
provider: z provider: z
.record( .record(
ModelsDev.Provider.partial().extend({ ModelsDev.Provider.partial().extend({
@@ -58,13 +161,72 @@ export namespace Config {
options: z.record(z.any()).optional(), options: z.record(z.any()).optional(),
}), }),
) )
.optional(), .optional()
mcp: z.record(z.string(), Mcp).optional(), .describe("Custom provider configurations and model overrides"),
mcp: z
.record(z.string(), Mcp)
.optional()
.describe("MCP (Model Context Protocol) server configurations"),
}) })
.strict() .strict()
.openapi({
ref: "Config.Info",
})
export type Info = z.output<typeof Info> export type Info = z.output<typeof Info>
export const global = lazy(async () => {
let result = await load(path.join(Global.Path.config, "config.json"))
await import(path.join(Global.Path.config, "config"), {
with: {
type: "toml",
},
})
.then(async (mod) => {
const { provider, model, ...rest } = mod.default
if (provider && model) result.model = `${provider}/${model}`
result["$schema"] = "https://opencode.ai/config.json"
result = mergeDeep(result, rest)
await Bun.write(
path.join(Global.Path.config, "config.json"),
JSON.stringify(result, null, 2),
)
await fs.unlink(path.join(Global.Path.config, "config"))
})
.catch(() => {})
return result
})
async function load(path: string) {
const data = await Bun.file(path)
.json()
.catch((err) => {
if (err.code === "ENOENT") return {}
throw new JsonError({ path }, { cause: err })
})
const parsed = Info.safeParse(data)
if (parsed.success) return parsed.data
throw new InvalidError({ path, issues: parsed.error.issues })
}
export const JsonError = NamedError.create(
"ConfigJsonError",
z.object({
path: z.string(),
}),
)
export const InvalidError = NamedError.create(
"ConfigInvalidError",
z.object({
path: z.string(),
issues: z.custom<z.ZodIssue[]>().optional(),
}),
)
export function get() { export function get() {
return state() return state()
} }
-1
View File
@@ -1,4 +1,3 @@
import { App } from "../app/app"
import path from "path" import path from "path"
import { Global } from "../global" import { Global } from "../global"
import fs from "fs/promises" import fs from "fs/promises"
-10
View File
@@ -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,
})) {
}
}
}
+12 -7
View File
@@ -1,5 +1,5 @@
import fs from "fs/promises" import fs from "fs/promises"
import { xdgData, xdgCache, xdgConfig } from "xdg-basedir" import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir"
import path from "path" import path from "path"
const app = "opencode" const app = "opencode"
@@ -7,18 +7,23 @@ const app = "opencode"
const data = path.join(xdgData!, app) const data = path.join(xdgData!, app)
const cache = path.join(xdgCache!, app) const cache = path.join(xdgCache!, app)
const config = path.join(xdgConfig!, app) const config = path.join(xdgConfig!, app)
const state = path.join(xdgState!, app)
await Promise.all([
fs.mkdir(data, { recursive: true }),
fs.mkdir(config, { recursive: true }),
fs.mkdir(cache, { recursive: true }),
])
export namespace Global { export namespace Global {
export const Path = { export const Path = {
data, data,
bin: path.join(data, "bin"), bin: path.join(data, "bin"),
providers: path.join(config, "providers"),
cache, cache,
config, config,
state,
} as const } as const
} }
await Promise.all([
fs.mkdir(Global.Path.data, { recursive: true }),
fs.mkdir(Global.Path.config, { recursive: true }),
fs.mkdir(Global.Path.cache, { recursive: true }),
fs.mkdir(Global.Path.providers, { recursive: true }),
fs.mkdir(Global.Path.state, { recursive: true }),
])
+85 -51
View File
@@ -9,17 +9,21 @@ import yargs from "yargs"
import { hideBin } from "yargs/helpers" import { hideBin } from "yargs/helpers"
import { RunCommand } from "./cli/cmd/run" import { RunCommand } from "./cli/cmd/run"
import { GenerateCommand } from "./cli/cmd/generate" import { GenerateCommand } from "./cli/cmd/generate"
import { VERSION } from "./cli/version"
import { ScrapCommand } from "./cli/cmd/scrap" import { ScrapCommand } from "./cli/cmd/scrap"
import { Log } from "./util/log" import { Log } from "./util/log"
import { AuthCommand, AuthLoginCommand } from "./cli/cmd/auth" import { AuthCommand, AuthLoginCommand } from "./cli/cmd/auth"
import { UpgradeCommand } from "./cli/cmd/upgrade" import { UpgradeCommand } from "./cli/cmd/upgrade"
import { Provider } from "./provider/provider" import { Provider } from "./provider/provider"
import { UI } from "./cli/ui" import { UI } from "./cli/ui"
import { Installation } from "./installation"
import { Bus } from "./bus"
import { Config } from "./config/config"
import { NamedError } from "./util/error"
import { FormatError } from "./cli/error"
const cli = yargs(hideBin(process.argv)) const cli = yargs(hideBin(process.argv))
.scriptName("opencode") .scriptName("opencode")
.version(VERSION) .version(Installation.VERSION)
.option("print-logs", { .option("print-logs", {
describe: "Print logs to stderr", describe: "Print logs to stderr",
type: "boolean", type: "boolean",
@@ -27,14 +31,14 @@ const cli = yargs(hideBin(process.argv))
.middleware(async () => { .middleware(async () => {
await Log.init({ print: process.argv.includes("--print-logs") }) await Log.init({ print: process.argv.includes("--print-logs") })
Log.Default.info("opencode", { Log.Default.info("opencode", {
version: VERSION, version: Installation.VERSION,
args: process.argv.slice(2), args: process.argv.slice(2),
}) })
}) })
.usage("\n" + UI.logo()) .usage("\n" + UI.logo())
.command({ .command({
command: "$0 [project]", command: "$0 [project]",
describe: "Start opencode TUI", describe: "start opencode TUI",
builder: (yargs) => builder: (yargs) =>
yargs.positional("project", { yargs.positional("project", {
type: "string", type: "string",
@@ -44,52 +48,65 @@ const cli = yargs(hideBin(process.argv))
while (true) { while (true) {
const cwd = args.project ? path.resolve(args.project) : process.cwd() const cwd = args.project ? path.resolve(args.project) : process.cwd()
process.chdir(cwd) process.chdir(cwd)
const result = await App.provide( const result = await App.provide({ cwd }, async (app) => {
{ cwd, version: VERSION }, const providers = await Provider.list()
async (app) => { if (Object.keys(providers).length === 0) {
const providers = await Provider.list() return "needs_provider"
if (Object.keys(providers).length === 0) { }
return "needs_provider"
await Share.init()
const server = Server.listen()
let cmd = ["go", "run", "./main.go"]
let cwd = new URL("../../tui/cmd/opencode", import.meta.url).pathname
if (Bun.embeddedFiles.length > 0) {
const blob = Bun.embeddedFiles[0] as File
const binary = path.join(Global.Path.cache, "tui", blob.name)
const file = Bun.file(binary)
if (!(await file.exists())) {
await Bun.write(file, blob, { mode: 0o755 })
await fs.chmod(binary, 0o755)
} }
cwd = process.cwd()
cmd = [binary]
}
const proc = Bun.spawn({
cmd: [...cmd, ...process.argv.slice(2)],
cwd,
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
env: {
...process.env,
OPENCODE_SERVER: server.url.toString(),
OPENCODE_APP_INFO: JSON.stringify(app),
},
onExit: () => {
server.stop()
},
})
await Share.init() ;(async () => {
const server = Server.listen() if (Installation.VERSION === "dev") return
if (Installation.isSnapshot()) return
const config = await Config.global()
if (config.autoupdate === false) return
const latest = await Installation.latest()
if (Installation.VERSION === latest) return
const method = await Installation.method()
if (method === "unknown") return
await Installation.upgrade(method, latest)
.then(() => {
Bus.publish(Installation.Event.Updated, { version: latest })
})
.catch(() => {})
})()
let cmd = ["go", "run", "./main.go"] await proc.exited
let cwd = new URL("../../tui/cmd/opencode", import.meta.url) server.stop()
.pathname
if (Bun.embeddedFiles.length > 0) {
const blob = Bun.embeddedFiles[0] as File
const binary = path.join(Global.Path.cache, "tui", blob.name)
const file = Bun.file(binary)
if (!(await file.exists())) {
await Bun.write(file, blob, { mode: 0o755 })
await fs.chmod(binary, 0o755)
}
cwd = process.cwd()
cmd = [binary]
}
const proc = Bun.spawn({
cmd: [...cmd, ...process.argv.slice(2)],
cwd,
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
env: {
...process.env,
OPENCODE_SERVER: server.url.toString(),
OPENCODE_APP_INFO: JSON.stringify(app),
},
onExit: () => {
server.stop()
},
})
await proc.exited
await server.stop()
return "done" return "done"
}, })
)
if (result === "done") break if (result === "done") break
if (result === "needs_provider") { if (result === "needs_provider") {
UI.empty() UI.empty()
@@ -105,21 +122,38 @@ const cli = yargs(hideBin(process.argv))
.command(ScrapCommand) .command(ScrapCommand)
.command(AuthCommand) .command(AuthCommand)
.command(UpgradeCommand) .command(UpgradeCommand)
.fail((msg, err) => { .fail((msg) => {
if ( if (
msg.startsWith("Unknown argument") || msg.startsWith("Unknown argument") ||
msg.startsWith("Not enough non-option arguments") msg.startsWith("Not enough non-option arguments")
) { ) {
cli.showHelp("log") cli.showHelp("log")
} }
Log.Default.error(msg, {
err,
})
}) })
.strict() .strict()
try { try {
await cli.parse() await cli.parse()
} catch (e) { } catch (e) {
Log.Default.error(e) const data: Record<string, any> = {}
if (e instanceof NamedError) {
const obj = e.toObject()
Object.assign(data, {
...obj.data,
})
}
if (e instanceof Error) {
Object.assign(data, {
name: e.name,
message: e.message,
cause: e.cause?.toString(),
})
}
Log.Default.error("fatal", data)
const formatted = FormatError(e)
if (formatted) UI.error(formatted)
if (!formatted)
UI.error(
"Unexpected error, check log file at " + Log.file() + " for more details",
)
} }
+126
View 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)
}
}
+3
View File
@@ -70,6 +70,9 @@ export namespace LSPServer {
const proc = Bun.spawn({ const proc = Bun.spawn({
cmd: ["go", "install", "golang.org/x/tools/gopls@latest"], cmd: ["go", "install", "golang.org/x/tools/gopls@latest"],
env: { ...process.env, GOBIN: Global.Path.bin }, env: { ...process.env, GOBIN: Global.Path.bin },
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}) })
const exit = await proc.exited const exit = await proc.exited
if (exit !== 0) { if (exit !== 0) {
@@ -64,6 +64,7 @@ export namespace Permission {
title: Info["title"] title: Info["title"]
metadata: Info["metadata"] metadata: Info["metadata"]
}) { }) {
return
const { pending, approved } = state() const { pending, approved } = state()
log.info("asking", { log.info("asking", {
sessionID: input.sessionID, sessionID: input.sessionID,
+5 -29
View File
@@ -1,5 +1,4 @@
import { Global } from "../global" import { Global } from "../global"
import { lazy } from "../util/lazy"
import { Log } from "../util/log" import { Log } from "../util/log"
import path from "path" import path from "path"
import { z } from "zod" import { z } from "zod"
@@ -14,17 +13,19 @@ export namespace ModelsDev {
attachment: z.boolean(), attachment: z.boolean(),
reasoning: z.boolean(), reasoning: z.boolean(),
temperature: z.boolean(), temperature: z.boolean(),
tool_call: z.boolean(),
cost: z.object({ cost: z.object({
input: z.number(), input: z.number(),
output: z.number(), output: z.number(),
inputCached: z.number(), cache_read: z.number().optional(),
outputCached: z.number(), cache_write: z.number().optional(),
}), }),
limit: z.object({ limit: z.object({
context: z.number(), context: z.number(),
output: z.number(), output: z.number(),
}), }),
id: z.string(), id: z.string(),
options: z.record(z.any()),
}) })
.openapi({ .openapi({
ref: "Model.Info", ref: "Model.Info",
@@ -33,6 +34,7 @@ export namespace ModelsDev {
export const Provider = z export const Provider = z
.object({ .object({
api: z.string().optional(),
name: z.string(), name: z.string(),
env: z.array(z.string()), env: z.array(z.string()),
id: z.string(), id: z.string(),
@@ -64,30 +66,4 @@ export namespace ModelsDev {
throw new Error(`Failed to fetch models.dev: ${result.statusText}`) throw new Error(`Failed to fetch models.dev: ${result.statusText}`)
await Bun.write(file, result) await Bun.write(file, result)
} }
const aisdk = lazy(async () => {
log.info("fetching ai-sdk")
const response = await fetch(
"https://registry.npmjs.org/-/v1/search?text=scope:@ai-sdk",
)
if (!response.ok)
throw new Error(
`Failed to fetch ai-sdk information: ${response.statusText}`,
)
const result = await response.json()
log.info("found ai-sdk", result.objects.length)
return result.objects
.filter((obj: any) => obj.package.name.startsWith("@ai-sdk/"))
.reduce((acc: any, obj: any) => {
acc[obj.package.name] = obj
return acc
}, {})
})
export async function pkg(providerID: string): Promise<[string, string]> {
const packages = await aisdk()
const match = packages[`@ai-sdk/${providerID}`]
if (match) return [match.package.name, "latest"]
return [providerID, "latest"]
}
} }
+147 -36
View File
@@ -27,9 +27,13 @@ import { TaskTool } from "../tool/task"
export namespace Provider { export namespace Provider {
const log = Log.create({ service: "provider" }) const log = Log.create({ service: "provider" })
type CustomLoader = ( type CustomLoader = (provider: ModelsDev.Provider) => Promise<
provider: ModelsDev.Provider, | {
) => Promise<Record<string, any> | false> getModel?: (sdk: any, modelID: string) => Promise<any>
options: Record<string, any>
}
| false
>
type Source = "env" | "config" | "custom" | "api" type Source = "env" | "config" | "custom" | "api"
@@ -40,27 +44,56 @@ export namespace Provider {
for (const model of Object.values(provider.models)) { for (const model of Object.values(provider.models)) {
model.cost = { model.cost = {
input: 0, input: 0,
inputCached: 0,
output: 0, output: 0,
outputCached: 0,
} }
} }
return { return {
apiKey: "", options: {
headers: { apiKey: "",
authorization: `Bearer ${access}`, async fetch(input: any, init: any) {
"anthropic-beta": "oauth-2025-04-20", const access = await AuthAnthropic.access()
const headers = {
...init.headers,
authorization: `Bearer ${access}`,
"anthropic-beta": "oauth-2025-04-20",
}
delete headers["x-api-key"]
return fetch(input, {
...init,
headers,
})
},
}, },
} }
}, },
openai: async () => {
return {
async getModel(sdk: any, modelID: string) {
return sdk.responses(modelID)
},
options: {},
}
},
"amazon-bedrock": async () => { "amazon-bedrock": async () => {
if (!process.env["AWS_PROFILE"]) return false if (!process.env["AWS_PROFILE"]) false
const region = process.env["AWS_REGION"] ?? "us-east-1"
const { fromNodeProviderChain } = await import( const { fromNodeProviderChain } = await import(
await BunProc.install("@aws-sdk/credential-providers") await BunProc.install("@aws-sdk/credential-providers")
) )
return { return {
region: process.env["AWS_REGION"] ?? "us-east-1", options: {
credentialProvider: fromNodeProviderChain(), region,
credentialProvider: fromNodeProviderChain(),
},
async getModel(sdk: any, modelID: string) {
if (modelID.includes("claude")) {
const prefix = region.split("-")[0]
modelID = `${prefix}.${modelID}`
}
return sdk.languageModel(modelID)
},
} }
}, },
} }
@@ -73,6 +106,7 @@ export namespace Provider {
[providerID: string]: { [providerID: string]: {
source: Source source: Source
info: ModelsDev.Provider info: ModelsDev.Provider
getModel?: (sdk: any, modelID: string) => Promise<any>
options: Record<string, any> options: Record<string, any>
} }
} = {} } = {}
@@ -88,23 +122,28 @@ export namespace Provider {
id: string, id: string,
options: Record<string, any>, options: Record<string, any>,
source: Source, source: Source,
getModel?: (sdk: any, modelID: string) => Promise<any>,
) { ) {
const provider = providers[id] const provider = providers[id]
if (!provider) { if (!provider) {
const info = database[id]
if (!info) return
if (info.api) options["baseURL"] = info.api
providers[id] = { providers[id] = {
source, source,
info: database[id], info,
options, options,
} }
return return
} }
provider.options = mergeDeep(provider.options, options) provider.options = mergeDeep(provider.options, options)
provider.source = source provider.source = source
provider.getModel = getModel ?? provider.getModel
} }
for (const [providerID, provider] of Object.entries( const configProviders = Object.entries(config.provider ?? {})
config.provider ?? {},
)) { for (const [providerID, provider] of configProviders) {
const existing = database[providerID] const existing = database[providerID]
const parsed: ModelsDev.Provider = { const parsed: ModelsDev.Provider = {
id: providerID, id: providerID,
@@ -122,13 +161,19 @@ export namespace Provider {
attachment: model.attachment ?? existing?.attachment ?? false, attachment: model.attachment ?? existing?.attachment ?? false,
reasoning: model.reasoning ?? existing?.reasoning ?? false, reasoning: model.reasoning ?? existing?.reasoning ?? false,
temperature: model.temperature ?? existing?.temperature ?? false, temperature: model.temperature ?? existing?.temperature ?? false,
cost: model.cost ?? tool_call: model.tool_call ?? existing?.tool_call ?? true,
existing?.cost ?? { cost: {
input: 0, ...existing?.cost,
output: 0, ...model.cost,
inputCached: 0, input: 0,
outputCached: 0, output: 0,
}, cache_read: 0,
cache_write: 0,
},
options: {
...existing?.options,
...model.options,
},
limit: model.limit ?? limit: model.limit ??
existing?.limit ?? { existing?.limit ?? {
context: 0, context: 0,
@@ -140,8 +185,12 @@ export namespace Provider {
database[providerID] = parsed database[providerID] = parsed
} }
const disabled = await Config.get().then(
(cfg) => new Set(cfg.disabled_providers ?? []),
)
// load env // load env
for (const [providerID, provider] of Object.entries(database)) { for (const [providerID, provider] of Object.entries(database)) {
if (disabled.has(providerID)) continue
if (provider.env.some((item) => process.env[item])) { if (provider.env.some((item) => process.env[item])) {
mergeProvider(providerID, {}, "env") mergeProvider(providerID, {}, "env")
} }
@@ -149,6 +198,7 @@ export namespace Provider {
// load apikeys // load apikeys
for (const [providerID, provider] of Object.entries(await Auth.all())) { for (const [providerID, provider] of Object.entries(await Auth.all())) {
if (disabled.has(providerID)) continue
if (provider.type === "api") { if (provider.type === "api") {
mergeProvider(providerID, { apiKey: provider.key }, "api") mergeProvider(providerID, { apiKey: provider.key }, "api")
} }
@@ -156,18 +206,22 @@ export namespace Provider {
// load custom // load custom
for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) { for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) {
if (disabled.has(providerID)) continue
const result = await fn(database[providerID]) const result = await fn(database[providerID])
if (result) mergeProvider(providerID, result, "custom") if (result)
mergeProvider(providerID, result.options, "custom", result.getModel)
} }
// load config // load config
for (const [providerID, provider] of Object.entries( for (const [providerID, provider] of configProviders) {
config.provider ?? {},
)) {
mergeProvider(providerID, provider.options ?? {}, "config") mergeProvider(providerID, provider.options ?? {}, "config")
} }
for (const providerID of Object.keys(providers)) { for (const [providerID, provider] of Object.entries(providers)) {
if (Object.keys(provider.info.models).length === 0) {
delete providers[providerID]
continue
}
log.info("found", { providerID }) log.info("found", { providerID })
} }
@@ -190,8 +244,8 @@ export namespace Provider {
const s = await state() const s = await state()
const existing = s.sdk.get(provider.id) const existing = s.sdk.get(provider.id)
if (existing) return existing if (existing) return existing
const [pkg, version] = await ModelsDev.pkg(provider.npm ?? provider.id) const pkg = provider.npm ?? provider.id
const mod = await import(await BunProc.install(pkg, version)) const mod = await import(await BunProc.install(pkg, "latest"))
const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!] const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!]
const loaded = fn(s.providers[provider.id]?.options) const loaded = fn(s.providers[provider.id]?.options)
s.sdk.set(provider.id, loaded) s.sdk.set(provider.id, loaded)
@@ -218,9 +272,9 @@ export namespace Provider {
const sdk = await getSDK(provider.info) const sdk = await getSDK(provider.info)
try { try {
const language = const language = provider.getModel
// @ts-expect-error ? await provider.getModel(sdk, modelID)
"responses" in sdk ? sdk.responses(modelID) : sdk.languageModel(modelID) : sdk.languageModel(modelID)
log.info("found", { providerID, modelID }) log.info("found", { providerID, modelID })
s.models.set(key, { s.models.set(key, {
info, info,
@@ -257,7 +311,15 @@ export namespace Provider {
} }
export async function defaultModel() { export async function defaultModel() {
const [provider] = await list().then((val) => Object.values(val)) const cfg = await Config.get()
if (cfg.model) return parseModel(cfg.model)
const provider = await list()
.then((val) => Object.values(val))
.then((x) =>
x.find(
(p) => !cfg.provider || Object.keys(cfg.provider).includes(p.info.id),
),
)
if (!provider) throw new Error("no providers found") if (!provider) throw new Error("no providers found")
const [model] = sort(Object.values(provider.info.models)) const [model] = sort(Object.values(provider.info.models))
if (!model) throw new Error("no models found") if (!model) throw new Error("no models found")
@@ -267,6 +329,14 @@ export namespace Provider {
} }
} }
export function parseModel(model: string) {
const [providerID, ...rest] = model.split("/")
return {
providerID: providerID,
modelID: rest.join("/"),
}
}
const TOOLS = [ const TOOLS = [
BashTool, BashTool,
EditTool, EditTool,
@@ -285,11 +355,20 @@ export namespace Provider {
TaskTool, TaskTool,
TodoReadTool, TodoReadTool,
] ]
const TOOL_MAPPING: Record<string, Tool.Info[]> = { const TOOL_MAPPING: Record<string, Tool.Info[]> = {
anthropic: TOOLS.filter((t) => t.id !== "opencode.patch"), anthropic: TOOLS.filter((t) => t.id !== "patch"),
openai: TOOLS, openai: TOOLS.map((t) => ({
...t,
parameters: optionalToNullable(t.parameters),
})),
azure: TOOLS.map((t) => ({
...t,
parameters: optionalToNullable(t.parameters),
})),
google: TOOLS, google: TOOLS,
} }
export async function tools(providerID: string) { export async function tools(providerID: string) {
/* /*
const cfg = await Config.get() const cfg = await Config.get()
@@ -301,6 +380,38 @@ export namespace Provider {
return TOOL_MAPPING[providerID] ?? TOOLS return TOOL_MAPPING[providerID] ?? TOOLS
} }
function optionalToNullable(schema: z.ZodTypeAny): z.ZodTypeAny {
if (schema instanceof z.ZodObject) {
const shape = schema.shape
const newShape: Record<string, z.ZodTypeAny> = {}
for (const [key, value] of Object.entries(shape)) {
const zodValue = value as z.ZodTypeAny
if (zodValue instanceof z.ZodOptional) {
newShape[key] = zodValue.unwrap().nullable()
} else {
newShape[key] = optionalToNullable(zodValue)
}
}
return z.object(newShape)
}
if (schema instanceof z.ZodArray) {
return z.array(optionalToNullable(schema.element))
}
if (schema instanceof z.ZodUnion) {
return z.union(
schema.options.map((option: z.ZodTypeAny) =>
optionalToNullable(option),
) as [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]],
)
}
return schema
}
export const ModelNotFoundError = NamedError.create( export const ModelNotFoundError = NamedError.create(
"ProviderModelNotFoundError", "ProviderModelNotFoundError",
z.object({ z.object({
@@ -0,0 +1,24 @@
import type { CoreMessage } from "ai"
export namespace ProviderTransform {
export function message(
msg: CoreMessage,
index: number,
providerID: string,
modelID: string,
) {
if (
(providerID === "anthropic" || modelID.includes("anthropic")) &&
index < 4
) {
msg.providerOptions = {
...msg.providerOptions,
anthropic: {
cacheControl: { type: "ephemeral" },
},
}
}
return msg
}
}
+40 -1
View File
@@ -12,9 +12,10 @@ import { App } from "../app/app"
import { Global } from "../global" import { Global } from "../global"
import { mapValues } from "remeda" import { mapValues } from "remeda"
import { NamedError } from "../util/error" import { NamedError } from "../util/error"
import { Fzf } from "../external/fzf"
import { ModelsDev } from "../provider/models" import { ModelsDev } from "../provider/models"
import { Ripgrep } from "../external/ripgrep" import { Ripgrep } from "../external/ripgrep"
import { Installation } from "../installation"
import { Config } from "../config/config"
const ERRORS = { const ERRORS = {
400: { 400: {
@@ -140,6 +141,25 @@ export namespace Server {
return c.json(App.info()) return c.json(App.info())
}, },
) )
.post(
"/config_get",
describeRoute({
description: "Get config info",
responses: {
200: {
description: "Get config info",
content: {
"application/json": {
schema: resolver(Config.Info),
},
},
},
},
}),
async (c) => {
return c.json(await Config.get())
},
)
.post( .post(
"/app_initialize", "/app_initialize",
describeRoute({ describeRoute({
@@ -466,6 +486,25 @@ export namespace Server {
return c.json(result) 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 return result
} }
+80 -44
View File
@@ -30,6 +30,9 @@ import type { Tool } from "../tool/tool"
import { SystemPrompt } from "./system" import { SystemPrompt } from "./system"
import { Flag } from "../flag/flag" import { Flag } from "../flag/flag"
import type { ModelsDev } from "../provider/models" import type { ModelsDev } from "../provider/models"
import { Installation } from "../installation"
import { Config } from "../config/config"
import { ProviderTransform } from "../provider/transform"
export namespace Session { export namespace Session {
const log = Log.create({ service: "session" }) const log = Log.create({ service: "session" })
@@ -40,11 +43,11 @@ export namespace Session {
parentID: Identifier.schema("session").optional(), parentID: Identifier.schema("session").optional(),
share: z share: z
.object({ .object({
secret: z.string(),
url: z.string(), url: z.string(),
}) })
.optional(), .optional(),
title: z.string(), title: z.string(),
version: z.string(),
time: z.object({ time: z.object({
created: z.number(), created: z.number(),
updated: z.number(), updated: z.number(),
@@ -55,6 +58,12 @@ export namespace Session {
}) })
export type Info = z.output<typeof Info> export type Info = z.output<typeof Info>
export const ShareInfo = z.object({
secret: z.string(),
url: z.string(),
})
export type ShareInfo = z.output<typeof ShareInfo>
export const Event = { export const Event = {
Updated: Bus.event( Updated: Bus.event(
"session.updated", "session.updated",
@@ -70,19 +79,30 @@ export namespace Session {
), ),
} }
const state = App.state("session", () => { const state = App.state(
const sessions = new Map<string, Info>() "session",
const messages = new Map<string, Message.Info[]>() () => {
const sessions = new Map<string, Info>()
const messages = new Map<string, Message.Info[]>()
const pending = new Map<string, AbortController>()
return { return {
sessions, sessions,
messages, messages,
} pending,
}) }
},
async (state) => {
for (const [_, controller] of state.pending) {
controller.abort()
}
},
)
export async function create(parentID?: string) { export async function create(parentID?: string) {
const result: Info = { const result: Info = {
id: Identifier.descending("session"), id: Identifier.descending("session"),
version: Installation.VERSION,
parentID, parentID,
title: title:
(parentID ? "Child session - " : "New Session - ") + (parentID ? "Child session - " : "New Session - ") +
@@ -95,7 +115,8 @@ export namespace Session {
log.info("created", result) log.info("created", result)
state().sessions.set(result.id, result) state().sessions.set(result.id, result)
await Storage.writeJSON("session/info/" + result.id, result) await Storage.writeJSON("session/info/" + result.id, result)
if (!result.parentID && Flag.OPENCODE_AUTO_SHARE) const cfg = await Config.get()
if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.autoshare))
share(result.id).then((share) => { share(result.id).then((share) => {
update(result.id, (draft) => { update(result.id, (draft) => {
draft.share = share draft.share = share
@@ -117,13 +138,21 @@ export namespace Session {
return read as Info return read as Info
} }
export async function getShare(id: string) {
return Storage.readJSON<ShareInfo>("session/share/" + id)
}
export async function share(id: string) { export async function share(id: string) {
const session = await get(id) const session = await get(id)
if (session.share) return session.share if (session.share) return session.share
const share = await Share.create(id) const share = await Share.create(id)
await update(id, (draft) => { await update(id, (draft) => {
draft.share = share draft.share = {
url: share.url,
}
}) })
await Storage.writeJSON<ShareInfo>("session/share/" + id, share)
await Share.sync("session/info/" + id, session)
for (const msg of await messages(id)) { for (const msg of await messages(id)) {
await Share.sync("session/message/" + id + "/" + msg.id, msg) await Share.sync("session/message/" + id + "/" + msg.id, msg)
} }
@@ -169,10 +198,10 @@ export namespace Session {
} }
export function abort(sessionID: string) { export function abort(sessionID: string) {
const controller = pending.get(sessionID) const controller = state().pending.get(sessionID)
if (!controller) return false if (!controller) return false
controller.abort() controller.abort()
pending.delete(sessionID) state().pending.delete(sessionID)
return true return true
} }
@@ -208,8 +237,9 @@ export namespace Session {
previous.metadata.assistant.tokens.cache.write + previous.metadata.assistant.tokens.cache.write +
previous.metadata.assistant.tokens.output previous.metadata.assistant.tokens.output
if ( if (
model.info.limit.context &&
tokens > tokens >
(model.info.limit.context - (model.info.limit.output ?? 0)) * 0.9 (model.info.limit.context - (model.info.limit.output ?? 0)) * 0.9
) { ) {
await summarize({ await summarize({
sessionID: input.sessionID, sessionID: input.sessionID,
@@ -232,20 +262,12 @@ export namespace Session {
if (msgs.length === 0 && !session.parentID) { if (msgs.length === 0 && !session.parentID) {
generateText({ generateText({
maxTokens: input.providerID === "google" ? 1024 : 20, maxTokens: input.providerID === "google" ? 1024 : 20,
providerOptions: model.info.options,
messages: [ messages: [
...SystemPrompt.title(input.providerID).map( ...SystemPrompt.title(input.providerID).map(
(x): CoreMessage => ({ (x): CoreMessage => ({
role: "system", role: "system",
content: x, content: x,
providerOptions: {
...(input.providerID === "anthropic"
? {
anthropic: {
cacheControl: { type: "ephemeral" },
},
}
: {}),
},
}), }),
), ),
...convertToCoreMessages([ ...convertToCoreMessages([
@@ -255,7 +277,9 @@ export namespace Session {
parts: toParts(input.parts), parts: toParts(input.parts),
}, },
]), ]),
], ].map((msg, i) =>
ProviderTransform.message(msg, i, input.providerID, input.modelID),
),
model: model.language, model: model.language,
}) })
.then((result) => { .then((result) => {
@@ -328,6 +352,16 @@ export namespace Session {
sessionID: input.sessionID, sessionID: input.sessionID,
abort: abort.signal, abort: abort.signal,
messageID: next.id, messageID: next.id,
metadata: async (val) => {
next.metadata.tool[opts.toolCallId] = {
...val,
time: {
start: 0,
end: 0,
},
}
await updateMessage(next)
},
}) })
next.metadata!.tool![opts.toolCallId] = { next.metadata!.tool![opts.toolCallId] = {
...result.metadata, ...result.metadata,
@@ -474,30 +508,22 @@ export namespace Session {
toolCallStreaming: true, toolCallStreaming: true,
abortSignal: abort.signal, abortSignal: abort.signal,
maxSteps: 1000, maxSteps: 1000,
providerOptions: model.info.options,
messages: [ messages: [
...system.map( ...system.map(
(x, index): CoreMessage => ({ (x): CoreMessage => ({
role: "system", role: "system",
content: x, content: x,
providerOptions: {
...(input.providerID === "anthropic" && index < 4
? {
anthropic: {
cacheControl: { type: "ephemeral" },
},
}
: {}),
},
}), }),
), ),
...convertToCoreMessages( ...convertToCoreMessages(
msgs.map(toUIMessage).filter((x) => x.parts.length > 0), msgs.map(toUIMessage).filter((x) => x.parts.length > 0),
), ),
], ].map((msg, i) =>
temperature: model.info.id === "codex-mini-latest" ? undefined : 0, ProviderTransform.message(msg, i, input.providerID, input.modelID),
tools: { ),
...tools, temperature: model.info.temperature ? 0 : undefined,
}, tools: model.info.tool_call === false ? undefined : tools,
model: model.language, model: model.language,
}) })
try { try {
@@ -558,7 +584,7 @@ export namespace Session {
break break
case "tool-call-delta": case "tool-call-delta":
break continue
// for some reason ai sdk claims to not send this part but it does // for some reason ai sdk claims to not send this part but it does
// @ts-expect-error // @ts-expect-error
@@ -593,6 +619,7 @@ export namespace Session {
l.info("unhandled", { l.info("unhandled", {
type: value.type, type: value.type,
}) })
continue
} }
await updateMessage(next) await updateMessage(next)
} }
@@ -721,17 +748,16 @@ export namespace Session {
await updateMessage(next) await updateMessage(next)
} }
const pending = new Map<string, AbortController>()
function lock(sessionID: string) { function lock(sessionID: string) {
log.info("locking", { sessionID }) log.info("locking", { sessionID })
if (pending.has(sessionID)) throw new BusyError(sessionID) if (state().pending.has(sessionID)) throw new BusyError(sessionID)
const controller = new AbortController() const controller = new AbortController()
pending.set(sessionID, controller) state().pending.set(sessionID, controller)
return { return {
signal: controller.signal, signal: controller.signal,
[Symbol.dispose]() { [Symbol.dispose]() {
log.info("unlocking", { sessionID }) log.info("unlocking", { sessionID })
pending.delete(sessionID) state().pending.delete(sessionID)
}, },
} }
} }
@@ -756,6 +782,16 @@ export namespace Session {
cost: new Decimal(0) cost: new Decimal(0)
.add(new Decimal(tokens.input).mul(model.cost.input).div(1_000_000)) .add(new Decimal(tokens.input).mul(model.cost.input).div(1_000_000))
.add(new Decimal(tokens.output).mul(model.cost.output).div(1_000_000)) .add(new Decimal(tokens.output).mul(model.cost.output).div(1_000_000))
.add(
new Decimal(tokens.cache.read)
.mul(model.cost.cache_read ?? 0)
.div(1_000_000),
)
.add(
new Decimal(tokens.cache.write)
.mul(model.cost.cache_write ?? 0)
.div(1_000_000),
)
.toNumber(), .toNumber(),
tokens, tokens,
} }
@@ -1,4 +1,4 @@
you will generate a short title based on the first message a user begins a conversation with You will generate a short title based on the first message a user begins a conversation with
- ensure it is not more than 50 characters long - ensure it is not more than 50 characters long
- the title should be a summary of the user's message - the title should be a summary of the user's message
- it should be one line long - it should be one line long
+4 -4
View File
@@ -25,7 +25,7 @@ export namespace SystemPrompt {
export async function environment() { export async function environment() {
const app = App.info() const app = App.info()
const tree = async () => { ;async () => {
const files = await Ripgrep.files({ const files = await Ripgrep.files({
cwd: app.path.cwd, cwd: app.path.cwd,
}) })
@@ -82,9 +82,9 @@ export namespace SystemPrompt {
` Platform: ${process.platform}`, ` Platform: ${process.platform}`,
` Today's date: ${new Date().toDateString()}`, ` Today's date: ${new Date().toDateString()}`,
`</env>`, `</env>`,
`<project>`, // `<project>`,
` ${app.git ? await tree() : ""}`, // ` ${app.git ? await tree() : ""}`,
`</project>`, // `</project>`,
].join("\n"), ].join("\n"),
] ]
} }
+10 -5
View File
@@ -1,5 +1,6 @@
import { App } from "../app/app" import { App } from "../app/app"
import { Bus } from "../bus" import { Bus } from "../bus"
import { Installation } from "../installation"
import { Session } from "../session" import { Session } from "../session"
import { Storage } from "../storage/storage" import { Storage } from "../storage/storage"
import { Log } from "../util/log" import { Log } from "../util/log"
@@ -19,10 +20,11 @@ export namespace Share {
export async function sync(key: string, content: any) { export async function sync(key: string, content: any) {
const [root, ...splits] = key.split("/") const [root, ...splits] = key.split("/")
if (root !== "session") return if (root !== "session") return
const [, sessionID] = splits const [sub, sessionID] = splits
const session = await Session.get(sessionID) if (sub === "share") return
if (!session.share) return const share = await Session.getShare(sessionID).catch(() => {})
const { secret } = session.share if (!share) return
const { secret } = share
pending.set(key, content) pending.set(key, content)
queue = queue queue = queue
.then(async () => { .then(async () => {
@@ -55,7 +57,10 @@ export namespace Share {
} }
export const URL = export const URL =
process.env["OPENCODE_API"] ?? "https://api.dev.opencode.ai" process.env["OPENCODE_API"] ??
(Installation.isSnapshot() || Installation.isDev()
? "https://api.dev.opencode.ai"
: "https://api.opencode.ai")
export async function create(sessionID: string) { export async function create(sessionID: string) {
return fetch(`${URL}/share_create`, { return fetch(`${URL}/share_create`, {
-2
View File
@@ -24,8 +24,6 @@ export namespace Storage {
} }
}) })
const locks = new Map<string, Promise<void>>()
export async function readJSON<T>(key: string) { export async function readJSON<T>(key: string) {
return Bun.file(path.join(state().dir, key + ".json")).json() as Promise<T> return Bun.file(path.join(state().dir, key + ".json")).json() as Promise<T>
} }
+2 -2
View File
@@ -26,7 +26,7 @@ const DEFAULT_TIMEOUT = 1 * 60 * 1000
const MAX_TIMEOUT = 10 * 60 * 1000 const MAX_TIMEOUT = 10 * 60 * 1000
export const BashTool = Tool.define({ export const BashTool = Tool.define({
id: "opencode.bash", id: "bash",
description: DESCRIPTION, description: DESCRIPTION,
parameters: z.object({ parameters: z.object({
command: z.string().describe("The command to execute"), command: z.string().describe("The command to execute"),
@@ -35,7 +35,7 @@ export const BashTool = Tool.define({
.min(0) .min(0)
.max(MAX_TIMEOUT) .max(MAX_TIMEOUT)
.describe("Optional timeout in milliseconds") .describe("Optional timeout in milliseconds")
.nullable(), .optional(),
description: z description: z
.string() .string()
.describe( .describe(
+42 -4
View File
@@ -9,7 +9,7 @@ import DESCRIPTION from "./edit.txt"
import { App } from "../app/app" import { App } from "../app/app"
export const EditTool = Tool.define({ export const EditTool = Tool.define({
id: "opencode.edit", id: "edit",
description: DESCRIPTION, description: DESCRIPTION,
parameters: z.object({ parameters: z.object({
filePath: z.string().describe("The absolute path to the file to modify"), filePath: z.string().describe("The absolute path to the file to modify"),
@@ -21,7 +21,7 @@ export const EditTool = Tool.define({
), ),
replaceAll: z replaceAll: z
.boolean() .boolean()
.nullable() .optional()
.describe("Replace all occurences of old_string (default false)"), .describe("Replace all occurences of old_string (default false)"),
}), }),
async execute(params, ctx) { async execute(params, ctx) {
@@ -35,7 +35,7 @@ export const EditTool = Tool.define({
: path.join(app.path.cwd, params.filePath) : path.join(app.path.cwd, params.filePath)
await Permission.ask({ await Permission.ask({
id: "opencode.edit", id: "edit",
sessionID: ctx.sessionID, sessionID: ctx.sessionID,
title: "Edit this file: " + filepath, title: "Edit this file: " + filepath,
metadata: { metadata: {
@@ -87,7 +87,9 @@ export const EditTool = Tool.define({
await file.write(contentNew) await file.write(contentNew)
})() })()
const diff = createTwoFilesPatch(filepath, filepath, contentOld, contentNew) const diff = trimDiff(
createTwoFilesPatch(filepath, filepath, contentOld, contentNew),
)
FileTimes.read(ctx.sessionID, filepath) FileTimes.read(ctx.sessionID, filepath)
@@ -113,3 +115,39 @@ export const EditTool = Tool.define({
} }
}, },
}) })
function trimDiff(diff: string): string {
const lines = diff.split("\n")
const contentLines = lines.filter(
(line) =>
(line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) &&
!line.startsWith("---") &&
!line.startsWith("+++"),
)
if (contentLines.length === 0) return diff
let min = Infinity
for (const line of contentLines) {
const content = line.slice(1)
if (content.trim().length > 0) {
const match = content.match(/^(\s*)/)
if (match) min = Math.min(min, match[1].length)
}
}
if (min === Infinity || min === 0) return diff
const trimmedLines = lines.map((line) => {
if (
(line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) &&
!line.startsWith("---") &&
!line.startsWith("+++")
) {
const prefix = line[0]
const content = line.slice(1)
return prefix + content.slice(min)
}
return line
})
return trimmedLines.join("\n")
}
+2 -2
View File
@@ -5,13 +5,13 @@ import { App } from "../app/app"
import DESCRIPTION from "./glob.txt" import DESCRIPTION from "./glob.txt"
export const GlobTool = Tool.define({ export const GlobTool = Tool.define({
id: "opencode.glob", id: "glob",
description: DESCRIPTION, description: DESCRIPTION,
parameters: z.object({ parameters: z.object({
pattern: z.string().describe("The glob pattern to match files against"), pattern: z.string().describe("The glob pattern to match files against"),
path: z path: z
.string() .string()
.nullable() .optional()
.describe( .describe(
`The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`, `The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`,
), ),
+3 -3
View File
@@ -6,7 +6,7 @@ import { Ripgrep } from "../external/ripgrep"
import DESCRIPTION from "./grep.txt" import DESCRIPTION from "./grep.txt"
export const GrepTool = Tool.define({ export const GrepTool = Tool.define({
id: "opencode.grep", id: "grep",
description: DESCRIPTION, description: DESCRIPTION,
parameters: z.object({ parameters: z.object({
pattern: z pattern: z
@@ -14,13 +14,13 @@ export const GrepTool = Tool.define({
.describe("The regex pattern to search for in file contents"), .describe("The regex pattern to search for in file contents"),
path: z path: z
.string() .string()
.nullable() .optional()
.describe( .describe(
"The directory to search in. Defaults to the current working directory.", "The directory to search in. Defaults to the current working directory.",
), ),
include: z include: z
.string() .string()
.nullable() .optional()
.describe( .describe(
'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")', 'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")',
), ),
+3 -3
View File
@@ -21,7 +21,7 @@ export const IGNORE_PATTERNS = [
const LIMIT = 100 const LIMIT = 100
export const ListTool = Tool.define({ export const ListTool = Tool.define({
id: "opencode.list", id: "list",
description: DESCRIPTION, description: DESCRIPTION,
parameters: z.object({ parameters: z.object({
path: z path: z
@@ -29,11 +29,11 @@ export const ListTool = Tool.define({
.describe( .describe(
"The absolute path to the directory to list (must be absolute, not relative)", "The absolute path to the directory to list (must be absolute, not relative)",
) )
.nullable(), .optional(),
ignore: z ignore: z
.array(z.string()) .array(z.string())
.describe("List of glob patterns to ignore") .describe("List of glob patterns to ignore")
.nullable(), .optional(),
}), }),
async execute(params) { async execute(params) {
const app = App.info() const app = App.info()
@@ -6,7 +6,7 @@ import { App } from "../app/app"
import DESCRIPTION from "./lsp-diagnostics.txt" import DESCRIPTION from "./lsp-diagnostics.txt"
export const LspDiagnosticTool = Tool.define({ export const LspDiagnosticTool = Tool.define({
id: "opencode.lsp_diagnostics", id: "lsp_diagnostics",
description: DESCRIPTION, description: DESCRIPTION,
parameters: z.object({ parameters: z.object({
path: z.string().describe("The path to the file to get diagnostics."), path: z.string().describe("The path to the file to get diagnostics."),
+1 -1
View File
@@ -6,7 +6,7 @@ import { App } from "../app/app"
import DESCRIPTION from "./lsp-hover.txt" import DESCRIPTION from "./lsp-hover.txt"
export const LspHoverTool = Tool.define({ export const LspHoverTool = Tool.define({
id: "opencode.lsp_hover", id: "lsp_hover",
description: DESCRIPTION, description: DESCRIPTION,
parameters: z.object({ parameters: z.object({
file: z.string().describe("The path to the file to get diagnostics."), file: z.string().describe("The path to the file to get diagnostics."),
+1 -1
View File
@@ -6,7 +6,7 @@ import path from "path"
import { App } from "../app/app" import { App } from "../app/app"
export const MultiEditTool = Tool.define({ export const MultiEditTool = Tool.define({
id: "opencode.multiedit", id: "multiedit",
description: DESCRIPTION, description: DESCRIPTION,
parameters: z.object({ parameters: z.object({
filePath: z.string().describe("The absolute path to the file to modify"), filePath: z.string().describe("The absolute path to the file to modify"),
+1 -2
View File
@@ -4,7 +4,6 @@ import * as fs from "fs/promises"
import { Tool } from "./tool" import { Tool } from "./tool"
import { FileTimes } from "./util/file-times" import { FileTimes } from "./util/file-times"
import DESCRIPTION from "./patch.txt" import DESCRIPTION from "./patch.txt"
import { App } from "../app/app"
const PatchParams = z.object({ const PatchParams = z.object({
patchText: z patchText: z
@@ -233,7 +232,7 @@ async function applyCommit(
} }
export const PatchTool = Tool.define({ export const PatchTool = Tool.define({
id: "opencode.patch", id: "patch",
description: DESCRIPTION, description: DESCRIPTION,
parameters: PatchParams, parameters: PatchParams,
execute: async (params, ctx) => { execute: async (params, ctx) => {
+3 -3
View File
@@ -12,18 +12,18 @@ const DEFAULT_READ_LIMIT = 2000
const MAX_LINE_LENGTH = 2000 const MAX_LINE_LENGTH = 2000
export const ReadTool = Tool.define({ export const ReadTool = Tool.define({
id: "opencode.read", id: "read",
description: DESCRIPTION, description: DESCRIPTION,
parameters: z.object({ parameters: z.object({
filePath: z.string().describe("The path to the file to read"), filePath: z.string().describe("The path to the file to read"),
offset: z offset: z
.number() .number()
.describe("The line number to start reading from (0-based)") .describe("The line number to start reading from (0-based)")
.nullable(), .optional(),
limit: z limit: z
.number() .number()
.describe("The number of lines to read (defaults to 2000)") .describe("The number of lines to read (defaults to 2000)")
.nullable(), .optional(),
}), }),
async execute(params, ctx) { async execute(params, ctx) {
let filePath = params.filePath let filePath = params.filePath
+30 -2
View File
@@ -2,9 +2,11 @@ import { Tool } from "./tool"
import DESCRIPTION from "./task.txt" import DESCRIPTION from "./task.txt"
import { z } from "zod" import { z } from "zod"
import { Session } from "../session" import { Session } from "../session"
import { Bus } from "../bus"
import { Message } from "../session/message"
export const TaskTool = Tool.define({ export const TaskTool = Tool.define({
id: "opencode.task", id: "task",
description: DESCRIPTION, description: DESCRIPTION,
parameters: z.object({ parameters: z.object({
description: z description: z
@@ -17,6 +19,31 @@ export const TaskTool = Tool.define({
const msg = await Session.getMessage(ctx.sessionID, ctx.messageID) const msg = await Session.getMessage(ctx.sessionID, ctx.messageID)
const metadata = msg.metadata.assistant! const metadata = msg.metadata.assistant!
function summary(input: Message.Info) {
const result = []
for (const part of input.parts) {
if (part.type === "tool-invocation") {
result.push({
toolInvocation: part.toolInvocation,
metadata: input.metadata.tool[part.toolInvocation.toolCallId],
})
}
}
return result
}
const unsub = Bus.subscribe(Message.Event.Updated, async (evt) => {
if (evt.properties.info.metadata.sessionID !== session.id) return
ctx.metadata({
title: params.description,
summary: summary(evt.properties.info),
})
})
ctx.abort.addEventListener("abort", () => {
Session.abort(session.id)
})
const result = await Session.chat({ const result = await Session.chat({
sessionID: session.id, sessionID: session.id,
modelID: metadata.modelID, modelID: metadata.modelID,
@@ -28,10 +55,11 @@ export const TaskTool = Tool.define({
}, },
], ],
}) })
unsub()
return { return {
metadata: { metadata: {
title: params.description, title: params.description,
summary: summary(result),
}, },
output: result.parts.findLast((x) => x.type === "text")!.text, output: result.parts.findLast((x) => x.type === "text")!.text,
} }
+2 -2
View File
@@ -23,7 +23,7 @@ const state = App.state("todo-tool", () => {
}) })
export const TodoWriteTool = Tool.define({ export const TodoWriteTool = Tool.define({
id: "opencode.todowrite", id: "todowrite",
description: DESCRIPTION_WRITE, description: DESCRIPTION_WRITE,
parameters: z.object({ parameters: z.object({
todos: z.array(TodoInfo).describe("The updated todo list"), todos: z.array(TodoInfo).describe("The updated todo list"),
@@ -42,7 +42,7 @@ export const TodoWriteTool = Tool.define({
}) })
export const TodoReadTool = Tool.define({ export const TodoReadTool = Tool.define({
id: "opencode.todoread", id: "todoread",
description: "Use this tool to read your todo list", description: "Use this tool to read your todo list",
parameters: z.object({}), parameters: z.object({}),
async execute(_params, opts) { async execute(_params, opts) {
+2 -1
View File
@@ -5,10 +5,11 @@ export namespace Tool {
title: string title: string
[key: string]: any [key: string]: any
} }
export type Context = { export type Context<M extends Metadata = Metadata> = {
sessionID: string sessionID: string
messageID: string messageID: string
abort: AbortSignal abort: AbortSignal
metadata(meta: M): void
} }
export interface Info< export interface Info<
Parameters extends StandardSchemaV1 = StandardSchemaV1, Parameters extends StandardSchemaV1 = StandardSchemaV1,
+42 -7
View File
@@ -8,7 +8,7 @@ const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
const MAX_TIMEOUT = 120 * 1000 // 2 minutes const MAX_TIMEOUT = 120 * 1000 // 2 minutes
export const WebFetchTool = Tool.define({ export const WebFetchTool = Tool.define({
id: "opencode.webfetch", id: "webfetch",
description: DESCRIPTION, description: DESCRIPTION,
parameters: z.object({ parameters: z.object({
url: z.string().describe("The URL to fetch content from"), url: z.string().describe("The URL to fetch content from"),
@@ -22,7 +22,7 @@ export const WebFetchTool = Tool.define({
.min(0) .min(0)
.max(MAX_TIMEOUT / 1000) .max(MAX_TIMEOUT / 1000)
.describe("Optional timeout in seconds (max 120)") .describe("Optional timeout in seconds (max 120)")
.nullable(), .optional(),
}), }),
async execute(params, ctx) { async execute(params, ctx) {
// Validate URL // Validate URL
@@ -76,7 +76,7 @@ export const WebFetchTool = Tool.define({
switch (params.format) { switch (params.format) {
case "text": case "text":
if (contentType.includes("text/html")) { if (contentType.includes("text/html")) {
const text = extractTextFromHTML(content) const text = await extractTextFromHTML(content)
return { return {
output: text, output: text,
metadata: { metadata: {
@@ -127,10 +127,45 @@ export const WebFetchTool = Tool.define({
}, },
}) })
function extractTextFromHTML(html: string): string { async function extractTextFromHTML(html: string) {
const doc = new DOMParser().parseFromString(html, "text/html") let text = ""
const text = doc.body.textContent || doc.body.innerText || "" let skipContent = false
return text.replace(/\s+/g, " ").trim()
const rewriter = new HTMLRewriter()
.on("script, style, noscript, iframe, object, embed", {
element() {
skipContent = true
},
text() {
// Skip text content inside these elements
},
})
.on("*", {
element(element) {
// Reset skip flag when entering other elements
if (
![
"script",
"style",
"noscript",
"iframe",
"object",
"embed",
].includes(element.tagName)
) {
skipContent = false
}
},
text(input) {
if (!skipContent) {
text += input.text
}
},
})
.transform(new Response(html))
await rewriter.text()
return text.trim()
} }
function convertHTMLToMarkdown(html: string): string { function convertHTMLToMarkdown(html: string): string {
+1 -1
View File
@@ -1,5 +1,5 @@
- Allows OpenCode to search the web and use the results to inform responses - Allows opencode to search the web and use the results to inform responses
- Provides up-to-date information for current events and recent data - Provides up-to-date information for current events and recent data
- Returns search result information formatted as search result blocks - Returns search result information formatted as search result blocks
- Use this tool for accessing information beyond Claude's knowledge cutoff - Use this tool for accessing information beyond Claude's knowledge cutoff
+2 -2
View File
@@ -8,7 +8,7 @@ import DESCRIPTION from "./write.txt"
import { App } from "../app/app" import { App } from "../app/app"
export const WriteTool = Tool.define({ export const WriteTool = Tool.define({
id: "opencode.write", id: "write",
description: DESCRIPTION, description: DESCRIPTION,
parameters: z.object({ parameters: z.object({
filePath: z filePath: z
@@ -29,7 +29,7 @@ export const WriteTool = Tool.define({
if (exists) await FileTimes.assert(ctx.sessionID, filepath) if (exists) await FileTimes.assert(ctx.sessionID, filepath)
await Permission.ask({ await Permission.ask({
id: "opencode.write", id: "write",
sessionID: ctx.sessionID, sessionID: ctx.sessionID,
title: exists title: exists
? "Overwrite this file: " + filepath ? "Overwrite this file: " + filepath
+12 -15
View File
@@ -1,7 +1,7 @@
import { z, type ZodSchema } from "zod" import { z, type ZodSchema } from "zod"
import { Log } from "./log" // import { Log } from "./log"
const log = Log.create() // const log = Log.create()
export abstract class NamedError extends Error { export abstract class NamedError extends Error {
abstract schema(): ZodSchema abstract schema(): ZodSchema
@@ -11,15 +11,16 @@ export abstract class NamedError extends Error {
name: Name, name: Name,
data: Data, data: Data,
) { ) {
const schema = z
.object({
name: z.literal(name),
data,
})
.openapi({
ref: name,
})
const result = class extends NamedError { const result = class extends NamedError {
public static readonly Schema = z public static readonly Schema = schema
.object({
name: z.literal(name),
data: data,
})
.openapi({
ref: name,
})
public readonly name = name as Name public readonly name = name as Name
@@ -29,10 +30,6 @@ export abstract class NamedError extends Error {
) { ) {
super(name, options) super(name, options)
this.name = name this.name = name
log.error(name, {
...this.data,
cause: options?.cause?.toString(),
})
} }
static isInstance(input: any): input is InstanceType<typeof result> { static isInstance(input: any): input is InstanceType<typeof result> {
@@ -40,7 +37,7 @@ export abstract class NamedError extends Error {
} }
schema() { schema() {
return result.Schema return schema
} }
toObject() { toObject() {
+3 -3
View File
@@ -68,13 +68,13 @@ export namespace Log {
} }
const result = { const result = {
info(message?: any, extra?: Record<string, any>) { info(message?: any, extra?: Record<string, any>) {
process.stderr.write(build(message, extra)) process.stderr.write("INFO " + build(message, extra))
}, },
error(message?: any, extra?: Record<string, any>) { error(message?: any, extra?: Record<string, any>) {
process.stderr.write(build(message, extra)) process.stderr.write("ERROR " + build(message, extra))
}, },
warn(message?: any, extra?: Record<string, any>) { warn(message?: any, extra?: Record<string, any>) {
process.stderr.write(build(message, extra)) process.stderr.write("WARN " + build(message, extra))
}, },
tag(key: string, value: string) { tag(key: string, value: string) {
if (tags) tags[key] = value if (tags) tags[key] = value
+24 -15
View File
@@ -3,21 +3,33 @@ import { App } from "../../src/app/app"
import { GlobTool } from "../../src/tool/glob" import { GlobTool } from "../../src/tool/glob"
import { ListTool } from "../../src/tool/ls" import { ListTool } from "../../src/tool/ls"
const ctx = {
sessionID: "test",
messageID: "",
abort: AbortSignal.any([]),
metadata: () => {},
}
describe("tool.glob", () => { describe("tool.glob", () => {
test("truncate", async () => { test("truncate", async () => {
await App.provide({ cwd: process.cwd(), version: "test" }, async () => { await App.provide({ cwd: process.cwd() }, async () => {
let result = await GlobTool.execute( let result = await GlobTool.execute(
{ pattern: "./node_modules/**/*" }, {
{ sessionID: "test" }, pattern: "./node_modules/**/*",
path: undefined,
},
ctx,
) )
expect(result.metadata.truncated).toBe(true) expect(result.metadata.truncated).toBe(true)
}) })
}) })
test("basic", async () => { test("basic", async () => {
await App.provide({ cwd: process.cwd(), version: "test" }, async () => { await App.provide({ cwd: process.cwd() }, async () => {
let result = await GlobTool.execute( let result = await GlobTool.execute(
{ pattern: "*.json" }, {
{ sessionID: "test" }, pattern: "*.json",
path: undefined,
},
ctx,
) )
expect(result.metadata).toMatchObject({ expect(result.metadata).toMatchObject({
truncated: false, truncated: false,
@@ -29,15 +41,12 @@ describe("tool.glob", () => {
describe("tool.ls", () => { describe("tool.ls", () => {
test("basic", async () => { test("basic", async () => {
const result = await App.provide( const result = await App.provide({ cwd: process.cwd() }, async () => {
{ cwd: process.cwd(), version: "test" }, return await ListTool.execute(
async () => { { path: "./example", ignore: [".git"] },
return await ListTool.execute( ctx,
{ path: "./example" }, )
{ sessionID: "test" }, })
)
},
)
expect(result.output).toMatchSnapshot() expect(result.output).toMatchSnapshot()
}) })
}) })
+19 -26
View File
@@ -23,10 +23,28 @@ func main() {
} }
url := os.Getenv("OPENCODE_SERVER") url := os.Getenv("OPENCODE_SERVER")
appInfoStr := os.Getenv("OPENCODE_APP_INFO") appInfoStr := os.Getenv("OPENCODE_APP_INFO")
var appInfo client.AppInfo var appInfo client.AppInfo
json.Unmarshal([]byte(appInfoStr), &appInfo) json.Unmarshal([]byte(appInfoStr), &appInfo)
logfile := filepath.Join(appInfo.Path.Data, "log", "tui.log")
if _, err := os.Stat(filepath.Dir(logfile)); os.IsNotExist(err) {
err := os.MkdirAll(filepath.Dir(logfile), 0755)
if err != nil {
slog.Error("Failed to create log directory", "error", err)
os.Exit(1)
}
}
file, err := os.Create(logfile)
if err != nil {
slog.Error("Failed to create log file", "error", err)
os.Exit(1)
}
defer file.Close()
logger := slog.New(slog.NewTextHandler(file, &slog.HandlerOptions{Level: slog.LevelDebug}))
slog.SetDefault(logger)
httpClient, err := client.NewClientWithResponses(url) httpClient, err := client.NewClientWithResponses(url)
if err != nil { if err != nil {
slog.Error("Failed to create client", "error", err) slog.Error("Failed to create client", "error", err)
@@ -46,7 +64,7 @@ func main() {
tui.NewModel(app_), tui.NewModel(app_),
tea.WithAltScreen(), tea.WithAltScreen(),
tea.WithKeyboardEnhancements(), tea.WithKeyboardEnhancements(),
// tea.WithMouseCellMotion(), tea.WithMouseCellMotion(),
) )
eventClient, err := client.NewClient(url) eventClient, err := client.NewClient(url)
@@ -67,35 +85,10 @@ func main() {
} }
}() }()
go func() {
paths, err := httpClient.PostPathGetWithResponse(context.Background())
if err != nil {
panic(err)
}
logfile := filepath.Join(paths.JSON200.Data, "log", "tui.log")
if _, err := os.Stat(filepath.Dir(logfile)); os.IsNotExist(err) {
err := os.MkdirAll(filepath.Dir(logfile), 0755)
if err != nil {
slog.Error("Failed to create log directory", "error", err)
os.Exit(1)
}
}
file, err := os.Create(logfile)
if err != nil {
slog.Error("Failed to create log file", "error", err)
os.Exit(1)
}
defer file.Close()
logger := slog.New(slog.NewTextHandler(file, &slog.HandlerOptions{Level: slog.LevelDebug}))
slog.SetDefault(logger)
}()
// Run the TUI // Run the TUI
result, err := program.Run() result, err := program.Run()
if err != nil { if err != nil {
slog.Error("TUI error", "error", err) slog.Error("TUI error", "error", err)
// return fmt.Errorf("TUI error: %v", err)
} }
slog.Info("TUI exited", "result", result) slog.Info("TUI exited", "result", result)
+82 -31
View File
@@ -5,13 +5,13 @@ import (
"fmt" "fmt"
"path/filepath" "path/filepath"
"sort" "sort"
"strings"
"log/slog" "log/slog"
tea "github.com/charmbracelet/bubbletea/v2" tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode/internal/commands" "github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/config" "github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/state"
"github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util" "github.com/sst/opencode/internal/util"
"github.com/sst/opencode/pkg/client" "github.com/sst/opencode/pkg/client"
@@ -20,16 +20,32 @@ import (
var RootPath string var RootPath string
type App struct { type App struct {
Info client.AppInfo Info client.AppInfo
Version string Version string
ConfigPath string StatePath string
Config *config.Config Config *client.ConfigInfo
Client *client.ClientWithResponses Client *client.ClientWithResponses
Provider *client.ProviderInfo State *config.State
Model *client.ModelInfo Provider *client.ProviderInfo
Session *client.SessionInfo Model *client.ModelInfo
Messages []client.MessageInfo Session *client.SessionInfo
Commands commands.Registry Messages []client.MessageInfo
Commands commands.CommandRegistry
}
type SessionSelectedMsg = *client.SessionInfo
type ModelSelectedMsg struct {
Provider client.ProviderInfo
Model client.ModelInfo
}
type SessionClearedMsg struct{}
type CompactSessionMsg struct{}
type SendMsg struct {
Text string
Attachments []Attachment
}
type CompletionDialogTriggerdMsg struct {
InitialValue string
} }
func New( func New(
@@ -40,23 +56,51 @@ func New(
) (*App, error) { ) (*App, error) {
RootPath = appInfo.Path.Root RootPath = appInfo.Path.Root
appConfigPath := filepath.Join(appInfo.Path.Config, "config") configResponse, err := httpClient.PostConfigGetWithResponse(ctx)
appConfig, err := config.LoadConfig(appConfigPath)
if err != nil { if err != nil {
appConfig = config.NewConfig() return nil, err
config.SaveConfig(appConfigPath, appConfig)
} }
theme.SetTheme(appConfig.Theme) configInfo := configResponse.JSON200
if configInfo.Keybinds == nil {
leader := "ctrl+x"
keybinds := client.ConfigKeybinds{
Leader: &leader,
}
configInfo.Keybinds = &keybinds
}
appStatePath := filepath.Join(appInfo.Path.State, "tui")
appState, err := config.LoadState(appStatePath)
if err != nil {
appState = config.NewState()
config.SaveState(appStatePath, appState)
}
if configInfo.Theme != nil {
appState.Theme = *configInfo.Theme
}
if configInfo.Model != nil {
splits := strings.Split(*configInfo.Model, "/")
appState.Provider = splits[0]
appState.Model = strings.Join(splits[1:], "/")
}
if appState.Theme != "" {
theme.SetTheme(appState.Theme)
}
slog.Debug("Loaded config", "config", configInfo)
app := &App{ app := &App{
Info: appInfo, Info: appInfo,
Version: version, Version: version,
ConfigPath: appConfigPath, StatePath: appStatePath,
Config: appConfig, Config: configInfo,
Client: httpClient, State: appState,
Session: &client.SessionInfo{}, Client: httpClient,
Messages: []client.MessageInfo{}, Session: &client.SessionInfo{},
Commands: commands.NewCommandRegistry(), Messages: []client.MessageInfo{},
Commands: commands.LoadFromConfig(configInfo),
} }
return app, nil return app, nil
@@ -102,11 +146,11 @@ func (a *App) InitializeProvider() tea.Cmd {
var currentProvider *client.ProviderInfo var currentProvider *client.ProviderInfo
var currentModel *client.ModelInfo var currentModel *client.ModelInfo
for _, provider := range providers { for _, provider := range providers {
if provider.Id == a.Config.Provider { if provider.Id == a.State.Provider {
currentProvider = &provider currentProvider = &provider
for _, model := range provider.Models { for _, model := range provider.Models {
if model.Id == a.Config.Model { if model.Id == a.State.Model {
currentModel = &model currentModel = &model
} }
} }
@@ -118,7 +162,7 @@ func (a *App) InitializeProvider() tea.Cmd {
} }
// TODO: handle no provider or model setup, yet // TODO: handle no provider or model setup, yet
return state.ModelSelectedMsg{ return ModelSelectedMsg{
Provider: *currentProvider, Provider: *currentProvider,
Model: *currentModel, Model: *currentModel,
} }
@@ -153,8 +197,11 @@ func (a *App) IsBusy() bool {
return lastMessage.Metadata.Time.Completed == nil return lastMessage.Metadata.Time.Completed == nil
} }
func (a *App) SaveConfig() { func (a *App) SaveState() {
config.SaveConfig(a.ConfigPath, a.Config) err := config.SaveState(a.StatePath, a.State)
if err != nil {
slog.Error("Failed to save state", "error", err)
}
} }
func (a *App) InitializeProject(ctx context.Context) tea.Cmd { func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
@@ -167,7 +214,7 @@ func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
} }
a.Session = session a.Session = session
cmds = append(cmds, util.CmdHandler(state.SessionSelectedMsg(session))) cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
go func() { go func() {
response, err := a.Client.PostSessionInitialize(ctx, client.PostSessionInitializeJSONRequestBody{ response, err := a.Client.PostSessionInitialize(ctx, client.PostSessionInitializeJSONRequestBody{
@@ -236,7 +283,7 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
return nil return nil
} }
a.Session = session a.Session = session
cmds = append(cmds, util.CmdHandler(state.SessionSelectedMsg(session))) cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
} }
// TODO: Handle attachments when API supports them // TODO: Handle attachments when API supports them
@@ -341,3 +388,7 @@ func (a *App) ListProviders(ctx context.Context) ([]client.ProviderInfo, error)
providers := *resp.JSON200 providers := *resp.JSON200
return providers.Providers, nil return providers.Providers, nil
} }
// func (a *App) loadCustomKeybinds() {
//
// }
+257 -67
View File
@@ -1,91 +1,281 @@
package commands package commands
import ( import (
"github.com/charmbracelet/bubbles/v2/key" "encoding/json"
"slices"
"strings"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode/pkg/client"
) )
// Command represents a user-triggerable action. type ExecuteCommandMsg Command
type ExecuteCommandsMsg []Command
type CommandExecutedMsg Command
type Keybinding struct {
RequiresLeader bool
Key string
}
func (k Keybinding) Matches(msg tea.KeyPressMsg, leader bool) bool {
key := k.Key
key = strings.TrimSpace(key)
return key == msg.String() && (k.RequiresLeader == leader)
}
type CommandName string
type Command struct { type Command struct {
// Name is the identifier used for slash commands (e.g., "new"). Name CommandName
Name string
// Description is a short explanation of what the command does.
Description string Description string
// KeyBinding is the keyboard shortcut to trigger this command. Keybindings []Keybinding
KeyBinding key.Binding Trigger string
} }
// Registry holds all the available commands. func (c Command) Keys() []string {
type Registry map[string]Command var keys []string
for _, k := range c.Keybindings {
// ExecuteCommandMsg is a message sent when a command should be executed. keys = append(keys, k.Key)
type ExecuteCommandMsg struct { }
Name string return keys
} }
func NewCommandRegistry() Registry { type CommandRegistry map[CommandName]Command
return Registry{
"help": { func (r CommandRegistry) Sorted() []Command {
Name: "help", var commands []Command
for _, command := range r {
commands = append(commands, command)
}
slices.SortFunc(commands, func(a, b Command) int {
if a.Name == AppExitCommand {
return 1
}
if b.Name == AppExitCommand {
return -1
}
return strings.Compare(string(a.Name), string(b.Name))
})
return commands
}
func (r CommandRegistry) Matches(msg tea.KeyPressMsg, leader bool) []Command {
var matched []Command
for _, command := range r.Sorted() {
if command.Matches(msg, leader) {
matched = append(matched, command)
}
}
return matched
}
const (
AppHelpCommand CommandName = "app_help"
EditorOpenCommand CommandName = "editor_open"
SessionNewCommand CommandName = "session_new"
SessionListCommand CommandName = "session_list"
SessionShareCommand CommandName = "session_share"
SessionInterruptCommand CommandName = "session_interrupt"
SessionCompactCommand CommandName = "session_compact"
ToolDetailsCommand CommandName = "tool_details"
ModelListCommand CommandName = "model_list"
ThemeListCommand CommandName = "theme_list"
ProjectInitCommand CommandName = "project_init"
InputClearCommand CommandName = "input_clear"
InputPasteCommand CommandName = "input_paste"
InputSubmitCommand CommandName = "input_submit"
InputNewlineCommand CommandName = "input_newline"
HistoryPreviousCommand CommandName = "history_previous"
HistoryNextCommand CommandName = "history_next"
MessagesPageUpCommand CommandName = "messages_page_up"
MessagesPageDownCommand CommandName = "messages_page_down"
MessagesHalfPageUpCommand CommandName = "messages_half_page_up"
MessagesHalfPageDownCommand CommandName = "messages_half_page_down"
MessagesPreviousCommand CommandName = "messages_previous"
MessagesNextCommand CommandName = "messages_next"
MessagesFirstCommand CommandName = "messages_first"
MessagesLastCommand CommandName = "messages_last"
AppExitCommand CommandName = "app_exit"
)
func (k Command) Matches(msg tea.KeyPressMsg, leader bool) bool {
for _, binding := range k.Keybindings {
if binding.Matches(msg, leader) {
return true
}
}
return false
}
func parseBindings(bindings ...string) []Keybinding {
var parsedBindings []Keybinding
for _, binding := range bindings {
for p := range strings.SplitSeq(binding, ",") {
requireLeader := strings.HasPrefix(p, "<leader>")
keybinding := strings.ReplaceAll(p, "<leader>", "")
keybinding = strings.TrimSpace(keybinding)
parsedBindings = append(parsedBindings, Keybinding{
RequiresLeader: requireLeader,
Key: keybinding,
})
}
}
return parsedBindings
}
func LoadFromConfig(config *client.ConfigInfo) CommandRegistry {
defaults := []Command{
{
Name: AppHelpCommand,
Description: "show help", Description: "show help",
KeyBinding: key.NewBinding( Keybindings: parseBindings("<leader>h"),
key.WithKeys("f1", "super+/", "super+h"), Trigger: "help",
),
}, },
"new": { {
Name: "new", Name: EditorOpenCommand,
Description: "open editor",
Keybindings: parseBindings("<leader>e"),
Trigger: "editor",
},
{
Name: SessionNewCommand,
Description: "new session", Description: "new session",
KeyBinding: key.NewBinding( Keybindings: parseBindings("<leader>n"),
key.WithKeys("f2", "super+n"), Trigger: "new",
),
}, },
"sessions": { {
Name: "sessions", Name: SessionListCommand,
Description: "switch session", Description: "list sessions",
KeyBinding: key.NewBinding( Keybindings: parseBindings("<leader>l"),
key.WithKeys("f3", "super+s"), Trigger: "sessions",
),
}, },
"model": { {
Name: "model", Name: SessionShareCommand,
Description: "switch model", Description: "share session",
KeyBinding: key.NewBinding( Keybindings: parseBindings("<leader>s"),
key.WithKeys("f4", "super+m"), Trigger: "share",
),
}, },
"theme": { {
Name: "theme", Name: SessionInterruptCommand,
Description: "switch theme", Description: "interrupt session",
KeyBinding: key.NewBinding( Keybindings: parseBindings("esc"),
key.WithKeys("f5", "super+t"),
),
}, },
"share": { {
Name: "share", Name: SessionCompactCommand,
Description: "create shareable link", Description: "compact the session",
KeyBinding: key.NewBinding( Keybindings: parseBindings("<leader>c"),
key.WithKeys("f6"), Trigger: "compact",
),
}, },
"init": { {
Name: "init", Name: ToolDetailsCommand,
Description: "create or update AGENTS.md", Description: "toggle tool details",
KeyBinding: key.NewBinding( Keybindings: parseBindings("<leader>d"),
key.WithKeys("f7"), Trigger: "details",
),
}, },
// "compact": { {
// Name: "compact", Name: ModelListCommand,
// Description: "compact the session", Description: "list models",
// KeyBinding: key.NewBinding( Keybindings: parseBindings("<leader>m"),
// key.WithKeys("f8"), Trigger: "models",
// ), },
{
Name: ThemeListCommand,
Description: "list themes",
Keybindings: parseBindings("<leader>t"),
Trigger: "themes",
},
{
Name: ProjectInitCommand,
Description: "create/update AGENTS.md",
Keybindings: parseBindings("<leader>i"),
Trigger: "init",
},
{
Name: InputClearCommand,
Description: "clear input",
Keybindings: parseBindings("ctrl+c"),
},
{
Name: InputPasteCommand,
Description: "paste content",
Keybindings: parseBindings("ctrl+v"),
},
{
Name: InputSubmitCommand,
Description: "submit message",
Keybindings: parseBindings("enter"),
},
{
Name: InputNewlineCommand,
Description: "insert newline",
Keybindings: parseBindings("shift+enter", "ctrl+j"),
},
// {
// Name: HistoryPreviousCommand,
// Description: "previous prompt",
// Keybindings: parseBindings("up"),
// }, // },
"quit": { // {
Name: "quit", // Name: HistoryNextCommand,
Description: "quit", // Description: "next prompt",
KeyBinding: key.NewBinding( // Keybindings: parseBindings("down"),
key.WithKeys("f10", "ctrl+c", "super+q"), // },
), {
Name: MessagesPageUpCommand,
Description: "page up",
Keybindings: parseBindings("pgup"),
},
{
Name: MessagesPageDownCommand,
Description: "page down",
Keybindings: parseBindings("pgdown"),
},
{
Name: MessagesHalfPageUpCommand,
Description: "half page up",
Keybindings: parseBindings("ctrl+alt+u"),
},
{
Name: MessagesHalfPageDownCommand,
Description: "half page down",
Keybindings: parseBindings("ctrl+alt+d"),
},
{
Name: MessagesPreviousCommand,
Description: "previous message",
Keybindings: parseBindings("ctrl+alt+k"),
},
{
Name: MessagesNextCommand,
Description: "next message",
Keybindings: parseBindings("ctrl+alt+j"),
},
{
Name: MessagesFirstCommand,
Description: "first message",
Keybindings: parseBindings("ctrl+g"),
},
{
Name: MessagesLastCommand,
Description: "last message",
Keybindings: parseBindings("ctrl+alt+g"),
},
{
Name: AppExitCommand,
Description: "exit the app",
Keybindings: parseBindings("ctrl+c", "<leader>q"),
Trigger: "exit",
}, },
} }
registry := make(CommandRegistry)
keybinds := map[string]string{}
marshalled, _ := json.Marshal(*config.Keybinds)
json.Unmarshal(marshalled, &keybinds)
for _, command := range defaults {
if keybind, ok := keybinds[string(command.Name)]; ok {
command.Keybindings = parseBindings(keybind)
}
registry[command.Name] = command
}
return registry
} }
+22 -14
View File
@@ -35,11 +35,10 @@ func (c *CommandCompletionProvider) GetEmptyMessage() string {
return "no matching commands" return "no matching commands"
} }
func getCommandCompletionItem(cmd commands.Command, space int) dialog.CompletionItemI { func getCommandCompletionItem(cmd commands.Command, space int, t theme.Theme) dialog.CompletionItemI {
t := theme.CurrentTheme()
spacer := strings.Repeat(" ", space) spacer := strings.Repeat(" ", space)
title := " /" + cmd.Name + lipgloss.NewStyle().Foreground(t.TextMuted()).Render(spacer+cmd.Description) title := " /" + cmd.Trigger + lipgloss.NewStyle().Foreground(t.TextMuted()).Render(spacer+cmd.Description)
value := "/" + cmd.Name value := string(cmd.Name)
return dialog.NewCompletionItem(dialog.CompletionItem{ return dialog.NewCompletionItem(dialog.CompletionItem{
Title: title, Title: title,
Value: value, Value: value,
@@ -47,20 +46,27 @@ func getCommandCompletionItem(cmd commands.Command, space int) dialog.Completion
} }
func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.CompletionItemI, error) { func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
t := theme.CurrentTheme()
commands := c.app.Commands
space := 1 space := 1
for _, cmd := range c.app.Commands { for _, cmd := range c.app.Commands {
if lipgloss.Width(cmd.Name) > space { if lipgloss.Width(cmd.Trigger) > space {
space = lipgloss.Width(cmd.Name) space = lipgloss.Width(cmd.Trigger)
} }
} }
space += 2 space += 2
sorted := commands.Sorted()
if query == "" { if query == "" {
// If no query, return all commands // If no query, return all commands
items := []dialog.CompletionItemI{} items := []dialog.CompletionItemI{}
for _, cmd := range c.app.Commands { for _, cmd := range sorted {
space := space - lipgloss.Width(cmd.Name) if cmd.Trigger == "" {
items = append(items, getCommandCompletionItem(cmd, space)) continue
}
space := space - lipgloss.Width(cmd.Trigger)
items = append(items, getCommandCompletionItem(cmd, space, t))
} }
return items, nil return items, nil
} }
@@ -69,10 +75,13 @@ func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.Comp
var commandNames []string var commandNames []string
commandMap := make(map[string]dialog.CompletionItemI) commandMap := make(map[string]dialog.CompletionItemI)
for _, cmd := range c.app.Commands { for _, cmd := range sorted {
space := space - lipgloss.Width(cmd.Name) if cmd.Trigger == "" {
commandNames = append(commandNames, cmd.Name) continue
commandMap[cmd.Name] = getCommandCompletionItem(cmd, space) }
space := space - lipgloss.Width(cmd.Trigger)
commandNames = append(commandNames, cmd.Trigger)
commandMap[cmd.Trigger] = getCommandCompletionItem(cmd, space, t)
} }
// Find fuzzy matches // Find fuzzy matches
@@ -88,6 +97,5 @@ func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.Comp
items = append(items, item) items = append(items, item)
} }
} }
return items, nil return items, nil
} }
+4 -1
View File
@@ -20,10 +20,13 @@ func NewCompletionManager(app *app.App) *CompletionManager {
} }
} }
func (m *CompletionManager) DefaultProvider() dialog.CompletionProvider {
return m.providers["commands"]
}
func (m *CompletionManager) GetProvider(input string) dialog.CompletionProvider { func (m *CompletionManager) GetProvider(input string) dialog.CompletionProvider {
if strings.HasPrefix(input, "/") { if strings.HasPrefix(input, "/") {
return m.providers["commands"] return m.providers["commands"]
} }
return m.providers["files"] return m.providers["files"]
} }
@@ -1,22 +0,0 @@
package chat
import (
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
type SendMsg struct {
Text string
Attachments []app.Attachment
}
func repo(width int) string {
repo := "github.com/sst/opencode"
t := theme.CurrentTheme()
return styles.BaseStyle().
Foreground(t.TextMuted()).
Width(width).
Render(repo)
}
+140 -272
View File
@@ -3,18 +3,15 @@ package chat
import ( import (
"fmt" "fmt"
"log/slog" "log/slog"
"os"
"os/exec"
"strings" "strings"
"github.com/charmbracelet/bubbles/v2/key"
"github.com/charmbracelet/bubbles/v2/spinner" "github.com/charmbracelet/bubbles/v2/spinner"
"github.com/charmbracelet/bubbles/v2/textarea"
tea "github.com/charmbracelet/bubbletea/v2" tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands" "github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/dialog" "github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/components/textarea"
"github.com/sst/opencode/internal/image" "github.com/sst/opencode/internal/image"
"github.com/sst/opencode/internal/layout" "github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/styles"
@@ -22,75 +19,32 @@ import (
"github.com/sst/opencode/internal/util" "github.com/sst/opencode/internal/util"
) )
type EditorComponent interface {
tea.Model
tea.ViewModel
layout.Sizeable
Content() string
Lines() int
Value() string
Submit() (tea.Model, tea.Cmd)
Clear() (tea.Model, tea.Cmd)
Paste() (tea.Model, tea.Cmd)
Newline() (tea.Model, tea.Cmd)
Previous() (tea.Model, tea.Cmd)
Next() (tea.Model, tea.Cmd)
}
type editorComponent struct { type editorComponent struct {
width int
height int
app *app.App app *app.App
width, height int
textarea textarea.Model textarea textarea.Model
attachments []app.Attachment attachments []app.Attachment
deleteMode bool
history []string history []string
historyIndex int historyIndex int
currentMessage string currentMessage string
spinner spinner.Model spinner spinner.Model
} }
type EditorKeyMaps struct {
Send key.Binding
OpenEditor key.Binding
Paste key.Binding
HistoryUp key.Binding
HistoryDown key.Binding
}
type DeleteAttachmentKeyMaps struct {
AttachmentDeleteMode key.Binding
Escape key.Binding
DeleteAllAttachments key.Binding
}
var editorMaps = EditorKeyMaps{
Send: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "send message"),
),
OpenEditor: key.NewBinding(
key.WithKeys("f12"),
key.WithHelp("f12", "open editor"),
),
Paste: key.NewBinding(
key.WithKeys("ctrl+v"),
key.WithHelp("ctrl+v", "paste content"),
),
HistoryUp: key.NewBinding(
key.WithKeys("up"),
key.WithHelp("up", "previous message"),
),
HistoryDown: key.NewBinding(
key.WithKeys("down"),
key.WithHelp("down", "next message"),
),
}
var DeleteKeyMaps = DeleteAttachmentKeyMaps{
AttachmentDeleteMode: key.NewBinding(
key.WithKeys("ctrl+r"),
key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
),
Escape: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "cancel delete mode"),
),
DeleteAllAttachments: key.NewBinding(
key.WithKeys("r"),
key.WithHelp("ctrl+r+r", "delete all attachments"),
),
}
const (
maxAttachments = 5
)
func (m *editorComponent) Init() tea.Cmd { func (m *editorComponent) Init() tea.Cmd {
return tea.Batch(textarea.Blink, m.spinner.Tick, tea.EnableReportFocus) return tea.Batch(textarea.Blink, m.spinner.Tick, tea.EnableReportFocus)
} }
@@ -98,154 +52,36 @@ func (m *editorComponent) Init() tea.Cmd {
func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd var cmds []tea.Cmd
var cmd tea.Cmd var cmd tea.Cmd
switch msg := msg.(type) { switch msg := msg.(type) {
case dialog.ThemeChangedMsg: case spinner.TickMsg:
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case tea.KeyPressMsg:
// Maximize editor responsiveness for printable characters
if msg.Text != "" {
m.textarea, cmd = m.textarea.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
case dialog.ThemeSelectedMsg:
m.textarea = createTextArea(&m.textarea) m.textarea = createTextArea(&m.textarea)
m.spinner = createSpinner() m.spinner = createSpinner()
return m, m.spinner.Tick return m, tea.Batch(m.spinner.Tick, textarea.Blink)
case dialog.CompletionSelectedMsg: case dialog.CompletionSelectedMsg:
if msg.IsCommand { if msg.IsCommand {
// Execute the command directly
commandName := strings.TrimPrefix(msg.CompletionValue, "/") commandName := strings.TrimPrefix(msg.CompletionValue, "/")
m.textarea.Reset() updated, cmd := m.Clear()
return m, util.CmdHandler(commands.ExecuteCommandMsg{Name: commandName}) m = updated.(*editorComponent)
cmds = append(cmds, cmd)
cmds = append(cmds, util.CmdHandler(commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)])))
return m, tea.Batch(cmds...)
} else { } else {
// For files, replace the text in the editor
existingValue := m.textarea.Value() existingValue := m.textarea.Value()
modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1) modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
m.textarea.SetValue(modifiedValue) m.textarea.SetValue(modifiedValue + " ")
return m, nil return m, nil
} }
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
if m.textarea.Value() != "" {
m.textarea.Reset()
return m, func() tea.Msg {
return nil
}
}
case "shift+enter":
value := m.textarea.Value()
m.textarea.SetValue(value + "\n")
return m, nil
}
if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
m.deleteMode = true
return m, nil
}
if key.Matches(msg, DeleteKeyMaps.DeleteAllAttachments) && m.deleteMode {
m.deleteMode = false
m.attachments = nil
return m, nil
}
// if m.deleteMode && len(msg.Runes) > 0 && unicode.IsDigit(msg.Runes[0]) {
// num := int(msg.Runes[0] - '0')
// m.deleteMode = false
// if num < 10 && len(m.attachments) > num {
// if num == 0 {
// m.attachments = m.attachments[num+1:]
// } else {
// m.attachments = slices.Delete(m.attachments, num, num+1)
// }
// return m, nil
// }
// }
if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) ||
key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) {
return m, nil
}
if key.Matches(msg, editorMaps.OpenEditor) {
if m.app.IsBusy() {
// status.Warn("Agent is working, please wait...")
return m, nil
}
value := m.textarea.Value()
m.textarea.Reset()
return m, m.openEditor(value)
}
if key.Matches(msg, DeleteKeyMaps.Escape) {
m.deleteMode = false
return m, nil
}
if key.Matches(msg, editorMaps.Paste) {
imageBytes, text, err := image.GetImageFromClipboard()
if err != nil {
slog.Error(err.Error())
return m, cmd
}
if len(imageBytes) != 0 {
attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments))
attachment := app.Attachment{FilePath: attachmentName, FileName: attachmentName, Content: imageBytes, MimeType: "image/png"}
m.attachments = append(m.attachments, attachment)
} else {
m.textarea.SetValue(m.textarea.Value() + text)
}
return m, cmd
}
// Handle history navigation with up/down arrow keys
// Only handle history navigation if the filepicker is not open and completion dialog is not open
if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryUp) {
// TODO: fix this
// && !m.app.IsFilepickerOpen() && !m.app.IsCompletionDialogOpen() {
// Get the current line number
currentLine := m.textarea.Line()
// Only navigate history if we're at the first line
if currentLine == 0 && len(m.history) > 0 {
// Save current message if we're just starting to navigate
if m.historyIndex == len(m.history) {
m.currentMessage = m.textarea.Value()
}
// Go to previous message in history
if m.historyIndex > 0 {
m.historyIndex--
m.textarea.SetValue(m.history[m.historyIndex])
}
return m, nil
}
}
if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryDown) {
// TODO: fix this
// && !m.app.IsFilepickerOpen() && !m.app.IsCompletionDialogOpen() {
// Get the current line number and total lines
currentLine := m.textarea.Line()
value := m.textarea.Value()
lines := strings.Split(value, "\n")
totalLines := len(lines)
// Only navigate history if we're at the last line
if currentLine == totalLines-1 {
if m.historyIndex < len(m.history)-1 {
// Go to next message in history
m.historyIndex++
m.textarea.SetValue(m.history[m.historyIndex])
} else if m.historyIndex == len(m.history)-1 {
// Return to the current message being composed
m.historyIndex = len(m.history)
m.textarea.SetValue(m.currentMessage)
}
return m, nil
}
}
// Handle Enter key
if m.textarea.Focused() && key.Matches(msg, editorMaps.Send) {
value := m.textarea.Value()
if len(value) > 0 && value[len(value)-1] == '\\' {
// If the last character is a backslash, remove it and add a newline
m.textarea.SetValue(value[:len(value)-1] + "\n")
return m, nil
} else {
// Otherwise, send the message
return m, m.send()
}
}
} }
m.spinner, cmd = m.spinner.Update(msg) m.spinner, cmd = m.spinner.Update(msg)
@@ -257,7 +93,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...) return m, tea.Batch(cmds...)
} }
func (m *editorComponent) View() string { func (m *editorComponent) Content() string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
base := styles.BaseStyle().Background(t.Background()).Render base := styles.BaseStyle().Background(t.Background()).Render
muted := styles.Muted().Background(t.Background()).Render muted := styles.Muted().Background(t.Background()).Render
@@ -277,9 +113,6 @@ func (m *editorComponent) View() string {
PaddingTop(1). PaddingTop(1).
PaddingBottom(1). PaddingBottom(1).
Background(t.BackgroundElement()). Background(t.BackgroundElement()).
Border(lipgloss.ThickBorder(), false, true).
BorderForeground(t.BackgroundSubtle()).
BorderBackground(t.Background()).
Render(textarea) Render(textarea)
hint := base("enter") + muted(" send ") hint := base("enter") + muted(" send ")
@@ -289,7 +122,7 @@ func (m *editorComponent) View() string {
model := "" model := ""
if m.app.Model != nil { if m.app.Model != nil {
model = base(m.app.Model.Name) + muted(" • /model") model = muted(m.app.Provider.Name) + base(" "+m.app.Model.Name)
} }
space := m.width - 2 - lipgloss.Width(model) - lipgloss.Width(hint) space := m.width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
@@ -299,66 +132,52 @@ func (m *editorComponent) View() string {
info = styles.Padded().Background(t.Background()).Render(info) info = styles.Padded().Background(t.Background()).Render(info)
content := strings.Join([]string{"", textarea, info}, "\n") content := strings.Join([]string{"", textarea, info}, "\n")
return content return content
} }
func (m *editorComponent) View() string {
if m.Lines() > 1 {
return ""
}
return m.Content()
}
func (m *editorComponent) GetSize() (width, height int) {
return m.width, m.height
}
func (m *editorComponent) SetSize(width, height int) tea.Cmd { func (m *editorComponent) SetSize(width, height int) tea.Cmd {
m.width = width m.width = width
m.height = height m.height = height
m.textarea.SetWidth(width - 5) // account for the prompt and padding right m.textarea.SetWidth(width - 5) // account for the prompt and padding right
m.textarea.SetHeight(height - 4) // account for info underneath // m.textarea.SetHeight(height - 4)
return nil return nil
} }
func (m *editorComponent) GetSize() (int, int) { func (m *editorComponent) Lines() int {
return m.width, m.height return m.textarea.LineCount()
} }
func (m *editorComponent) openEditor(value string) tea.Cmd { func (m *editorComponent) Value() string {
editor := os.Getenv("EDITOR") return m.textarea.Value()
if editor == "" {
editor = "nvim"
}
tmpfile, err := os.CreateTemp("", "msg_*.md")
tmpfile.WriteString(value)
if err != nil {
// status.Error(err.Error())
return nil
}
tmpfile.Close()
c := exec.Command(editor, tmpfile.Name()) //nolint:gosec
c.Stdin = os.Stdin
c.Stdout = os.Stdout
c.Stderr = os.Stderr
return tea.ExecProcess(c, func(err error) tea.Msg {
if err != nil {
// status.Error(err.Error())
return nil
}
content, err := os.ReadFile(tmpfile.Name())
if err != nil {
// status.Error(err.Error())
return nil
}
if len(content) == 0 {
// status.Warn("Message is empty")
return nil
}
os.Remove(tmpfile.Name())
attachments := m.attachments
m.attachments = nil
return SendMsg{
Text: string(content),
Attachments: attachments,
}
})
} }
func (m *editorComponent) send() tea.Cmd { func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
value := strings.TrimSpace(m.textarea.Value()) value := strings.TrimSpace(m.Value())
m.textarea.Reset() if value == "" {
return m, nil
}
if len(value) > 0 && value[len(value)-1] == '\\' {
// If the last character is a backslash, remove it and add a newline
m.textarea.SetValue(value[:len(value)-1] + "\n")
return m, nil
}
var cmds []tea.Cmd
updated, cmd := m.Clear()
m = updated.(*editorComponent)
cmds = append(cmds, cmd)
attachments := m.attachments attachments := m.attachments
// Save to history if not empty and not a duplicate of the last entry // Save to history if not empty and not a duplicate of the last entry
@@ -371,24 +190,77 @@ func (m *editorComponent) send() tea.Cmd {
} }
m.attachments = nil m.attachments = nil
if value == "" {
return nil cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: attachments}))
return m, tea.Batch(cmds...)
}
func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
m.textarea.Reset()
return m, nil
}
func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
imageBytes, text, err := image.GetImageFromClipboard()
if err != nil {
slog.Error(err.Error())
return m, nil
} }
if len(imageBytes) != 0 {
attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments))
attachment := app.Attachment{FilePath: attachmentName, FileName: attachmentName, Content: imageBytes, MimeType: "image/png"}
m.attachments = append(m.attachments, attachment)
} else {
m.textarea.SetValue(m.textarea.Value() + text)
}
return m, nil
}
// Check for slash command func (m *editorComponent) Newline() (tea.Model, tea.Cmd) {
// if strings.HasPrefix(value, "/") { m.textarea.Newline()
// commandName := strings.TrimPrefix(value, "/") return m, nil
// if _, ok := m.app.Commands[commandName]; ok { }
// return util.CmdHandler(commands.ExecuteCommandMsg{Name: commandName})
// }
// }
return tea.Batch( func (m *editorComponent) Previous() (tea.Model, tea.Cmd) {
util.CmdHandler(SendMsg{ currentLine := m.textarea.Line()
Text: value,
Attachments: attachments, // Only navigate history if we're at the first line
}), if currentLine == 0 && len(m.history) > 0 {
) // Save current message if we're just starting to navigate
if m.historyIndex == len(m.history) {
m.currentMessage = m.textarea.Value()
}
// Go to previous message in history
if m.historyIndex > 0 {
m.historyIndex--
m.textarea.SetValue(m.history[m.historyIndex])
}
return m, nil
}
return m, nil
}
func (m *editorComponent) Next() (tea.Model, tea.Cmd) {
currentLine := m.textarea.Line()
value := m.textarea.Value()
lines := strings.Split(value, "\n")
totalLines := len(lines)
// Only navigate history if we're at the last line
if currentLine == totalLines-1 {
if m.historyIndex < len(m.history)-1 {
// Go to next message in history
m.historyIndex++
m.textarea.SetValue(m.history[m.historyIndex])
} else if m.historyIndex == len(m.history)-1 {
// Return to the current message being composed
m.historyIndex = len(m.history)
m.textarea.SetValue(m.currentMessage)
}
return m, nil
}
return m, nil
} }
func createTextArea(existing *textarea.Model) textarea.Model { func createTextArea(existing *textarea.Model) textarea.Model {
@@ -434,11 +306,7 @@ func createSpinner() spinner.Model {
) )
} }
func (m *editorComponent) GetValue() string { func NewEditorComponent(app *app.App) EditorComponent {
return m.textarea.Value()
}
func NewEditorComponent(app *app.App) layout.ModelWithView {
s := createSpinner() s := createSpinner()
ta := createTextArea(nil) ta := createTextArea(nil)
+172 -87
View File
@@ -1,6 +1,7 @@
package chat package chat
import ( import (
"encoding/json"
"fmt" "fmt"
"path/filepath" "path/filepath"
"slices" "slices"
@@ -136,7 +137,7 @@ func renderContentBlock(content string, options ...renderingOption) string {
PaddingBottom(renderer.paddingBottom). PaddingBottom(renderer.paddingBottom).
PaddingLeft(renderer.paddingLeft). PaddingLeft(renderer.paddingLeft).
PaddingRight(renderer.paddingRight). PaddingRight(renderer.paddingRight).
Background(t.BackgroundSubtle()). Background(t.BackgroundPanel()).
Foreground(t.TextMuted()). Foreground(t.TextMuted()).
BorderStyle(lipgloss.ThickBorder()) BorderStyle(lipgloss.ThickBorder())
@@ -145,7 +146,7 @@ func renderContentBlock(content string, options ...renderingOption) string {
align = *renderer.align align = *renderer.align
} }
borderColor := t.BackgroundSubtle() borderColor := t.BackgroundPanel()
if renderer.borderColor != nil { if renderer.borderColor != nil {
borderColor = *renderer.borderColor borderColor = *renderer.borderColor
} }
@@ -158,7 +159,7 @@ func renderContentBlock(content string, options ...renderingOption) string {
AlignHorizontal(align). AlignHorizontal(align).
BorderLeftForeground(borderColor). BorderLeftForeground(borderColor).
BorderLeftBackground(t.Background()). BorderLeftBackground(t.Background()).
BorderRightForeground(t.BackgroundSubtle()). BorderRightForeground(t.BackgroundPanel()).
BorderRightBackground(t.Background()) BorderRightBackground(t.Background())
case lipgloss.Right: case lipgloss.Right:
style = style. style = style.
@@ -167,7 +168,7 @@ func renderContentBlock(content string, options ...renderingOption) string {
AlignHorizontal(align). AlignHorizontal(align).
BorderRightForeground(borderColor). BorderRightForeground(borderColor).
BorderRightBackground(t.Background()). BorderRightBackground(t.Background()).
BorderLeftForeground(t.BackgroundSubtle()). BorderLeftForeground(t.BackgroundPanel()).
BorderLeftBackground(t.Background()) BorderLeftBackground(t.Background())
} }
@@ -201,17 +202,20 @@ func renderContentBlock(content string, options ...renderingOption) string {
return content return content
} }
func calculatePadding() int {
if layout.Current.Viewport.Width < 80 {
return 5
} else if layout.Current.Viewport.Width < 120 {
return 15
} else {
return 20
}
}
func renderText(message client.MessageInfo, text string, author string) string { func renderText(message client.MessageInfo, text string, author string) string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
width := layout.Current.Container.Width width := layout.Current.Container.Width
padding := 0 padding := calculatePadding()
if layout.Current.Viewport.Width < 80 {
padding = 5
} else if layout.Current.Viewport.Width < 120 {
padding = 15
} else {
padding = 20
}
timestamp := time.UnixMilli(int64(message.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM") timestamp := time.UnixMilli(int64(message.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM")
if time.Now().Format("02 Jan 2006") == timestamp[:11] { if time.Now().Format("02 Jan 2006") == timestamp[:11] {
@@ -222,9 +226,11 @@ func renderText(message client.MessageInfo, text string, author string) string {
textWidth := max(lipgloss.Width(text), lipgloss.Width(info)) textWidth := max(lipgloss.Width(text), lipgloss.Width(info))
markdownWidth := min(textWidth, width-padding-4) // -4 for the border and padding markdownWidth := min(textWidth, width-padding-4) // -4 for the border and padding
content := toMarkdown(text, markdownWidth, t.BackgroundSubtle()) if message.Role == client.Assistant {
markdownWidth = width - padding - 4
}
content := toMarkdown(text, markdownWidth, t.BackgroundPanel())
content = strings.Join([]string{content, info}, "\n") content = strings.Join([]string{content, info}, "\n")
// content = lipgloss.JoinVertical(align, content, info)
switch message.Role { switch message.Role {
case client.User: case client.User:
@@ -245,9 +251,11 @@ func renderToolInvocation(
toolCall client.MessageToolInvocationToolCall, toolCall client.MessageToolInvocationToolCall,
result *string, result *string,
metadata client.MessageInfo_Metadata_Tool_AdditionalProperties, metadata client.MessageInfo_Metadata_Tool_AdditionalProperties,
showResult bool, showDetails bool,
isLast bool,
contentOnly bool,
) string { ) string {
ignoredTools := []string{"opencode_todoread"} ignoredTools := []string{"todoread"}
if slices.Contains(ignoredTools, toolCall.ToolName) { if slices.Contains(ignoredTools, toolCall.ToolName) {
return "" return ""
} }
@@ -256,7 +264,7 @@ func renderToolInvocation(
innerWidth := outerWidth - 6 innerWidth := outerWidth - 6
paddingTop := 0 paddingTop := 0
paddingBottom := 0 paddingBottom := 0
if showResult { if showDetails {
paddingTop = 1 paddingTop = 1
if result == nil || *result == "" { if result == nil || *result == "" {
paddingBottom = 1 paddingBottom = 1
@@ -266,7 +274,7 @@ func renderToolInvocation(
t := theme.CurrentTheme() t := theme.CurrentTheme()
style := styles.Muted(). style := styles.Muted().
Width(outerWidth). Width(outerWidth).
Background(t.BackgroundSubtle()). Background(t.BackgroundPanel()).
PaddingTop(paddingTop). PaddingTop(paddingTop).
PaddingBottom(paddingBottom). PaddingBottom(paddingBottom).
PaddingLeft(2). PaddingLeft(2).
@@ -274,26 +282,44 @@ func renderToolInvocation(
BorderLeft(true). BorderLeft(true).
BorderRight(true). BorderRight(true).
BorderBackground(t.Background()). BorderBackground(t.Background()).
BorderForeground(t.BackgroundSubtle()). BorderForeground(t.BackgroundPanel()).
BorderStyle(lipgloss.ThickBorder()) BorderStyle(lipgloss.ThickBorder())
if toolCall.State == "partial-call" { if toolCall.State == "partial-call" {
title := renderToolAction(toolCall.ToolName)
if !showDetails {
title = "∟ " + title
padding := calculatePadding()
style := lipgloss.NewStyle().Width(outerWidth - padding - 4).Background(t.BackgroundPanel())
return renderContentBlock(style.Render(title),
WithAlign(lipgloss.Left),
WithBorderColor(t.Accent()),
WithPaddingTop(0),
WithPaddingBottom(1),
)
}
style = style.Foreground(t.TextMuted()) style = style.Foreground(t.TextMuted())
return style.Render(renderToolAction(toolCall.ToolName)) return style.Render(title)
} }
toolArgs := "" toolArgs := ""
toolArgsMap := make(map[string]any) toolArgsMap := make(map[string]any)
if toolCall.Args != nil { if toolCall.Args != nil {
value := *toolCall.Args value := *toolCall.Args
m, ok := value.(map[string]any) if m, ok := value.(map[string]any); ok {
if ok {
toolArgsMap = m toolArgsMap = m
firstKey := ""
keys := make([]string, 0, len(toolArgsMap))
for key := range toolArgsMap { for key := range toolArgsMap {
firstKey = key keys = append(keys, key)
break
} }
slices.Sort(keys)
firstKey := ""
if len(keys) > 0 {
firstKey = keys[0]
}
toolArgs = renderArgs(&toolArgsMap, firstKey) toolArgs = renderArgs(&toolArgsMap, firstKey)
} }
} }
@@ -306,7 +332,7 @@ func renderToolInvocation(
if m, ok := metadata.Get("message"); ok { if m, ok := metadata.Get("message"); ok {
style = style.BorderLeftForeground(t.Error()) style = style.BorderLeftForeground(t.Error())
error = styles.BaseStyle(). error = styles.BaseStyle().
Background(t.BackgroundSubtle()). Background(t.BackgroundPanel()).
Foreground(t.Error()). Foreground(t.Error()).
Render(m.(string)) Render(m.(string))
error = renderContentBlock( error = renderContentBlock(
@@ -318,30 +344,19 @@ func renderToolInvocation(
} }
} }
elapsed := ""
start := metadata.Time.Start
end := metadata.Time.End
durationMs := end - start
duration := time.Duration(durationMs * float32(time.Millisecond))
roundedDuration := time.Duration(duration.Round(time.Millisecond))
if durationMs > 1000 {
roundedDuration = time.Duration(duration.Round(time.Second))
}
elapsed = styles.Muted().Render(roundedDuration.String())
title := "" title := ""
switch toolCall.ToolName { switch toolCall.ToolName {
case "opencode_read": case "read":
toolArgs = renderArgs(&toolArgsMap, "filePath") toolArgs = renderArgs(&toolArgsMap, "filePath")
title = fmt.Sprintf("Read: %s %s", toolArgs, elapsed) title = fmt.Sprintf("READ %s", toolArgs)
if preview, ok := metadata.Get("preview"); ok && toolArgsMap["filePath"] != nil { if preview, ok := metadata.Get("preview"); ok && toolArgsMap["filePath"] != nil {
filename := toolArgsMap["filePath"].(string) filename := toolArgsMap["filePath"].(string)
body = preview.(string) body = preview.(string)
body = renderFile(filename, body, WithTruncate(6)) body = renderFile(filename, body, WithTruncate(6))
} }
case "opencode_edit": case "edit":
if filename, ok := toolArgsMap["filePath"].(string); ok { if filename, ok := toolArgsMap["filePath"].(string); ok {
title = fmt.Sprintf("Edit: %s %s", relative(filename), elapsed) title = fmt.Sprintf("EDIT %s", relative(filename))
if d, ok := metadata.Get("diff"); ok { if d, ok := metadata.Get("diff"); ok {
patch := d.(string) patch := d.(string)
var formattedDiff string var formattedDiff string
@@ -359,12 +374,12 @@ func renderToolInvocation(
formattedDiff = lipgloss.NewStyle(). formattedDiff = lipgloss.NewStyle().
BorderStyle(lipgloss.ThickBorder()). BorderStyle(lipgloss.ThickBorder()).
BorderBackground(t.Background()). BorderBackground(t.Background()).
BorderForeground(t.BackgroundSubtle()). BorderForeground(t.BackgroundPanel()).
BorderLeft(true). BorderLeft(true).
BorderRight(true). BorderRight(true).
Render(formattedDiff) Render(formattedDiff)
if showResult { if showDetails {
style = style.Width(lipgloss.Width(formattedDiff)) style = style.Width(lipgloss.Width(formattedDiff))
title += "\n" title += "\n"
} }
@@ -380,37 +395,39 @@ func renderToolInvocation(
) )
} }
} }
case "opencode_write": case "write":
if filename, ok := toolArgsMap["filePath"].(string); ok { if filename, ok := toolArgsMap["filePath"].(string); ok {
title = fmt.Sprintf("Write: %s %s", relative(filename), elapsed) title = fmt.Sprintf("WRITE %s", relative(filename))
if content, ok := toolArgsMap["content"].(string); ok { if content, ok := toolArgsMap["content"].(string); ok {
body = renderFile(filename, content) body = renderFile(filename, content)
} }
} }
case "opencode_bash": case "bash":
if description, ok := toolArgsMap["description"].(string); ok { if description, ok := toolArgsMap["description"].(string); ok {
title = fmt.Sprintf("Shell: %s %s", description, elapsed) title = fmt.Sprintf("SHELL %s", description)
} }
if stdout, ok := metadata.Get("stdout"); ok { if stdout, ok := metadata.Get("stdout"); ok {
command := toolArgsMap["command"].(string) command := toolArgsMap["command"].(string)
stdout := stdout.(string) stdout := stdout.(string)
body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout) body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
body = toMarkdown(body, innerWidth, t.BackgroundSubtle()) body = toMarkdown(body, innerWidth, t.BackgroundPanel())
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1)) body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
} }
case "opencode_webfetch": case "webfetch":
toolArgs = renderArgs(&toolArgsMap, "url") toolArgs = renderArgs(&toolArgsMap, "url")
title = fmt.Sprintf("Fetching: %s %s", toolArgs, elapsed) title = fmt.Sprintf("FETCH %s", toolArgs)
if format, ok := toolArgsMap["format"].(string); ok { if format, ok := toolArgsMap["format"].(string); ok {
body = *result if result != nil {
body = truncateHeight(body, 10) body = *result
if format == "html" || format == "markdown" { body = truncateHeight(body, 10)
body = toMarkdown(body, innerWidth, t.BackgroundSubtle()) if format == "html" || format == "markdown" {
body = toMarkdown(body, innerWidth, t.BackgroundPanel())
}
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
} }
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
} }
case "opencode_todowrite": case "todowrite":
title = fmt.Sprintf("Planning %s", elapsed) title = fmt.Sprintf("PLAN")
if to, ok := metadata.Get("todos"); ok && finished { if to, ok := metadata.Get("todos"); ok && finished {
todos := to.([]any) todos := to.([]any)
@@ -426,17 +443,79 @@ func renderToolInvocation(
body += fmt.Sprintf("- [ ] %s\n", content) body += fmt.Sprintf("- [ ] %s\n", content)
} }
} }
body = toMarkdown(body, innerWidth, t.BackgroundSubtle()) body = toMarkdown(body, innerWidth, t.BackgroundPanel())
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1)) body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
} }
case "task":
if description, ok := toolArgsMap["description"].(string); ok {
title = fmt.Sprintf("TASK %s", description)
if summary, ok := metadata.Get("summary"); ok {
toolcalls := summary.([]any)
// toolcalls :=
steps := []string{}
for _, toolcall := range toolcalls {
call := toolcall.(map[string]any)
if toolInvocation, ok := call["toolInvocation"].(map[string]any); ok {
data, _ := json.Marshal(toolInvocation)
var toolCall client.MessageToolInvocationToolCall
_ = json.Unmarshal(data, &toolCall)
if metadata, ok := call["metadata"].(map[string]any); ok {
data, _ = json.Marshal(metadata)
var toolMetadata client.MessageInfo_Metadata_Tool_AdditionalProperties
_ = json.Unmarshal(data, &toolMetadata)
step := renderToolInvocation(
toolCall,
nil,
toolMetadata,
false,
false,
true,
)
steps = append(steps, step)
}
}
}
body = strings.Join(steps, "\n")
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
}
}
default: default:
toolName := renderToolName(toolCall.ToolName) toolName := renderToolName(toolCall.ToolName)
title = fmt.Sprintf("%s: %s %s", toolName, toolArgs, elapsed) title = fmt.Sprintf("%s %s", toolName, toolArgs)
if result == nil {
empty := ""
result = &empty
}
body = *result body = *result
body = truncateHeight(body, 10) body = truncateHeight(body, 10)
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1)) body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
} }
if contentOnly {
title = "∟ " + title
return title
}
if !showDetails {
title = "∟ " + title
padding := calculatePadding()
style := lipgloss.NewStyle().Width(outerWidth - padding - 4).Background(t.BackgroundPanel())
paddingBottom := 0
if isLast {
paddingBottom = 1
}
return renderContentBlock(style.Render(title),
WithAlign(lipgloss.Left),
WithBorderColor(t.Accent()),
WithPaddingTop(0),
WithPaddingBottom(paddingBottom),
)
}
if body == "" && error == "" { if body == "" && error == "" {
body = *result body = *result
body = truncateHeight(body, 10) body = truncateHeight(body, 10)
@@ -450,10 +529,10 @@ func renderToolInvocation(
content, content,
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())), lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
) )
if showResult && body != "" && error == "" { if showDetails && body != "" && error == "" {
content += "\n" + body content += "\n" + body
} }
if showResult && error != "" { if showDetails && error != "" {
content += "\n" + error content += "\n" + error
} }
return content return content
@@ -461,22 +540,18 @@ func renderToolInvocation(
func renderToolName(name string) string { func renderToolName(name string) string {
switch name { switch name {
// case agent.AgentToolName: case "list":
// return "Task" return "LIST"
case "opencode_ls": case "webfetch":
return "List" return "FETCH"
case "opencode_webfetch": case "todowrite":
return "Fetch" return "PLAN"
case "opencode_todoread":
return "Planning"
case "opencode_todowrite":
return "Planning"
default: default:
normalizedName := name normalizedName := name
if strings.HasPrefix(name, "opencode_") { if strings.HasPrefix(name, "opencode_") {
normalizedName = strings.TrimPrefix(name, "opencode_") normalizedName = strings.TrimPrefix(name, "opencode_")
} }
return cases.Title(language.Und).String(normalizedName) return cases.Upper(language.Und).String(normalizedName)
} }
} }
@@ -517,34 +592,36 @@ func renderFile(filename string, content string, options ...fileRenderingOption)
content = truncateHeight(content, renderer.height) content = truncateHeight(content, renderer.height)
} }
content = fmt.Sprintf("```%s\n%s\n```", extension(renderer.filename), content) content = fmt.Sprintf("```%s\n%s\n```", extension(renderer.filename), content)
content = toMarkdown(content, width, t.BackgroundSubtle()) content = toMarkdown(content, width, t.BackgroundPanel())
return renderContentBlock(content, WithFullWidth(), WithMarginBottom(1)) return renderContentBlock(content, WithFullWidth(), WithMarginBottom(1))
} }
func renderToolAction(name string) string { func renderToolAction(name string) string {
switch name { switch name {
// case agent.AgentToolName: case "task":
// return "Preparing prompt..." return "Searching..."
case "opencode_bash": case "bash":
return "Building command..." return "Building command..."
case "opencode_edit": case "edit":
return "Preparing edit..." return "Preparing edit..."
case "opencode_fetch": case "webfetch":
return "Writing fetch..." return "Fetching from the web..."
case "opencode_glob": case "glob":
return "Finding files..." return "Finding files..."
case "opencode_grep": case "grep":
return "Searching content..." return "Searching content..."
case "opencode_ls": case "list":
return "Listing directory..." return "Listing directory..."
case "opencode_read": case "read":
return "Reading file..." return "Reading file..."
case "opencode_write": case "write":
return "Preparing write..." return "Preparing write..."
case "opencode_patch": case "todowrite", "todoread":
return "Planning..."
case "patch":
return "Preparing patch..." return "Preparing patch..."
case "opencode_batch": case "batch":
return "Running batch operations..." return "Running batch operations..."
} }
return "Working..." return "Working..."
@@ -554,9 +631,17 @@ func renderArgs(args *map[string]any, titleKey string) string {
if args == nil || len(*args) == 0 { if args == nil || len(*args) == 0 {
return "" return ""
} }
keys := make([]string, 0, len(*args))
for key := range *args {
keys = append(keys, key)
}
slices.Sort(keys)
title := "" title := ""
parts := []string{} parts := []string{}
for key, value := range *args { for _, key := range keys {
value := (*args)[key]
if value == nil { if value == nil {
continue continue
} }
+154 -101
View File
@@ -1,116 +1,102 @@
package chat package chat
import ( import (
"slices"
"strings" "strings"
"time" "time"
"github.com/charmbracelet/bubbles/v2/key"
"github.com/charmbracelet/bubbles/v2/spinner" "github.com/charmbracelet/bubbles/v2/spinner"
"github.com/charmbracelet/bubbles/v2/viewport" "github.com/charmbracelet/bubbles/v2/viewport"
tea "github.com/charmbracelet/bubbletea/v2" tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/commands"
"github.com/sst/opencode/internal/components/dialog" "github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/layout" "github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/state"
"github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/pkg/client" "github.com/sst/opencode/pkg/client"
) )
type MessagesComponent interface {
tea.Model
tea.ViewModel
PageUp() (tea.Model, tea.Cmd)
PageDown() (tea.Model, tea.Cmd)
HalfPageUp() (tea.Model, tea.Cmd)
HalfPageDown() (tea.Model, tea.Cmd)
First() (tea.Model, tea.Cmd)
Last() (tea.Model, tea.Cmd)
// Previous() (tea.Model, tea.Cmd)
// Next() (tea.Model, tea.Cmd)
ToolDetailsVisible() bool
}
type messagesComponent struct { type messagesComponent struct {
app *app.App
width, height int width, height int
app *app.App
viewport viewport.Model viewport viewport.Model
spinner spinner.Model spinner spinner.Model
rendering bool
attachments viewport.Model attachments viewport.Model
showToolResults bool commands commands.CommandsComponent
cache *MessageCache cache *MessageCache
rendering bool
showToolDetails bool
tail bool tail bool
} }
type renderFinishedMsg struct{} type renderFinishedMsg struct{}
type ToggleToolMessagesMsg struct{} type ToggleToolDetailsMsg struct{}
type MessageKeys struct {
PageDown key.Binding
PageUp key.Binding
HalfPageUp key.Binding
HalfPageDown key.Binding
}
var messageKeys = MessageKeys{
PageDown: key.NewBinding(
key.WithKeys("pgdown"),
key.WithHelp("f/pgdn", "page down"),
),
PageUp: key.NewBinding(
key.WithKeys("pgup"),
key.WithHelp("b/pgup", "page up"),
),
HalfPageUp: key.NewBinding(
key.WithKeys("ctrl+u"),
key.WithHelp("ctrl+u", "½ page up"),
),
HalfPageDown: key.NewBinding(
key.WithKeys("ctrl+d", "ctrl+d"),
key.WithHelp("ctrl+d", "½ page down"),
),
}
func (m *messagesComponent) Init() tea.Cmd { func (m *messagesComponent) Init() tea.Cmd {
return tea.Batch(m.viewport.Init(), m.spinner.Tick) return tea.Batch(m.viewport.Init(), m.spinner.Tick, m.commands.Init())
} }
func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd var cmds []tea.Cmd
switch msg := msg.(type) { switch msg.(type) {
case SendMsg: case app.SendMsg:
m.viewport.GotoBottom() m.viewport.GotoBottom()
m.tail = true m.tail = true
return m, nil return m, nil
case dialog.ThemeChangedMsg: case dialog.ThemeSelectedMsg:
m.cache.Clear() m.cache.Clear()
m.renderView() return m, m.Reload()
return m, nil case ToggleToolDetailsMsg:
case ToggleToolMessagesMsg: m.showToolDetails = !m.showToolDetails
m.showToolResults = !m.showToolResults return m, m.Reload()
m.renderView() case app.SessionSelectedMsg:
return m, nil
case state.SessionSelectedMsg:
m.cache.Clear() m.cache.Clear()
cmd := m.Reload() m.tail = true
m.viewport.GotoBottom() return m, m.Reload()
return m, cmd case app.SessionClearedMsg:
case state.SessionClearedMsg:
m.cache.Clear() m.cache.Clear()
cmd := m.Reload() cmd := m.Reload()
return m, cmd return m, cmd
case tea.KeyMsg:
if key.Matches(msg, messageKeys.PageUp) ||
key.Matches(msg, messageKeys.PageDown) ||
key.Matches(msg, messageKeys.HalfPageUp) ||
key.Matches(msg, messageKeys.HalfPageDown) {
u, cmd := m.viewport.Update(msg)
m.viewport = u
m.tail = m.viewport.AtBottom()
cmds = append(cmds, cmd)
}
case renderFinishedMsg: case renderFinishedMsg:
m.rendering = false m.rendering = false
if m.tail { if m.tail {
m.viewport.GotoBottom() m.viewport.GotoBottom()
} }
case state.StateUpdatedMsg: case client.EventSessionUpdated, client.EventMessageUpdated:
m.renderView() m.renderView()
if m.tail { if m.tail {
m.viewport.GotoBottom() m.viewport.GotoBottom()
} }
} }
viewport, cmd := m.viewport.Update(msg)
m.viewport = viewport
m.tail = m.viewport.AtBottom()
cmds = append(cmds, cmd)
spinner, cmd := m.spinner.Update(msg) spinner, cmd := m.spinner.Update(msg)
m.spinner = spinner m.spinner = spinner
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
updated, cmd := m.commands.Update(msg)
m.commands = updated.(commands.CommandsComponent)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...) return m, tea.Batch(cmds...)
} }
@@ -135,6 +121,17 @@ func (m *messagesComponent) renderView() {
for _, message := range m.app.Messages { for _, message := range m.app.Messages {
var content string var content string
var cached bool var cached bool
lastToolIndex := 0
lastToolIndices := []int{}
for i, p := range message.Parts {
part, _ := p.ValueByDiscriminator()
switch part.(type) {
case client.MessagePartText:
lastToolIndices = append(lastToolIndices, lastToolIndex)
case client.MessagePartToolInvocation:
lastToolIndex = i
}
}
author := "" author := ""
switch message.Role { switch message.Role {
@@ -144,7 +141,7 @@ func (m *messagesComponent) renderView() {
author = message.Metadata.Assistant.ModelID author = message.Metadata.Assistant.ModelID
} }
for _, p := range message.Parts { for i, p := range message.Parts {
part, err := p.ValueByDiscriminator() part, err := p.ValueByDiscriminator()
if err != nil { if err != nil {
continue //TODO: handle error? continue //TODO: handle error?
@@ -171,6 +168,7 @@ func (m *messagesComponent) renderView() {
previousBlockType = assistantTextBlock previousBlockType = assistantTextBlock
} }
case client.MessagePartToolInvocation: case client.MessagePartToolInvocation:
isLastToolInvocation := slices.Contains(lastToolIndices, i)
toolInvocationPart := part.(client.MessagePartToolInvocation) toolInvocationPart := part.(client.MessagePartToolInvocation)
toolCall, _ := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolCall() toolCall, _ := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolCall()
metadata := client.MessageInfo_Metadata_Tool_AdditionalProperties{} metadata := client.MessageInfo_Metadata_Tool_AdditionalProperties{}
@@ -186,20 +184,34 @@ func (m *messagesComponent) renderView() {
if toolCall.State == "result" { if toolCall.State == "result" {
key := m.cache.GenerateKey(message.Id, key := m.cache.GenerateKey(message.Id,
toolCall.ToolCallId, toolCall.ToolCallId,
m.showToolResults, m.showToolDetails,
layout.Current.Viewport.Width, layout.Current.Viewport.Width,
) )
content, cached = m.cache.Get(key) content, cached = m.cache.Get(key)
if !cached { if !cached {
content = renderToolInvocation(toolCall, result, metadata, m.showToolResults) content = renderToolInvocation(
toolCall,
result,
metadata,
m.showToolDetails,
isLastToolInvocation,
false,
)
m.cache.Set(key, content) m.cache.Set(key, content)
} }
} else { } else {
// if the tool call isn't finished, never cache // if the tool call isn't finished, don't cache
content = renderToolInvocation(toolCall, result, metadata, m.showToolResults) content = renderToolInvocation(
toolCall,
result,
metadata,
m.showToolDetails,
isLastToolInvocation,
false,
)
} }
if previousBlockType != toolInvocationBlock { if previousBlockType != toolInvocationBlock && m.showToolDetails {
blocks = append(blocks, "") blocks = append(blocks, "")
} }
blocks = append(blocks, content) blocks = append(blocks, content)
@@ -261,7 +273,7 @@ func (m *messagesComponent) header() string {
BorderLeft(true). BorderLeft(true).
BorderRight(true). BorderRight(true).
BorderBackground(t.Background()). BorderBackground(t.Background()).
BorderForeground(t.BackgroundSubtle()). BorderForeground(t.BackgroundElement()).
BorderStyle(lipgloss.ThickBorder()). BorderStyle(lipgloss.ThickBorder()).
Render(header) Render(header)
@@ -273,7 +285,13 @@ func (m *messagesComponent) View() string {
return m.home() return m.home()
} }
if m.rendering { if m.rendering {
return m.viewport.View() return lipgloss.Place(
m.width,
m.height,
lipgloss.Center,
lipgloss.Center,
"Loading session...",
)
} }
t := theme.CurrentTheme() t := theme.CurrentTheme()
return lipgloss.JoinVertical( return lipgloss.JoinVertical(
@@ -311,50 +329,43 @@ func (m *messagesComponent) home() string {
// cwd := app.Info.Path.Cwd // cwd := app.Info.Path.Cwd
// config := app.Info.Path.Config // config := app.Info.Path.Config
commands := [][]string{ versionStyle := lipgloss.NewStyle().
{"/help", "show help"}, Background(t.Background()).
{"/sessions", "list sessions"}, Foreground(t.TextMuted()).
{"/new", "start a new session"}, Width(lipgloss.Width(logo)).
{"/model", "switch model"}, Align(lipgloss.Right)
{"/theme", "switch theme"}, version := versionStyle.Render(m.app.Version)
{"/quit", "exit the app"},
}
commandLines := []string{} logoAndVersion := strings.Join([]string{logo, version}, "\n")
for _, command := range commands { logoAndVersion = lipgloss.PlaceHorizontal(
commandLines = append(commandLines, (base(command[0]+" ") + muted(command[1]))) m.width,
} lipgloss.Center,
logoAndVersion,
logoAndVersion := lipgloss.JoinVertical( lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
lipgloss.Right, )
logo, m.commands.SetBackgroundColor(t.Background())
muted(m.app.Version), commands := lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
m.commands.View(),
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
) )
lines := []string{} lines := []string{}
lines = append(lines, "")
lines = append(lines, "")
lines = append(lines, logoAndVersion) lines = append(lines, logoAndVersion)
lines = append(lines, "") lines = append(lines, "")
lines = append(lines, "")
// lines = append(lines, base("cwd ")+muted(cwd)) // lines = append(lines, base("cwd ")+muted(cwd))
// lines = append(lines, base("config ")+muted(config)) // lines = append(lines, base("config ")+muted(config))
// lines = append(lines, "") // lines = append(lines, "")
lines = append(lines, commandLines...) lines = append(lines, commands)
lines = append(lines, "")
if m.rendering {
lines = append(lines, base("Loading session..."))
} else {
lines = append(lines, "")
}
return lipgloss.Place( return lipgloss.Place(
m.width, m.width,
m.height, m.height,
lipgloss.Center, lipgloss.Center,
lipgloss.Center, lipgloss.Center,
baseStyle.Width(lipgloss.Width(logoAndVersion)).Render( baseStyle.Render(strings.Join(lines, "\n")),
strings.Join(lines, "\n"),
),
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())), lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
) )
} }
@@ -373,6 +384,7 @@ func (m *messagesComponent) SetSize(width, height int) tea.Cmd {
m.viewport.SetHeight(height - lipgloss.Height(m.header())) m.viewport.SetHeight(height - lipgloss.Height(m.header()))
m.attachments.SetWidth(width + 40) m.attachments.SetWidth(width + 40)
m.attachments.SetHeight(3) m.attachments.SetHeight(3)
m.commands.SetSize(width, height)
m.renderView() m.renderView()
return nil return nil
} }
@@ -389,7 +401,43 @@ func (m *messagesComponent) Reload() tea.Cmd {
} }
} }
func NewMessagesComponent(app *app.App) layout.ModelWithView { func (m *messagesComponent) PageUp() (tea.Model, tea.Cmd) {
m.viewport.ViewUp()
return m, nil
}
func (m *messagesComponent) PageDown() (tea.Model, tea.Cmd) {
m.viewport.ViewDown()
return m, nil
}
func (m *messagesComponent) HalfPageUp() (tea.Model, tea.Cmd) {
m.viewport.HalfViewUp()
return m, nil
}
func (m *messagesComponent) HalfPageDown() (tea.Model, tea.Cmd) {
m.viewport.HalfViewDown()
return m, nil
}
func (m *messagesComponent) First() (tea.Model, tea.Cmd) {
m.viewport.GotoTop()
m.tail = false
return m, nil
}
func (m *messagesComponent) Last() (tea.Model, tea.Cmd) {
m.viewport.GotoBottom()
m.tail = true
return m, nil
}
func (m *messagesComponent) ToolDetailsVisible() bool {
return m.showToolDetails
}
func NewMessagesComponent(app *app.App) MessagesComponent {
customSpinner := spinner.Spinner{ customSpinner := spinner.Spinner{
Frames: []string{" ", "┃", "┃"}, Frames: []string{" ", "┃", "┃"},
FPS: time.Second / 3, FPS: time.Second / 3,
@@ -398,17 +446,22 @@ func NewMessagesComponent(app *app.App) layout.ModelWithView {
vp := viewport.New() vp := viewport.New()
attachments := viewport.New() attachments := viewport.New()
vp.KeyMap.PageUp = messageKeys.PageUp vp.KeyMap = viewport.KeyMap{}
vp.KeyMap.PageDown = messageKeys.PageDown
vp.KeyMap.HalfPageUp = messageKeys.HalfPageUp t := theme.CurrentTheme()
vp.KeyMap.HalfPageDown = messageKeys.HalfPageDown commandsView := commands.New(
app,
commands.WithBackground(t.Background()),
commands.WithLimit(6),
)
return &messagesComponent{ return &messagesComponent{
app: app, app: app,
viewport: vp, viewport: vp,
spinner: s, spinner: s,
attachments: attachments, attachments: attachments,
showToolResults: true, commands: commandsView,
showToolDetails: true,
cache: NewMessageCache(), cache: NewMessageCache(),
tail: true, tail: true,
} }
@@ -0,0 +1,201 @@
package commands
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/lipgloss/v2/compat"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
type CommandsComponent interface {
tea.Model
tea.ViewModel
layout.Sizeable
SetBackgroundColor(color compat.AdaptiveColor)
}
type commandsComponent struct {
app *app.App
width, height int
showKeybinds bool
background *compat.AdaptiveColor
limit *int
}
func (c *commandsComponent) SetSize(width, height int) tea.Cmd {
c.width = width
c.height = height
return nil
}
func (c *commandsComponent) GetSize() (int, int) {
return c.width, c.height
}
func (c *commandsComponent) SetBackgroundColor(color compat.AdaptiveColor) {
c.background = &color
}
func (c *commandsComponent) Init() tea.Cmd {
return nil
}
func (c *commandsComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
c.width = msg.Width
c.height = msg.Height
}
return c, nil
}
func (c *commandsComponent) View() string {
t := theme.CurrentTheme()
triggerStyle := lipgloss.NewStyle().
Foreground(t.Primary()).
Bold(true)
descriptionStyle := lipgloss.NewStyle().
Foreground(t.Text())
keybindStyle := lipgloss.NewStyle().
Foreground(t.TextMuted())
if c.background != nil {
triggerStyle = triggerStyle.Background(*c.background)
descriptionStyle = descriptionStyle.Background(*c.background)
keybindStyle = keybindStyle.Background(*c.background)
}
var commandsWithTriggers []commands.Command
for _, cmd := range c.app.Commands.Sorted() {
if cmd.Trigger != "" {
commandsWithTriggers = append(commandsWithTriggers, cmd)
}
}
if c.limit != nil && len(commandsWithTriggers) > *c.limit {
commandsWithTriggers = commandsWithTriggers[:*c.limit]
}
if len(commandsWithTriggers) == 0 {
return styles.Muted().Render("No commands with triggers available")
}
// Calculate column widths
maxTriggerWidth := 0
maxDescriptionWidth := 0
maxKeybindWidth := 0
// Prepare command data
type commandRow struct {
trigger string
description string
keybinds string
}
rows := make([]commandRow, 0, len(commandsWithTriggers))
for _, cmd := range commandsWithTriggers {
trigger := "/" + cmd.Trigger
description := cmd.Description
// Format keybindings
var keybindStrs []string
if c.showKeybinds {
for _, kb := range cmd.Keybindings {
if kb.RequiresLeader {
keybindStrs = append(keybindStrs, *c.app.Config.Keybinds.Leader+" "+kb.Key)
} else {
keybindStrs = append(keybindStrs, kb.Key)
}
}
}
keybinds := strings.Join(keybindStrs, ", ")
rows = append(rows, commandRow{
trigger: trigger,
description: description,
keybinds: keybinds,
})
// Update max widths
if len(trigger) > maxTriggerWidth {
maxTriggerWidth = len(trigger)
}
if len(description) > maxDescriptionWidth {
maxDescriptionWidth = len(description)
}
if len(keybinds) > maxKeybindWidth {
maxKeybindWidth = len(keybinds)
}
}
// Add padding between columns
columnPadding := 3
// Build the output
var output strings.Builder
for _, row := range rows {
// Pad each column to align properly
trigger := fmt.Sprintf("%-*s", maxTriggerWidth, row.trigger)
description := fmt.Sprintf("%-*s", maxDescriptionWidth, row.description)
// Apply styles and combine
line := triggerStyle.Render(trigger) +
triggerStyle.Render(strings.Repeat(" ", columnPadding)) +
descriptionStyle.Render(description)
if c.showKeybinds && row.keybinds != "" {
line += keybindStyle.Render(strings.Repeat(" ", columnPadding)) +
keybindStyle.Render(row.keybinds)
}
output.WriteString(line + "\n")
}
// Remove trailing newline
result := strings.TrimSuffix(output.String(), "\n")
return result
}
type Option func(*commandsComponent)
func WithKeybinds(show bool) Option {
return func(c *commandsComponent) {
c.showKeybinds = show
}
}
func WithBackground(background compat.AdaptiveColor) Option {
return func(c *commandsComponent) {
c.background = &background
}
}
func WithLimit(limit int) Option {
return func(c *commandsComponent) {
c.limit = &limit
}
}
func New(app *app.App, opts ...Option) CommandsComponent {
c := &commandsComponent{
app: app,
background: nil,
showKeybinds: true,
}
for _, opt := range opts {
opt(c)
}
return c
}
@@ -1,12 +1,13 @@
package dialog package dialog
import ( import (
"log/slog"
"github.com/charmbracelet/bubbles/v2/key" "github.com/charmbracelet/bubbles/v2/key"
"github.com/charmbracelet/bubbles/v2/textarea" "github.com/charmbracelet/bubbles/v2/textarea"
tea "github.com/charmbracelet/bubbletea/v2" tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2" "github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/list" "github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util" "github.com/sst/opencode/internal/util"
@@ -76,7 +77,8 @@ type CompletionDialogCompleteItemMsg struct {
type CompletionDialogCloseMsg struct{} type CompletionDialogCloseMsg struct{}
type CompletionDialog interface { type CompletionDialog interface {
layout.ModelWithView tea.Model
tea.ViewModel
SetWidth(width int) SetWidth(width int)
IsEmpty() bool IsEmpty() bool
SetProvider(provider CompletionProvider) SetProvider(provider CompletionProvider)
@@ -98,10 +100,10 @@ type completionDialogKeyMap struct {
var completionDialogKeys = completionDialogKeyMap{ var completionDialogKeys = completionDialogKeyMap{
Complete: key.NewBinding( Complete: key.NewBinding(
key.WithKeys("tab", "enter"), key.WithKeys("tab", "enter", "right"),
), ),
Cancel: key.NewBinding( Cancel: key.NewBinding(
key.WithKeys(" ", "esc", "backspace"), key.WithKeys(" ", "esc", "backspace", "ctrl+c"),
), ),
} }
@@ -109,39 +111,13 @@ func (c *completionDialogComponent) Init() tea.Cmd {
return nil return nil
} }
func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd {
value := c.pseudoSearchTextArea.Value()
if value == "" {
return nil
}
// Check if this is a command completion
isCommand := c.completionProvider.GetId() == "commands"
return tea.Batch(
util.CmdHandler(CompletionSelectedMsg{
SearchString: value,
CompletionValue: item.GetValue(),
IsCommand: isCommand,
}),
c.close(),
)
}
func (c *completionDialogComponent) close() tea.Cmd {
c.list.SetItems([]CompletionItemI{})
c.pseudoSearchTextArea.Reset()
c.pseudoSearchTextArea.Blur()
return util.CmdHandler(CompletionDialogCloseMsg{})
}
func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd var cmds []tea.Cmd
switch msg := msg.(type) { switch msg := msg.(type) {
case []CompletionItemI: case []CompletionItemI:
c.list.SetItems(msg) c.list.SetItems(msg)
case app.CompletionDialogTriggerdMsg:
c.pseudoSearchTextArea.SetValue(msg.InitialValue)
case tea.KeyMsg: case tea.KeyMsg:
if c.pseudoSearchTextArea.Focused() { if c.pseudoSearchTextArea.Focused() {
if !key.Matches(msg, completionDialogKeys.Complete) { if !key.Matches(msg, completionDialogKeys.Complete) {
@@ -160,9 +136,8 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmd = func() tea.Msg { cmd = func() tea.Msg {
items, err := c.completionProvider.GetChildEntries(query) items, err := c.completionProvider.GetChildEntries(query)
if err != nil { if err != nil {
// status.Error(err.Error()) slog.Error("Failed to get completion items", "error", err)
} }
// c.list.SetItems(items)
return items return items
} }
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
@@ -192,13 +167,12 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmd := func() tea.Msg { cmd := func() tea.Msg {
items, err := c.completionProvider.GetChildEntries("") items, err := c.completionProvider.GetChildEntries("")
if err != nil { if err != nil {
// status.Error(err.Error()) slog.Error("Failed to get completion items", "error", err)
} }
return items return items
} }
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
cmds = append(cmds, c.pseudoSearchTextArea.Focus()) cmds = append(cmds, c.pseudoSearchTextArea.Focus())
c.pseudoSearchTextArea.SetValue(msg.String())
return c, tea.Batch(cmds...) return c, tea.Batch(cmds...)
} }
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
@@ -227,13 +201,6 @@ func (c *completionDialogComponent) View() string {
return baseStyle.Padding(0, 0). return baseStyle.Padding(0, 0).
Background(t.BackgroundElement()). Background(t.BackgroundElement()).
Border(lipgloss.ThickBorder()).
BorderTop(false).
BorderBottom(false).
BorderRight(true).
BorderLeft(true).
BorderBackground(t.Background()).
BorderForeground(t.BackgroundSubtle()).
Width(c.width). Width(c.width).
Render(c.list.View()) Render(c.list.View())
} }
@@ -250,9 +217,35 @@ func (c *completionDialogComponent) SetProvider(provider CompletionProvider) {
if c.completionProvider.GetId() != provider.GetId() { if c.completionProvider.GetId() != provider.GetId() {
c.completionProvider = provider c.completionProvider = provider
c.list.SetEmptyMessage(" " + provider.GetEmptyMessage()) c.list.SetEmptyMessage(" " + provider.GetEmptyMessage())
c.list.SetItems([]CompletionItemI{})
} }
} }
func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd {
value := c.pseudoSearchTextArea.Value()
if value == "" {
return nil
}
// Check if this is a command completion
isCommand := c.completionProvider.GetId() == "commands"
return tea.Batch(
util.CmdHandler(CompletionSelectedMsg{
SearchString: value,
CompletionValue: item.GetValue(),
IsCommand: isCommand,
}),
c.close(),
)
}
func (c *completionDialogComponent) close() tea.Cmd {
c.pseudoSearchTextArea.Reset()
c.pseudoSearchTextArea.Blur()
return util.CmdHandler(CompletionDialogCloseMsg{})
}
func NewCompletionDialogComponent(completionProvider CompletionProvider) CompletionDialog { func NewCompletionDialogComponent(completionProvider CompletionProvider) CompletionDialog {
ti := textarea.New() ti := textarea.New()
@@ -266,7 +259,7 @@ func NewCompletionDialogComponent(completionProvider CompletionProvider) Complet
go func() { go func() {
items, err := completionProvider.GetChildEntries("") items, err := completionProvider.GetChildEntries("")
if err != nil { if err != nil {
// status.Error(err.Error()) slog.Error("Failed to get completion items", "error", err)
} }
li.SetItems(items) li.SetItems(items)
}() }()
+21 -35
View File
@@ -3,9 +3,9 @@ package dialog
import ( import (
"strings" "strings"
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2" tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/modal" "github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/layout" "github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/theme"
@@ -15,28 +15,9 @@ type helpDialog struct {
width int width int
height int height int
modal *modal.Modal modal *modal.Modal
bindings []key.Binding commands []commands.Command
} }
// func (i bindingItem) Render(selected bool, width int) string {
// t := theme.CurrentTheme()
// baseStyle := styles.BaseStyle().
// Width(width - 2).
// Background(t.BackgroundElement())
//
// if selected {
// baseStyle = baseStyle.
// Background(t.Primary()).
// Foreground(t.BackgroundElement()).
// Bold(true)
// } else {
// baseStyle = baseStyle.
// Foreground(t.Text())
// }
//
// return baseStyle.Padding(0, 1).Render(i.binding.Help().Desc)
// }
func (h *helpDialog) Init() tea.Cmd { func (h *helpDialog) Init() tea.Cmd {
return nil return nil
} }
@@ -63,19 +44,24 @@ func (h *helpDialog) View() string {
PaddingLeft(1).Background(t.BackgroundElement()) PaddingLeft(1).Background(t.BackgroundElement())
lines := []string{} lines := []string{}
for _, b := range h.bindings { for _, b := range h.commands {
content := keyStyle.Render(b.Help().Key) // Only interested in slash commands
content += descStyle.Render(" " + b.Help().Desc) if b.Trigger == "" {
for i, key := range b.Keys() { continue
if i == 0 {
keyString := " (" + strings.ToUpper(key) + ")"
// space := max(h.width-lipgloss.Width(content)-lipgloss.Width(keyString), 0)
// spacer := strings.Repeat(" ", space)
// content += descStyle.Render(spacer)
content += descStyle.Render(keyString)
}
} }
content := keyStyle.Render("/" + b.Trigger)
content += descStyle.Render(" " + b.Description)
// for i, key := range b.Keybindings {
// if i == 0 {
// keyString := " (" + key.Key + ")"
// space := max(h.width-lipgloss.Width(content)-lipgloss.Width(keyString), 0)
// spacer := strings.Repeat(" ", space)
// content += descStyle.Render(spacer)
// content += descStyle.Render(keyString)
// }
// }
lines = append(lines, contentStyle.Render(content)) lines = append(lines, contentStyle.Render(content))
} }
@@ -94,9 +80,9 @@ type HelpDialog interface {
layout.Modal layout.Modal
} }
func NewHelpDialog(bindings ...key.Binding) HelpDialog { func NewHelpDialog(commands []commands.Command) HelpDialog {
return &helpDialog{ return &helpDialog{
bindings: bindings, commands: commands,
modal: modal.New(), modal: modal.New(modal.WithTitle("Help")),
} }
} }
@@ -13,7 +13,6 @@ import (
"github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/modal" "github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/layout" "github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/state"
"github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util" "github.com/sst/opencode/internal/util"
@@ -115,7 +114,7 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Sequence( return m, tea.Sequence(
util.CmdHandler(modal.CloseModalMsg{}), util.CmdHandler(modal.CloseModalMsg{}),
util.CmdHandler( util.CmdHandler(
state.ModelSelectedMsg{ app.ModelSelectedMsg{
Provider: m.provider, Provider: m.provider,
Model: models[m.selectedIdx], Model: models[m.selectedIdx],
}), }),
@@ -181,6 +180,7 @@ func (m *modelDialog) switchProvider(offset int) {
m.hScrollOffset = newOffset m.hScrollOffset = newOffset
m.provider = m.availableProviders[m.hScrollOffset] m.provider = m.availableProviders[m.hScrollOffset]
m.modal.SetTitle(fmt.Sprintf("Select %s Model", m.provider.Name))
m.setupModelsForProvider(m.provider.Id) m.setupModelsForProvider(m.provider.Id)
} }
@@ -190,14 +190,6 @@ func (m *modelDialog) View() string {
Background(t.BackgroundElement()). Background(t.BackgroundElement()).
Foreground(t.Text()) Foreground(t.Text())
// Capitalize first letter of provider name
title := baseStyle.
Foreground(t.Primary()).
Bold(true).
Width(maxDialogWidth).
Padding(0, 0, 1).
Render(fmt.Sprintf("Select %s Model", m.provider.Name))
// Render visible models // Render visible models
endIdx := min(m.scrollOffset+numVisibleModels, len(m.provider.Models)) endIdx := min(m.scrollOffset+numVisibleModels, len(m.provider.Models))
modelItems := make([]string, 0, endIdx-m.scrollOffset) modelItems := make([]string, 0, endIdx-m.scrollOffset)
@@ -218,8 +210,9 @@ func (m *modelDialog) View() string {
content := lipgloss.JoinVertical( content := lipgloss.JoinVertical(
lipgloss.Left, lipgloss.Left,
title, baseStyle.
baseStyle.Width(maxDialogWidth).Render(lipgloss.JoinVertical(lipgloss.Left, modelItems...)), Width(maxDialogWidth).
Render(lipgloss.JoinVertical(lipgloss.Left, modelItems...)),
scrollIndicator, scrollIndicator,
) )
@@ -239,12 +232,7 @@ func (m *modelDialog) getScrollIndicators(maxWidth int) string {
} }
if m.hScrollPossible { if m.hScrollPossible {
if m.hScrollOffset > 0 { indicator = "← " + indicator + "→"
indicator = "← " + indicator
}
if m.hScrollOffset < len(m.availableProviders)-1 {
indicator += "→"
}
} }
if indicator == "" { if indicator == "" {
@@ -314,6 +302,6 @@ func NewModelDialog(app *app.App) ModelDialog {
hScrollOffset: 0, hScrollOffset: 0,
hScrollPossible: len(availableProviders) > 1, hScrollPossible: len(availableProviders) > 1,
provider: availableProviders[0], provider: availableProviders[0],
modal: modal.New(), modal: modal.New(modal.WithTitle(fmt.Sprintf("Select %s Model", availableProviders[0].Name))),
} }
} }
@@ -6,7 +6,6 @@ import (
"github.com/charmbracelet/bubbles/v2/viewport" "github.com/charmbracelet/bubbles/v2/viewport"
tea "github.com/charmbracelet/bubbletea/v2" tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util" "github.com/sst/opencode/internal/util"
@@ -30,7 +29,8 @@ type PermissionResponseMsg struct {
// PermissionDialogComponent interface for permission dialog component // PermissionDialogComponent interface for permission dialog component
type PermissionDialogComponent interface { type PermissionDialogComponent interface {
layout.ModelWithView tea.Model
tea.ViewModel
// SetPermissions(permission permission.PermissionRequest) tea.Cmd // SetPermissions(permission permission.PermissionRequest) tea.Cmd
} }
@@ -8,7 +8,6 @@ import (
"github.com/sst/opencode/internal/components/list" "github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/components/modal" "github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/layout" "github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/state"
"github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util" "github.com/sst/opencode/internal/util"
@@ -20,14 +19,12 @@ type SessionDialog interface {
layout.Modal layout.Modal
} }
type sessionItem struct { type sessionItem client.SessionInfo
session client.SessionInfo
}
func (s sessionItem) Render(selected bool, width int) string { func (s sessionItem) Render(selected bool, width int) string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
baseStyle := styles.BaseStyle(). baseStyle := styles.BaseStyle().
Width(width - 2). Width(width - 4).
Background(t.BackgroundElement()) Background(t.BackgroundElement())
if selected { if selected {
@@ -40,7 +37,7 @@ func (s sessionItem) Render(selected bool, width int) string {
Foreground(t.Text()) Foreground(t.Text())
} }
return baseStyle.Padding(0, 1).Render(s.session.Title) return baseStyle.Padding(0, 1).Render(s.Title)
} }
type sessionDialog struct { type sessionDialog struct {
@@ -61,15 +58,14 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
s.width = msg.Width s.width = msg.Width
s.height = msg.Height s.height = msg.Height
s.list.SetMaxWidth(layout.Current.Container.Width - 12) s.list.SetMaxWidth(layout.Current.Container.Width - 12)
case tea.KeyMsg: case tea.KeyPressMsg:
switch msg.String() { switch msg.String() {
case "enter": case "enter":
if item, idx := s.list.GetSelectedItem(); idx >= 0 { if item, idx := s.list.GetSelectedItem(); idx >= 0 {
selectedSession := item.session s.selectedSessionID = item.Id
s.selectedSessionID = selectedSession.Id
return s, tea.Sequence( return s, tea.Sequence(
util.CmdHandler(modal.CloseModalMsg{}), util.CmdHandler(modal.CloseModalMsg{}),
util.CmdHandler(state.SessionSelectedMsg(&selectedSession)), util.CmdHandler(app.SessionSelectedMsg(&item)),
) )
} }
} }
@@ -95,7 +91,10 @@ func NewSessionDialog(app *app.App) SessionDialog {
var sessionItems []sessionItem var sessionItems []sessionItem
for _, sess := range sessions { for _, sess := range sessions {
sessionItems = append(sessionItems, sessionItem{session: sess}) if sess.ParentID != nil {
continue
}
sessionItems = append(sessionItems, sessionItem(sess))
} }
list := list.NewListComponent( list := list.NewListComponent(
@@ -10,8 +10,8 @@ import (
"github.com/sst/opencode/internal/util" "github.com/sst/opencode/internal/util"
) )
// ThemeChangedMsg is sent when the theme is changed // ThemeSelectedMsg is sent when the theme is changed
type ThemeChangedMsg struct { type ThemeSelectedMsg struct {
ThemeName string ThemeName string
} }
@@ -75,7 +75,7 @@ func (t *themeDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
return t, tea.Sequence( return t, tea.Sequence(
util.CmdHandler(modal.CloseModalMsg{}), util.CmdHandler(modal.CloseModalMsg{}),
util.CmdHandler(ThemeChangedMsg{ThemeName: selectedTheme}), util.CmdHandler(ThemeSelectedMsg{ThemeName: selectedTheme}),
) )
} }
} }
@@ -441,10 +441,10 @@ func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg color.C
<entry type="TextWhitespace" style="%s"/> <entry type="TextWhitespace" style="%s"/>
</style> </style>
`, `,
getColor(t.BackgroundSubtle()), // Background getColor(t.BackgroundPanel()), // Background
getColor(t.Text()), // Text getColor(t.Text()), // Text
getColor(t.Text()), // Other getColor(t.Text()), // Other
getColor(t.Error()), // Error getColor(t.Error()), // Error
getColor(t.SyntaxKeyword()), // Keyword getColor(t.SyntaxKeyword()), // Keyword
getColor(t.SyntaxKeyword()), // KeywordConstant getColor(t.SyntaxKeyword()), // KeywordConstant
@@ -614,7 +614,7 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
// Get the appropriate color based on terminal background // Get the appropriate color based on terminal background
bgColor := lipgloss.Color(getColor(highlightBg)) bgColor := lipgloss.Color(getColor(highlightBg))
fgColor := lipgloss.Color(getColor(theme.CurrentTheme().BackgroundSubtle())) fgColor := lipgloss.Color(getColor(theme.CurrentTheme().BackgroundPanel()))
for i := 0; i < len(content); { for i := 0; i < len(content); {
// Check if we're at an ANSI sequence // Check if we're at an ANSI sequence
@@ -1,10 +1,10 @@
package list package list
import ( import (
"strings"
"github.com/charmbracelet/bubbles/v2/key" "github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2" tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/layout"
) )
type ListItem interface { type ListItem interface {
@@ -12,7 +12,8 @@ type ListItem interface {
} }
type List[T ListItem] interface { type List[T ListItem] interface {
layout.ModelWithView tea.Model
tea.ViewModel
SetMaxWidth(maxWidth int) SetMaxWidth(maxWidth int)
GetSelectedItem() (item T, idx int) GetSelectedItem() (item T, idx int)
SetItems(items []T) SetItems(items []T)
@@ -148,7 +149,7 @@ func (c *listComponent[T]) View() string {
listItems = append(listItems, title) listItems = append(listItems, title)
} }
return lipgloss.JoinVertical(lipgloss.Left, listItems...) return strings.Join(listItems, "\n")
} }
func NewListComponent[T ListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) List[T] { func NewListComponent[T ListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) List[T] {
+21 -15
View File
@@ -1,6 +1,8 @@
package modal package modal
import ( import (
"strings"
"github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/layout" "github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/styles"
@@ -66,6 +68,10 @@ func New(opts ...ModalOption) *Modal {
return m return m
} }
func (m *Modal) SetTitle(title string) {
m.title = title
}
// Render renders the modal centered on the screen // Render renders the modal centered on the screen
func (m *Modal) Render(contentView string, background string) string { func (m *Modal) Render(contentView string, background string) string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
@@ -95,15 +101,20 @@ func (m *Modal) Render(contentView string, background string) string {
titleStyle := baseStyle. titleStyle := baseStyle.
Foreground(t.Primary()). Foreground(t.Primary()).
Bold(true). Bold(true).
Width(innerWidth).
Padding(0, 1) Padding(0, 1)
titleView := titleStyle.Render(m.title) escStyle := baseStyle.Foreground(t.TextMuted()).Bold(false)
finalContent = lipgloss.JoinVertical( escText := escStyle.Render("esc")
lipgloss.Left,
titleView, // Calculate position for esc text
contentView, titleWidth := lipgloss.Width(m.title)
) escWidth := lipgloss.Width(escText)
spacesNeeded := max(0, innerWidth-titleWidth-escWidth-3)
spacer := strings.Repeat(" ", spacesNeeded)
titleLine := m.title + spacer + escText
titleLine = titleStyle.Render(titleLine)
finalContent = strings.Join([]string{titleLine, contentView}, "\n") + "\n"
} else { } else {
finalContent = contentView finalContent = contentView
} }
@@ -112,14 +123,7 @@ func (m *Modal) Render(contentView string, background string) string {
PaddingTop(1). PaddingTop(1).
PaddingBottom(1). PaddingBottom(1).
PaddingLeft(2). PaddingLeft(2).
PaddingRight(2). PaddingRight(2)
BorderStyle(lipgloss.ThickBorder()).
BorderLeft(true).
BorderRight(true).
BorderLeftForeground(t.BackgroundSubtle()).
BorderLeftBackground(t.Background()).
BorderRightForeground(t.BackgroundSubtle()).
BorderRightBackground(t.Background())
modalView := modalStyle. modalView := modalStyle.
Width(outerWidth). Width(outerWidth).
@@ -139,5 +143,7 @@ func (m *Modal) Render(contentView string, background string) string {
row, row,
modalView, modalView,
background, background,
layout.WithOverlayBorder(),
layout.WithOverlayBorderColor(t.Primary()),
) )
} }
@@ -1,4 +1,4 @@
package core package status
import ( import (
"fmt" "fmt"
@@ -7,13 +7,13 @@ import (
tea "github.com/charmbracelet/bubbletea/v2" tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/theme"
) )
type StatusComponent interface { type StatusComponent interface {
layout.ModelWithView tea.Model
tea.ViewModel
} }
type statusComponent struct { type statusComponent struct {
@@ -88,7 +88,7 @@ func (m statusComponent) View() string {
cwd := styles.Padded(). cwd := styles.Padded().
Foreground(t.TextMuted()). Foreground(t.TextMuted()).
Background(t.BackgroundSubtle()). Background(t.BackgroundPanel()).
Render(m.app.Info.Path.Cwd) Render(m.app.Info.Path.Cwd)
sessionInfo := "" sessionInfo := ""
@@ -123,7 +123,7 @@ func (m statusComponent) View() string {
0, 0,
m.width-lipgloss.Width(logo)-lipgloss.Width(cwd)-lipgloss.Width(sessionInfo), m.width-lipgloss.Width(logo)-lipgloss.Width(cwd)-lipgloss.Width(sessionInfo),
) )
spacer := lipgloss.NewStyle().Background(t.BackgroundSubtle()).Width(space).Render("") spacer := lipgloss.NewStyle().Background(t.BackgroundPanel()).Width(space).Render("")
status := logo + cwd + spacer + sessionInfo status := logo + cwd + spacer + sessionInfo
@@ -0,0 +1,125 @@
// Package memoization implement a simple memoization cache. It's designed to
// improve performance in textarea.
package textarea
import (
"container/list"
"crypto/sha256"
"fmt"
"sync"
)
// Hasher is an interface that requires a Hash method. The Hash method is
// expected to return a string representation of the hash of the object.
type Hasher interface {
Hash() string
}
// entry is a struct that holds a key-value pair. It is used as an element
// in the evictionList of the MemoCache.
type entry[T any] struct {
key string
value T
}
// MemoCache is a struct that represents a cache with a set capacity. It
// uses an LRU (Least Recently Used) eviction policy. It is safe for
// concurrent use.
type MemoCache[H Hasher, T any] struct {
capacity int
mutex sync.Mutex
cache map[string]*list.Element // The cache holding the results
evictionList *list.List // A list to keep track of the order for LRU
hashableItems map[string]T // This map keeps track of the original hashable items (optional)
}
// NewMemoCache is a function that creates a new MemoCache with a given
// capacity. It returns a pointer to the created MemoCache.
func NewMemoCache[H Hasher, T any](capacity int) *MemoCache[H, T] {
return &MemoCache[H, T]{
capacity: capacity,
cache: make(map[string]*list.Element),
evictionList: list.New(),
hashableItems: make(map[string]T),
}
}
// Capacity is a method that returns the capacity of the MemoCache.
func (m *MemoCache[H, T]) Capacity() int {
return m.capacity
}
// Size is a method that returns the current size of the MemoCache. It is
// the number of items currently stored in the cache.
func (m *MemoCache[H, T]) Size() int {
m.mutex.Lock()
defer m.mutex.Unlock()
return m.evictionList.Len()
}
// Get is a method that returns the value associated with the given
// hashable item in the MemoCache. If there is no corresponding value, the
// method returns nil.
func (m *MemoCache[H, T]) Get(h H) (T, bool) {
m.mutex.Lock()
defer m.mutex.Unlock()
hashedKey := h.Hash()
if element, found := m.cache[hashedKey]; found {
m.evictionList.MoveToFront(element)
return element.Value.(*entry[T]).value, true
}
var result T
return result, false
}
// Set is a method that sets the value for the given hashable item in the
// MemoCache. If the cache is at capacity, it evicts the least recently
// used item before adding the new item.
func (m *MemoCache[H, T]) Set(h H, value T) {
m.mutex.Lock()
defer m.mutex.Unlock()
hashedKey := h.Hash()
if element, found := m.cache[hashedKey]; found {
m.evictionList.MoveToFront(element)
element.Value.(*entry[T]).value = value
return
}
// Check if the cache is at capacity
if m.evictionList.Len() >= m.capacity {
// Evict the least recently used item from the cache
toEvict := m.evictionList.Back()
if toEvict != nil {
evictedEntry := m.evictionList.Remove(toEvict).(*entry[T])
delete(m.cache, evictedEntry.key)
delete(m.hashableItems, evictedEntry.key) // if you're keeping track of original items
}
}
// Add the value to the cache and the evictionList
newEntry := &entry[T]{
key: hashedKey,
value: value,
}
element := m.evictionList.PushFront(newEntry)
m.cache[hashedKey] = element
m.hashableItems[hashedKey] = value // if you're keeping track of original items
}
// HString is a type that implements the Hasher interface for strings.
type HString string
// Hash is a method that returns the hash of the string.
func (h HString) Hash() string {
return fmt.Sprintf("%x", sha256.Sum256([]byte(h)))
}
// HInt is a type that implements the Hasher interface for integers.
type HInt int
// Hash is a method that returns the hash of the integer.
func (h HInt) Hash() string {
return fmt.Sprintf("%x", sha256.Sum256([]byte(fmt.Sprintf("%d", h))))
}
@@ -0,0 +1,102 @@
// Package runeutil provides utility functions for tidying up incoming runes
// from Key messages.
package textarea
import (
"unicode"
"unicode/utf8"
)
// Sanitizer is a helper for bubble widgets that want to process
// Runes from input key messages.
type Sanitizer interface {
// Sanitize removes control characters from runes in a KeyRunes
// message, and optionally replaces newline/carriage return/tabs by a
// specified character.
//
// The rune array is modified in-place if possible. In that case, the
// returned slice is the original slice shortened after the control
// characters have been removed/translated.
Sanitize(runes []rune) []rune
}
// NewSanitizer constructs a rune sanitizer.
func NewSanitizer(opts ...Option) Sanitizer {
s := sanitizer{
replaceNewLine: []rune("\n"),
replaceTab: []rune(" "),
}
for _, o := range opts {
s = o(s)
}
return &s
}
// Option is the type of option that can be passed to Sanitize().
type Option func(sanitizer) sanitizer
// ReplaceTabs replaces tabs by the specified string.
func ReplaceTabs(tabRepl string) Option {
return func(s sanitizer) sanitizer {
s.replaceTab = []rune(tabRepl)
return s
}
}
// ReplaceNewlines replaces newline characters by the specified string.
func ReplaceNewlines(nlRepl string) Option {
return func(s sanitizer) sanitizer {
s.replaceNewLine = []rune(nlRepl)
return s
}
}
func (s *sanitizer) Sanitize(runes []rune) []rune {
// dstrunes are where we are storing the result.
dstrunes := runes[:0:len(runes)]
// copied indicates whether dstrunes is an alias of runes
// or a copy. We need a copy when dst moves past src.
// We use this as an optimization to avoid allocating
// a new rune slice in the common case where the output
// is smaller or equal to the input.
copied := false
for src := 0; src < len(runes); src++ {
r := runes[src]
switch {
case r == utf8.RuneError:
// skip
case r == '\r' || r == '\n':
if len(dstrunes)+len(s.replaceNewLine) > src && !copied {
dst := len(dstrunes)
dstrunes = make([]rune, dst, len(runes)+len(s.replaceNewLine))
copy(dstrunes, runes[:dst])
copied = true
}
dstrunes = append(dstrunes, s.replaceNewLine...)
case r == '\t':
if len(dstrunes)+len(s.replaceTab) > src && !copied {
dst := len(dstrunes)
dstrunes = make([]rune, dst, len(runes)+len(s.replaceTab))
copy(dstrunes, runes[:dst])
copied = true
}
dstrunes = append(dstrunes, s.replaceTab...)
case unicode.IsControl(r):
// Other control characters: skip.
default:
// Keep the character.
dstrunes = append(dstrunes, runes[src])
}
}
return dstrunes
}
type sanitizer struct {
replaceNewLine []rune
replaceTab []rune
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,267 @@
package toast
import (
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/lipgloss/v2/compat"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
// ShowToastMsg is a message to display a toast notification
type ShowToastMsg struct {
Message string
Title *string
Color compat.AdaptiveColor
Duration time.Duration
}
// DismissToastMsg is a message to dismiss a specific toast
type DismissToastMsg struct {
ID string
}
// Toast represents a single toast notification
type Toast struct {
ID string
Message string
Title *string
Color compat.AdaptiveColor
CreatedAt time.Time
Duration time.Duration
}
// ToastManager manages multiple toast notifications
type ToastManager struct {
toasts []Toast
}
// NewToastManager creates a new toast manager
func NewToastManager() *ToastManager {
return &ToastManager{
toasts: []Toast{},
}
}
// Init initializes the toast manager
func (tm *ToastManager) Init() tea.Cmd {
return nil
}
// Update handles messages for the toast manager
func (tm *ToastManager) Update(msg tea.Msg) (*ToastManager, tea.Cmd) {
switch msg := msg.(type) {
case ShowToastMsg:
toast := Toast{
ID: fmt.Sprintf("toast-%d", time.Now().UnixNano()),
Title: msg.Title,
Message: msg.Message,
Color: msg.Color,
CreatedAt: time.Now(),
Duration: msg.Duration,
}
tm.toasts = append(tm.toasts, toast)
// Return command to dismiss after duration
return tm, tea.Tick(toast.Duration, func(t time.Time) tea.Msg {
return DismissToastMsg{ID: toast.ID}
})
case DismissToastMsg:
var newToasts []Toast
for _, t := range tm.toasts {
if t.ID != msg.ID {
newToasts = append(newToasts, t)
}
}
tm.toasts = newToasts
}
return tm, nil
}
// renderSingleToast renders a single toast notification
func (tm *ToastManager) renderSingleToast(toast Toast) string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle().
Background(t.BackgroundElement()).
Foreground(t.Text()).
Padding(1, 2)
maxWidth := max(40, layout.Current.Viewport.Width/3)
contentMaxWidth := max(maxWidth-6, 20)
// Build content with wrapping
var content strings.Builder
if toast.Title != nil {
titleStyle := lipgloss.NewStyle().
Foreground(toast.Color).
Bold(true)
content.WriteString(titleStyle.Render(*toast.Title))
content.WriteString("\n")
}
// Wrap message text
messageStyle := lipgloss.NewStyle()
contentWidth := lipgloss.Width(toast.Message)
if contentWidth > contentMaxWidth {
messageStyle = messageStyle.Width(contentMaxWidth)
}
content.WriteString(messageStyle.Render(toast.Message))
// Render toast with max width
return baseStyle.MaxWidth(maxWidth).Render(content.String())
}
// View renders all active toasts
func (tm *ToastManager) View() string {
if len(tm.toasts) == 0 {
return ""
}
var toastViews []string
for _, toast := range tm.toasts {
toastView := tm.renderSingleToast(toast)
toastViews = append(toastViews, toastView+"\n")
}
return strings.Join(toastViews, "\n")
}
// RenderOverlay renders the toasts as an overlay on the given background
func (tm *ToastManager) RenderOverlay(background string) string {
if len(tm.toasts) == 0 {
return background
}
bgWidth := lipgloss.Width(background)
bgHeight := lipgloss.Height(background)
result := background
// Start from top with 2 character padding
currentY := 2
// Render each toast individually
for _, toast := range tm.toasts {
// Render individual toast
toastView := tm.renderSingleToast(toast)
toastWidth := lipgloss.Width(toastView)
toastHeight := lipgloss.Height(toastView)
// Position at top-right with 2 character padding from right edge
x := max(bgWidth-toastWidth-4, 0)
// Check if toast fits vertically
if currentY+toastHeight > bgHeight-2 {
// No more room for toasts
break
}
// Place this toast
result = layout.PlaceOverlay(
x,
currentY,
toastView,
result,
layout.WithOverlayBorder(),
layout.WithOverlayBorderColor(toast.Color),
)
// Move down for next toast (add 1 for spacing between toasts)
currentY += toastHeight + 1
}
return result
}
type ToastOptions struct {
Title string
Duration time.Duration
}
type toastOptions struct {
title *string
duration *time.Duration
color *compat.AdaptiveColor
}
type ToastOption func(*toastOptions)
func WithTitle(title string) ToastOption {
return func(t *toastOptions) {
t.title = &title
}
}
func WithDuration(duration time.Duration) ToastOption {
return func(t *toastOptions) {
t.duration = &duration
}
}
func WithColor(color compat.AdaptiveColor) ToastOption {
return func(t *toastOptions) {
t.color = &color
}
}
func NewToast(message string, options ...ToastOption) tea.Cmd {
t := theme.CurrentTheme()
duration := 5 * time.Second
color := t.Primary()
opts := toastOptions{
duration: &duration,
color: &color,
}
for _, option := range options {
option(&opts)
}
return func() tea.Msg {
return ShowToastMsg{
Message: message,
Title: opts.title,
Duration: *opts.duration,
Color: *opts.color,
}
}
}
func NewInfoToast(message string, options ...ToastOption) tea.Cmd {
options = append(options, WithColor(theme.CurrentTheme().Info()))
return NewToast(
message,
options...,
)
}
func NewSuccessToast(message string, options ...ToastOption) tea.Cmd {
options = append(options, WithColor(theme.CurrentTheme().Success()))
return NewToast(
message,
options...,
)
}
func NewWarningToast(message string, options ...ToastOption) tea.Cmd {
options = append(options, WithColor(theme.CurrentTheme().Warning()))
return NewToast(
message,
options...,
)
}
func NewErrorToast(message string, options ...ToastOption) tea.Cmd {
options = append(options, WithColor(theme.CurrentTheme().Error()))
return NewToast(
message,
options...,
)
}
+24 -18
View File
@@ -7,25 +7,31 @@ import (
"os" "os"
"github.com/BurntSushi/toml" "github.com/BurntSushi/toml"
"github.com/sst/opencode/pkg/client"
) )
type Config struct { type State struct {
Theme string `toml:"theme"` Theme string `toml:"theme"`
Provider string `toml:"provider"` Provider string `toml:"provider"`
Model string `toml:"model"` Model string `toml:"model"`
} }
// NewConfig creates a new Config instance with default values. func NewState() *State {
// This can be useful for initializing a new configuration file. return &State{
func NewConfig() *Config {
return &Config{
Theme: "opencode", Theme: "opencode",
} }
} }
// SaveConfig writes the provided Config struct to the specified TOML file. func MergeState(state *State, config *client.ConfigInfo) *client.ConfigInfo {
if config.Theme == nil {
config.Theme = &state.Theme
}
return config
}
// SaveState writes the provided Config struct to the specified TOML file.
// It will create the file if it doesn't exist, or overwrite it if it does. // It will create the file if it doesn't exist, or overwrite it if it does.
func SaveConfig(filePath string, config *Config) error { func SaveState(filePath string, state *State) error {
file, err := os.Create(filePath) file, err := os.Create(filePath)
if err != nil { if err != nil {
return fmt.Errorf("failed to create/open config file %s: %w", filePath, err) return fmt.Errorf("failed to create/open config file %s: %w", filePath, err)
@@ -34,26 +40,26 @@ func SaveConfig(filePath string, config *Config) error {
writer := bufio.NewWriter(file) writer := bufio.NewWriter(file)
encoder := toml.NewEncoder(writer) encoder := toml.NewEncoder(writer)
if err := encoder.Encode(config); err != nil { if err := encoder.Encode(state); err != nil {
return fmt.Errorf("failed to encode config to TOML file %s: %w", filePath, err) return fmt.Errorf("failed to encode state to TOML file %s: %w", filePath, err)
} }
if err := writer.Flush(); err != nil { if err := writer.Flush(); err != nil {
return fmt.Errorf("failed to flush writer for config file %s: %w", filePath, err) return fmt.Errorf("failed to flush writer for state file %s: %w", filePath, err)
} }
slog.Debug("Configuration saved to file", "file", filePath) slog.Debug("State saved to file", "file", filePath)
return nil return nil
} }
// LoadConfig reads a Config struct from the specified TOML file. // LoadState loads the state from the specified TOML file.
// It returns a pointer to the Config struct and an error if any issues occur. // It returns a pointer to the State struct and an error if any issues occur.
func LoadConfig(filePath string) (*Config, error) { func LoadState(filePath string) (*State, error) {
var config Config var state State
if _, err := toml.DecodeFile(filePath, &config); err != nil { if _, err := toml.DecodeFile(filePath, &state); err != nil {
if _, statErr := os.Stat(filePath); os.IsNotExist(statErr) { if _, statErr := os.Stat(filePath); os.IsNotExist(statErr) {
return nil, fmt.Errorf("config file not found at %s: %w", filePath, statErr) return nil, fmt.Errorf("state file not found at %s: %w", filePath, statErr)
} }
return nil, fmt.Errorf("failed to decode TOML from file %s: %w", filePath, err) return nil, fmt.Errorf("failed to decode TOML from file %s: %w", filePath, err)
} }
return &config, nil return &state, nil
} }
+33 -28
View File
@@ -6,20 +6,12 @@ import (
"github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/theme"
) )
type ModelWithView interface { type Container interface {
tea.Model tea.Model
tea.ViewModel tea.ViewModel
}
type Container interface {
ModelWithView
Sizeable Sizeable
Focus() Focusable
Blur() Alignable
MaxWidth() int
Alignment() lipgloss.Position
GetPosition() (x, y int)
GetContent() ModelWithView
} }
type container struct { type container struct {
@@ -28,7 +20,7 @@ type container struct {
x int x int
y int y int
content ModelWithView content tea.ViewModel
paddingTop int paddingTop int
paddingRight int paddingRight int
@@ -48,13 +40,19 @@ type container struct {
} }
func (c *container) Init() tea.Cmd { func (c *container) Init() tea.Cmd {
return c.content.Init() if model, ok := c.content.(tea.Model); ok {
return model.Init()
}
return nil
} }
func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
u, cmd := c.content.Update(msg) if model, ok := c.content.(tea.Model); ok {
c.content = u.(ModelWithView) u, cmd := model.Update(msg)
return c, cmd c.content = u.(tea.ViewModel)
return c, cmd
}
return c, nil
} }
func (c *container) View() string { func (c *container) View() string {
@@ -156,21 +154,28 @@ func (c *container) Alignment() lipgloss.Position {
} }
// Focus sets the container as focused // Focus sets the container as focused
func (c *container) Focus() { func (c *container) Focus() tea.Cmd {
c.focused = true c.focused = true
// Pass focus to content if it supports it if focusable, ok := c.content.(Focusable); ok {
if focusable, ok := c.content.(interface{ Focus() }); ok { return focusable.Focus()
focusable.Focus()
} }
return nil
} }
// Blur removes focus from the container // Blur removes focus from the container
func (c *container) Blur() { func (c *container) Blur() tea.Cmd {
c.focused = false c.focused = false
// Remove focus from content if it supports it if blurable, ok := c.content.(Focusable); ok {
if blurable, ok := c.content.(interface{ Blur() }); ok { return blurable.Blur()
blurable.Blur()
} }
return nil
}
func (c *container) IsFocused() bool {
if blurable, ok := c.content.(Focusable); ok {
return blurable.IsFocused()
}
return c.focused
} }
// GetPosition returns the x, y coordinates of the container // GetPosition returns the x, y coordinates of the container
@@ -178,14 +183,14 @@ func (c *container) GetPosition() (x, y int) {
return c.x, c.y return c.x, c.y
} }
// GetContent returns the content of the container func (c *container) SetPosition(x, y int) {
func (c *container) GetContent() ModelWithView { c.x = x
return c.content c.y = y
} }
type ContainerOption func(*container) type ContainerOption func(*container)
func NewContainer(content ModelWithView, options ...ContainerOption) Container { func NewContainer(content tea.ViewModel, options ...ContainerOption) Container {
c := &container{ c := &container{
content: content, content: content,
borderStyle: lipgloss.NormalBorder(), borderStyle: lipgloss.NormalBorder(),
+60 -78
View File
@@ -13,22 +13,22 @@ const (
FlexDirectionVertical FlexDirectionVertical
) )
type FlexPaneSize struct { type FlexChildSize struct {
Fixed bool Fixed bool
Size int Size int
} }
var FlexPaneSizeGrow = FlexPaneSize{Fixed: false} var FlexChildSizeGrow = FlexChildSize{Fixed: false}
func FlexPaneSizeFixed(size int) FlexPaneSize { func FlexChildSizeFixed(size int) FlexChildSize {
return FlexPaneSize{Fixed: true, Size: size} return FlexChildSize{Fixed: true, Size: size}
} }
type FlexLayout interface { type FlexLayout interface {
ModelWithView tea.ViewModel
Sizeable Sizeable
SetPanes(panes []Container) tea.Cmd SetChildren(panes []tea.ViewModel) tea.Cmd
SetPaneSizes(sizes []FlexPaneSize) tea.Cmd SetSizes(sizes []FlexChildSize) tea.Cmd
SetDirection(direction FlexDirection) tea.Cmd SetDirection(direction FlexDirection) tea.Cmd
} }
@@ -36,94 +36,69 @@ type flexLayout struct {
width int width int
height int height int
direction FlexDirection direction FlexDirection
panes []Container children []tea.ViewModel
sizes []FlexPaneSize sizes []FlexChildSize
} }
type FlexLayoutOption func(*flexLayout) type FlexLayoutOption func(*flexLayout)
func (f *flexLayout) Init() tea.Cmd {
var cmds []tea.Cmd
for _, pane := range f.panes {
if pane != nil {
cmds = append(cmds, pane.Init())
}
}
return tea.Batch(cmds...)
}
func (f *flexLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
return f, f.SetSize(msg.Width, msg.Height)
}
for i, pane := range f.panes {
if pane != nil {
u, cmd := pane.Update(msg)
f.panes[i] = u.(Container)
if cmd != nil {
cmds = append(cmds, cmd)
}
}
}
return f, tea.Batch(cmds...)
}
func (f *flexLayout) View() string { func (f *flexLayout) View() string {
if len(f.panes) == 0 { if len(f.children) == 0 {
return "" return ""
} }
t := theme.CurrentTheme() t := theme.CurrentTheme()
views := make([]string, 0, len(f.panes)) views := make([]string, 0, len(f.children))
for i, pane := range f.panes { for i, child := range f.children {
if pane == nil { if child == nil {
continue continue
} }
var paneWidth, paneHeight int alignment := lipgloss.Center
if alignable, ok := child.(Alignable); ok {
alignment = alignable.Alignment()
}
var childWidth, childHeight int
if f.direction == FlexDirectionHorizontal { if f.direction == FlexDirectionHorizontal {
paneWidth, paneHeight = f.calculatePaneSize(i) childWidth, childHeight = f.calculateChildSize(i)
view := lipgloss.PlaceHorizontal( view := lipgloss.PlaceHorizontal(
paneWidth, childWidth,
pane.Alignment(), alignment,
pane.View(), child.View(),
// TODO: make configurable WithBackgroundStyle
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())), lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
) )
views = append(views, view) views = append(views, view)
} else { } else {
paneWidth, paneHeight = f.calculatePaneSize(i) childWidth, childHeight = f.calculateChildSize(i)
view := lipgloss.Place( view := lipgloss.Place(
f.width, f.width,
paneHeight, childHeight,
lipgloss.Center, lipgloss.Center,
pane.Alignment(), alignment,
pane.View(), child.View(),
// TODO: make configurable WithBackgroundStyle
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())), lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
) )
views = append(views, view) views = append(views, view)
} }
} }
if f.direction == FlexDirectionHorizontal { if f.direction == FlexDirectionHorizontal {
return lipgloss.JoinHorizontal(lipgloss.Center, views...) return lipgloss.JoinHorizontal(lipgloss.Center, views...)
} }
return lipgloss.JoinVertical(lipgloss.Center, views...) return lipgloss.JoinVertical(lipgloss.Center, views...)
} }
func (f *flexLayout) calculatePaneSize(index int) (width, height int) { func (f *flexLayout) calculateChildSize(index int) (width, height int) {
if index >= len(f.panes) { if index >= len(f.children) {
return 0, 0 return 0, 0
} }
totalFixed := 0 totalFixed := 0
flexCount := 0 flexCount := 0
for i, pane := range f.panes { for i, child := range f.children {
if pane == nil { if child == nil {
continue continue
} }
if i < len(f.sizes) && f.sizes[i].Fixed { if i < len(f.sizes) && f.sizes[i].Fixed {
@@ -165,9 +140,13 @@ func (f *flexLayout) SetSize(width, height int) tea.Cmd {
var cmds []tea.Cmd var cmds []tea.Cmd
currentX, currentY := 0, 0 currentX, currentY := 0, 0
for i, pane := range f.panes { for i, child := range f.children {
if pane != nil { if child != nil {
paneWidth, paneHeight := f.calculatePaneSize(i) paneWidth, paneHeight := f.calculateChildSize(i)
alignment := lipgloss.Center
if alignable, ok := child.(Alignable); ok {
alignment = alignable.Alignment()
}
// Calculate actual position based on alignment // Calculate actual position based on alignment
actualX, actualY := currentX, currentY actualX, actualY := currentX, currentY
@@ -179,11 +158,13 @@ func (f *flexLayout) SetSize(width, height int) tea.Cmd {
} else { } else {
// In vertical layout, horizontal alignment affects X position // In vertical layout, horizontal alignment affects X position
contentWidth := paneWidth contentWidth := paneWidth
if pane.MaxWidth() > 0 && contentWidth > pane.MaxWidth() { if alignable, ok := child.(Alignable); ok {
contentWidth = pane.MaxWidth() if alignable.MaxWidth() > 0 && contentWidth > alignable.MaxWidth() {
contentWidth = alignable.MaxWidth()
}
} }
switch pane.Alignment() { switch alignment {
case lipgloss.Center: case lipgloss.Center:
actualX = (f.width - contentWidth) / 2 actualX = (f.width - contentWidth) / 2
case lipgloss.Right: case lipgloss.Right:
@@ -193,14 +174,15 @@ func (f *flexLayout) SetSize(width, height int) tea.Cmd {
} }
} }
// Set position if the pane is a *container // Set position if the pane is Alignable
if c, ok := pane.(*container); ok { if c, ok := child.(Alignable); ok {
c.x = actualX c.SetPosition(actualX, actualY)
c.y = actualY
} }
cmd := pane.SetSize(paneWidth, paneHeight) if sizeable, ok := child.(Sizeable); ok {
cmds = append(cmds, cmd) cmd := sizeable.SetSize(paneWidth, paneHeight)
cmds = append(cmds, cmd)
}
// Update position for next pane // Update position for next pane
if f.direction == FlexDirectionHorizontal { if f.direction == FlexDirectionHorizontal {
@@ -217,15 +199,15 @@ func (f *flexLayout) GetSize() (int, int) {
return f.width, f.height return f.width, f.height
} }
func (f *flexLayout) SetPanes(panes []Container) tea.Cmd { func (f *flexLayout) SetChildren(children []tea.ViewModel) tea.Cmd {
f.panes = panes f.children = children
if f.width > 0 && f.height > 0 { if f.width > 0 && f.height > 0 {
return f.SetSize(f.width, f.height) return f.SetSize(f.width, f.height)
} }
return nil return nil
} }
func (f *flexLayout) SetPaneSizes(sizes []FlexPaneSize) tea.Cmd { func (f *flexLayout) SetSizes(sizes []FlexChildSize) tea.Cmd {
f.sizes = sizes f.sizes = sizes
if f.width > 0 && f.height > 0 { if f.width > 0 && f.height > 0 {
return f.SetSize(f.width, f.height) return f.SetSize(f.width, f.height)
@@ -241,11 +223,11 @@ func (f *flexLayout) SetDirection(direction FlexDirection) tea.Cmd {
return nil return nil
} }
func NewFlexLayout(options ...FlexLayoutOption) FlexLayout { func NewFlexLayout(children []tea.ViewModel, options ...FlexLayoutOption) FlexLayout {
layout := &flexLayout{ layout := &flexLayout{
children: children,
direction: FlexDirectionHorizontal, direction: FlexDirectionHorizontal,
panes: []Container{}, sizes: []FlexChildSize{},
sizes: []FlexPaneSize{},
} }
for _, option := range options { for _, option := range options {
option(layout) option(layout)
@@ -259,13 +241,13 @@ func WithDirection(direction FlexDirection) FlexLayoutOption {
} }
} }
func WithPanes(panes ...Container) FlexLayoutOption { func WithChildren(children ...tea.ViewModel) FlexLayoutOption {
return func(f *flexLayout) { return func(f *flexLayout) {
f.panes = panes f.children = children
} }
} }
func WithPaneSizes(sizes ...FlexPaneSize) FlexLayoutOption { func WithSizes(sizes ...FlexChildSize) FlexLayoutOption {
return func(f *flexLayout) { return func(f *flexLayout) {
f.sizes = sizes f.sizes = sizes
} }
+8
View File
@@ -5,6 +5,7 @@ import (
"github.com/charmbracelet/bubbles/v2/key" "github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2" tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
) )
var Current *LayoutInfo var Current *LayoutInfo
@@ -45,6 +46,13 @@ type Sizeable interface {
GetSize() (int, int) GetSize() (int, int)
} }
type Alignable interface {
MaxWidth() int
Alignment() lipgloss.Position
SetPosition(x, y int)
GetPosition() (x, y int)
}
func KeyMapToSlice(t any) (bindings []key.Binding) { func KeyMapToSlice(t any) (bindings []key.Binding) {
typ := reflect.TypeOf(t) typ := reflect.TypeOf(t)
if typ.Kind() != reflect.Struct { if typ.Kind() != reflect.Struct {
+248 -15
View File
@@ -1,9 +1,13 @@
package layout package layout
import ( import (
"fmt"
"regexp"
"strings" "strings"
"unicode/utf8"
"github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/lipgloss/v2/compat"
chAnsi "github.com/charmbracelet/x/ansi" chAnsi "github.com/charmbracelet/x/ansi"
"github.com/muesli/ansi" "github.com/muesli/ansi"
"github.com/muesli/reflow/truncate" "github.com/muesli/reflow/truncate"
@@ -23,29 +27,58 @@ func getLines(s string) (lines []string, widest int) {
return lines, widest return lines, widest
} }
// overlayOptions holds configuration for overlay rendering
type overlayOptions struct {
whitespace *whitespace
border bool
borderColor *compat.AdaptiveColor
}
// OverlayOption sets options for overlay rendering
type OverlayOption func(*overlayOptions)
// PlaceOverlay places fg on top of bg. // PlaceOverlay places fg on top of bg.
func PlaceOverlay( func PlaceOverlay(
x, y int, x, y int,
fg, bg string, fg, bg string,
opts ...WhitespaceOption, opts ...OverlayOption,
) string { ) string {
fgLines, fgWidth := getLines(fg) fgLines, fgWidth := getLines(fg)
bgLines, bgWidth := getLines(bg) bgLines, bgWidth := getLines(bg)
bgHeight := len(bgLines) bgHeight := len(bgLines)
fgHeight := len(fgLines) fgHeight := len(fgLines)
if fgWidth >= bgWidth && fgHeight >= bgHeight { // Parse options
// FIXME: return fg or bg? options := &overlayOptions{
return fg whitespace: &whitespace{},
}
for _, opt := range opts {
opt(options)
} }
// TODO: allow placement outside of the bg box? // Adjust for borders if enabled
x = util.Clamp(x, 0, bgWidth-fgWidth) if options.border {
y = util.Clamp(y, 0, bgHeight-fgHeight) // Add space for left and right borders
adjustedFgWidth := fgWidth + 2
// Adjust placement to account for borders
x = util.Clamp(x, 0, bgWidth-adjustedFgWidth)
y = util.Clamp(y, 0, bgHeight-fgHeight)
ws := &whitespace{} // Pad all foreground lines to the same width for consistent borders
for _, opt := range opts { for i := range fgLines {
opt(ws) lineWidth := ansi.PrintableRuneWidth(fgLines[i])
if lineWidth < fgWidth {
fgLines[i] += strings.Repeat(" ", fgWidth-lineWidth)
}
}
} else {
if fgWidth >= bgWidth && fgHeight >= bgHeight {
// FIXME: return fg or bg?
return fg
}
// TODO: allow placement outside of the bg box?
x = util.Clamp(x, 0, bgWidth-fgWidth)
y = util.Clamp(y, 0, bgHeight-fgHeight)
} }
var b strings.Builder var b strings.Builder
@@ -59,25 +92,62 @@ func PlaceOverlay(
} }
pos := 0 pos := 0
// Handle left side of the line up to the overlay
if x > 0 { if x > 0 {
left := truncate.String(bgLine, uint(x)) left := truncate.String(bgLine, uint(x))
pos = ansi.PrintableRuneWidth(left) pos = ansi.PrintableRuneWidth(left)
b.WriteString(left) b.WriteString(left)
if pos < x { if pos < x {
b.WriteString(ws.render(x - pos)) b.WriteString(options.whitespace.render(x - pos))
pos = x pos = x
} }
} }
fgLine := fgLines[i-y] // Render the overlay content with optional borders
b.WriteString(fgLine) if options.border {
pos += ansi.PrintableRuneWidth(fgLine) // Get the foreground line
fgLine := fgLines[i-y]
fgLineWidth := ansi.PrintableRuneWidth(fgLine)
// Extract the styles at the border positions
leftStyle := getStyleAtPosition(bgLine, pos)
rightStyle := getStyleAtPosition(bgLine, pos + 1 + fgLineWidth)
// Left border - combine background from original with border foreground
leftSeq := combineStyles(leftStyle, options.borderColor)
if leftSeq != "" {
b.WriteString(leftSeq)
}
b.WriteString("┃")
b.WriteString("\x1b[0m") // Reset all styles
pos++
// Content
b.WriteString(fgLine)
pos += fgLineWidth
// Right border - combine background from original with border foreground
rightSeq := combineStyles(rightStyle, options.borderColor)
if rightSeq != "" {
b.WriteString(rightSeq)
}
b.WriteString("┃")
b.WriteString("\x1b[0m") // Reset all styles
pos++
} else {
// No border, just render the content
fgLine := fgLines[i-y]
b.WriteString(fgLine)
pos += ansi.PrintableRuneWidth(fgLine)
}
// Handle right side of the line after the overlay
right := cutLeft(bgLine, pos) right := cutLeft(bgLine, pos)
bgWidth := ansi.PrintableRuneWidth(bgLine) bgWidth := ansi.PrintableRuneWidth(bgLine)
rightWidth := ansi.PrintableRuneWidth(right) rightWidth := ansi.PrintableRuneWidth(right)
if rightWidth <= bgWidth-pos { if rightWidth <= bgWidth-pos {
b.WriteString(ws.render(bgWidth - rightWidth - pos)) b.WriteString(options.whitespace.render(bgWidth - rightWidth - pos))
} }
b.WriteString(right) b.WriteString(right)
@@ -92,6 +162,146 @@ func cutLeft(s string, cutWidth int) string {
return chAnsi.Cut(s, cutWidth, lipgloss.Width(s)) return chAnsi.Cut(s, cutWidth, lipgloss.Width(s))
} }
// ansiStyle represents parsed ANSI style attributes
type ansiStyle struct {
fgColor string
bgColor string
attrs []string
}
// parseANSISequence parses an ANSI escape sequence into its components
func parseANSISequence(seq string) ansiStyle {
style := ansiStyle{}
// Extract the parameters from the sequence (e.g., \x1b[38;5;123;48;5;456m -> "38;5;123;48;5;456")
if !strings.HasPrefix(seq, "\x1b[") || !strings.HasSuffix(seq, "m") {
return style
}
params := seq[2 : len(seq)-1]
if params == "" {
return style
}
parts := strings.Split(params, ";")
i := 0
for i < len(parts) {
switch parts[i] {
case "0": // Reset
style = ansiStyle{}
case "1", "2", "3", "4", "5", "6", "7", "8", "9": // Various attributes
style.attrs = append(style.attrs, parts[i])
case "38": // Foreground color
if i+1 < len(parts) && parts[i+1] == "5" && i+2 < len(parts) {
// 256 color mode
style.fgColor = strings.Join(parts[i:i+3], ";")
i += 2
} else if i+1 < len(parts) && parts[i+1] == "2" && i+4 < len(parts) {
// RGB color mode
style.fgColor = strings.Join(parts[i:i+5], ";")
i += 4
}
case "48": // Background color
if i+1 < len(parts) && parts[i+1] == "5" && i+2 < len(parts) {
// 256 color mode
style.bgColor = strings.Join(parts[i:i+3], ";")
i += 2
} else if i+1 < len(parts) && parts[i+1] == "2" && i+4 < len(parts) {
// RGB color mode
style.bgColor = strings.Join(parts[i:i+5], ";")
i += 4
}
case "30", "31", "32", "33", "34", "35", "36", "37": // Standard foreground colors
style.fgColor = parts[i]
case "40", "41", "42", "43", "44", "45", "46", "47": // Standard background colors
style.bgColor = parts[i]
case "90", "91", "92", "93", "94", "95", "96", "97": // Bright foreground colors
style.fgColor = parts[i]
case "100", "101", "102", "103", "104", "105", "106", "107": // Bright background colors
style.bgColor = parts[i]
}
i++
}
return style
}
// combineStyles creates an ANSI sequence that combines background from one style with foreground from another
func combineStyles(bgStyle ansiStyle, fgColor *compat.AdaptiveColor) string {
if fgColor == nil && bgStyle.bgColor == "" && len(bgStyle.attrs) == 0 {
return ""
}
var parts []string
// Add attributes
parts = append(parts, bgStyle.attrs...)
// Add background color from the original style
if bgStyle.bgColor != "" {
parts = append(parts, bgStyle.bgColor)
}
// Add foreground color if specified
if fgColor != nil {
// Use the light color (could be improved to detect terminal background)
color := (*fgColor).Light
// Use RGBA to get color components
r, g, b, _ := color.RGBA()
// RGBA returns 16-bit values, we need 8-bit
parts = append(parts, fmt.Sprintf("38;2;%d;%d;%d", r>>8, g>>8, b>>8))
}
if len(parts) == 0 {
return ""
}
return fmt.Sprintf("\x1b[%sm", strings.Join(parts, ";"))
}
// getStyleAtPosition extracts the active ANSI style at a given visual position
func getStyleAtPosition(s string, targetPos int) ansiStyle {
// ANSI escape sequence regex
ansiRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
visualPos := 0
currentStyle := ansiStyle{}
i := 0
for i < len(s) && visualPos <= targetPos {
// Check if we're at an ANSI escape sequence
if match := ansiRegex.FindStringIndex(s[i:]); match != nil && match[0] == 0 {
// Found an ANSI sequence at current position
seq := s[i : i+match[1]]
parsedStyle := parseANSISequence(seq)
// Update current style (merge with existing)
if parsedStyle.fgColor != "" {
currentStyle.fgColor = parsedStyle.fgColor
}
if parsedStyle.bgColor != "" {
currentStyle.bgColor = parsedStyle.bgColor
}
if len(parsedStyle.attrs) > 0 {
currentStyle.attrs = parsedStyle.attrs
}
i += match[1]
} else if i < len(s) {
// Regular character
if visualPos == targetPos {
return currentStyle
}
_, size := utf8.DecodeRuneInString(s[i:])
i += size
visualPos++
}
}
return currentStyle
}
type whitespace struct { type whitespace struct {
style termenv.Style style termenv.Style
chars string chars string
@@ -129,3 +339,26 @@ func (w whitespace) render(width int) string {
// WhitespaceOption sets a styling rule for rendering whitespace. // WhitespaceOption sets a styling rule for rendering whitespace.
type WhitespaceOption func(*whitespace) type WhitespaceOption func(*whitespace)
// WithWhitespace sets whitespace options for the overlay
func WithWhitespace(opts ...WhitespaceOption) OverlayOption {
return func(o *overlayOptions) {
for _, opt := range opts {
opt(o.whitespace)
}
}
}
// WithOverlayBorder enables border rendering for the overlay
func WithOverlayBorder() OverlayOption {
return func(o *overlayOptions) {
o.border = true
}
}
// WithOverlayBorderColor sets the border color for the overlay
func WithOverlayBorderColor(color compat.AdaptiveColor) OverlayOption {
return func(o *overlayOptions) {
o.borderColor = &color
}
}
-187
View File
@@ -1,187 +0,0 @@
package page
import (
"context"
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/completions"
"github.com/sst/opencode/internal/components/chat"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/util"
)
var ChatPage PageID = "chat"
type chatPage struct {
app *app.App
editor layout.Container
messages layout.Container
layout layout.FlexLayout
completionDialog dialog.CompletionDialog
completionManager *completions.CompletionManager
showCompletionDialog bool
}
type ChatKeyMap struct {
Cancel key.Binding
ToggleTools key.Binding
ShowCompletionDialog key.Binding
}
var keyMap = ChatKeyMap{
Cancel: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "cancel"),
),
ToggleTools: key.NewBinding(
key.WithKeys("ctrl+h"),
key.WithHelp("ctrl+h", "toggle tools"),
),
ShowCompletionDialog: key.NewBinding(
key.WithKeys("/"),
key.WithHelp("/", "Complete"),
),
}
func (p *chatPage) Init() tea.Cmd {
cmds := []tea.Cmd{
p.layout.Init(),
}
cmds = append(cmds, p.completionDialog.Init())
return tea.Batch(cmds...)
}
func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
cmd := p.layout.SetSize(msg.Width, msg.Height)
cmds = append(cmds, cmd)
case chat.SendMsg:
p.showCompletionDialog = false
cmd := p.sendMessage(msg.Text, msg.Attachments)
if cmd != nil {
return p, cmd
}
case dialog.CompletionDialogCloseMsg:
p.showCompletionDialog = false
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
_, cmd := p.editor.Update(msg)
if cmd != nil {
return p, cmd
}
}
switch {
case key.Matches(msg, keyMap.ShowCompletionDialog):
p.showCompletionDialog = true
// Continue sending keys to layout->chat
case key.Matches(msg, keyMap.Cancel):
if p.app.Session.Id != "" {
// Cancel the current session's generation process
// This allows users to interrupt long-running operations
p.app.Cancel(context.Background(), p.app.Session.Id)
return p, nil
}
case key.Matches(msg, keyMap.ToggleTools):
return p, util.CmdHandler(chat.ToggleToolMessagesMsg{})
}
}
if p.showCompletionDialog {
// Get the current text from the editor to determine which provider to use
editorModel := p.editor.GetContent().(interface{ GetValue() string })
currentInput := editorModel.GetValue()
provider := p.completionManager.GetProvider(currentInput)
p.completionDialog.SetProvider(provider)
context, contextCmd := p.completionDialog.Update(msg)
p.completionDialog = context.(dialog.CompletionDialog)
cmds = append(cmds, contextCmd)
// Doesn't forward event if enter key is pressed
if keyMsg, ok := msg.(tea.KeyMsg); ok {
if keyMsg.String() == "enter" {
return p, tea.Batch(cmds...)
}
}
}
u, cmd := p.layout.Update(msg)
cmds = append(cmds, cmd)
p.layout = u.(layout.FlexLayout)
return p, tea.Batch(cmds...)
}
func (p *chatPage) sendMessage(text string, attachments []app.Attachment) tea.Cmd {
var cmds []tea.Cmd
cmd := p.app.SendChatMessage(context.Background(), text, attachments)
cmds = append(cmds, cmd)
return tea.Batch(cmds...)
}
func (p *chatPage) SetSize(width, height int) tea.Cmd {
return p.layout.SetSize(width, height)
}
func (p *chatPage) GetSize() (int, int) {
return p.layout.GetSize()
}
func (p *chatPage) View() string {
layoutView := p.layout.View()
if p.showCompletionDialog {
editorWidth, _ := p.editor.GetSize()
editorX, editorY := p.editor.GetPosition()
p.completionDialog.SetWidth(editorWidth)
overlay := p.completionDialog.View()
layoutView = layout.PlaceOverlay(
editorX,
editorY-lipgloss.Height(overlay)+2,
overlay,
layoutView,
)
}
return layoutView
}
func NewChatPage(app *app.App) layout.ModelWithView {
completionManager := completions.NewCompletionManager(app)
initialProvider := completionManager.GetProvider("")
completionDialog := dialog.NewCompletionDialogComponent(initialProvider)
messagesContainer := layout.NewContainer(
chat.NewMessagesComponent(app),
)
editorContainer := layout.NewContainer(
chat.NewEditorComponent(app),
layout.WithMaxWidth(layout.Current.Container.Width),
layout.WithAlignCenter(),
)
return &chatPage{
app: app,
editor: editorContainer,
messages: messagesContainer,
completionDialog: completionDialog,
completionManager: completionManager,
layout: layout.NewFlexLayout(
layout.WithPanes(messagesContainer, editorContainer),
layout.WithDirection(layout.FlexDirectionVertical),
layout.WithPaneSizes(
layout.FlexPaneSizeGrow,
layout.FlexPaneSizeFixed(6),
),
),
}
}
-8
View File
@@ -1,8 +0,0 @@
package page
type PageID string
// PageChangeMsg is used to change the current page
type PageChangeMsg struct {
ID PageID
}
-19
View File
@@ -1,19 +0,0 @@
package state
import (
"github.com/sst/opencode/pkg/client"
)
type SessionSelectedMsg = *client.SessionInfo
type ModelSelectedMsg struct {
Provider client.ProviderInfo
Model client.ModelInfo
}
type SessionClearedMsg struct{}
type CompactSessionMsg struct{}
// TODO: remove
type StateUpdatedMsg struct {
State map[string]any
}
+1 -1
View File
@@ -8,6 +8,6 @@ var Terminal *TerminalInfo
func init() { func init() {
Terminal = &TerminalInfo{ Terminal = &TerminalInfo{
BackgroundIsDark: false, BackgroundIsDark: true,
} }
} }
-12
View File
@@ -1,12 +0,0 @@
package styles
const (
OpenCodeIcon string = "◍"
ErrorIcon string = "ⓔ"
WarningIcon string = "ⓦ"
InfoIcon string = "ⓘ"
HintIcon string = "ⓗ"
SpinnerIcon string = "⟳"
DocumentIcon string = "🖼"
)
+4 -4
View File
@@ -15,7 +15,7 @@ func BaseStyle() lipgloss.Style {
func Panel() lipgloss.Style { func Panel() lipgloss.Style {
t := theme.CurrentTheme() t := theme.CurrentTheme()
return lipgloss.NewStyle(). return lipgloss.NewStyle().
Background(t.BackgroundSubtle()). Background(t.BackgroundPanel()).
Border(lipgloss.NormalBorder(), true, false, true, false). Border(lipgloss.NormalBorder(), true, false, true, false).
BorderForeground(t.BorderSubtle()). BorderForeground(t.BorderSubtle()).
Foreground(t.Text()) Foreground(t.Text())
@@ -131,9 +131,9 @@ func BackgroundColor() compat.AdaptiveColor {
return theme.CurrentTheme().Background() return theme.CurrentTheme().Background()
} }
// BackgroundSubtleColor returns the subtle background color from the current theme // BackgroundPanelColor returns the subtle background color from the current theme
func BackgroundSubtleColor() compat.AdaptiveColor { func BackgroundPanelColor() compat.AdaptiveColor {
return theme.CurrentTheme().BackgroundSubtle() return theme.CurrentTheme().BackgroundPanel()
} }
// BackgroundElementColor returns the darker background color from the current theme // BackgroundElementColor returns the darker background color from the current theme
+1 -1
View File
@@ -98,7 +98,7 @@ func NewAyuTheme() *AyuTheme {
Dark: lipgloss.Color(darkBg), Dark: lipgloss.Color(darkBg),
Light: lipgloss.Color(darkBg), Light: lipgloss.Color(darkBg),
} }
theme.BackgroundSubtleColor = compat.AdaptiveColor{ theme.BackgroundPanelColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkBgAlt), Dark: lipgloss.Color(darkBgAlt),
Light: lipgloss.Color(darkBgAlt), Light: lipgloss.Color(darkBgAlt),
} }
+298
View File
@@ -0,0 +1,298 @@
package theme
import (
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/lipgloss/v2/compat"
)
// EverforestTheme implements the Theme interface with Everforest colors.
// It provides both dark and light variants with Medium (default) contrast.
type EverforestTheme struct {
BaseTheme
}
// NewEverforestTheme creates a new instance of the Everforest Medium theme.
func NewEverforestTheme() *EverforestTheme {
// Everforest color palette - Medium variant
// Official colors from https://github.com/sainnhe/everforest/wiki
// Dark mode colors - using Everforest:Dark Medium contrast palette
darkStep1 := "#2d353b" // App background
darkStep2 := "#333c43" // Subtle background
darkStep3 := "#343f44" // UI element background
darkStep4 := "#3d484d" // Hovered UI element background
darkStep5 := "#475258" // Active/Selected UI element background
darkStep6 := "#7a8478" // Subtle borders and separators
darkStep7 := "#859289" // UI element border and focus rings
darkStep8 := "#9da9a0" // Hovered UI element border
darkStep9 := "#a7c080" // Solid backgrounds
darkStep10 := "#83c092" // Hovered solid backgrounds
darkStep11 := "#7a8478" // Low-contrast text
darkStep12 := "#d3c6aa" // High-contrast text
// Dark mode accent colors
darkPrimary := darkStep9 // Primary uses step 9 (green)
darkSecondary := "#7fbbb3" // Secondary (blue)
darkAccent := "#d699b6" // Accent (purple)
darkRed := "#e67e80" // Error (red)
darkOrange := "#e69875" // Warning (orange)
darkGreen := "#a7c080" // Success (green)
darkCyan := "#83c092" // Info (aqua)
darkYellow := "#dbbc7f" // Emphasized text
// Light mode colors for the Everforest:Light Medium contrast palette
lightStep1 := "#fdf6e3" // App background
lightStep2 := "#efebd4" // Subtle background
lightStep3 := "#f4f0d9" // UI element background
lightStep4 := "#efebd4" // Hovered UI element background
lightStep5 := "#e6e2cc" // Active/Selected UI element background
lightStep6 := "#a6b0a0" // Subtle borders and separators
lightStep7 := "#939f91" // UI element border and focus rings
lightStep8 := "#829181" // Hovered UI element border
lightStep9 := "#8da101" // Solid backgrounds
lightStep10 := "#35a77c" // Hovered solid backgrounds
lightStep11 := "#a6b0a0" // Low-contrast text
lightStep12 := "#5c6a72" // High-contrast text
// Light mode accent colors
lightPrimary := lightStep9 // Primary uses step 9 (green)
lightSecondary := "#3a94c5" // Secondary blue
lightAccent := "#df69ba" // Accent purple
lightRed := "#f85552" // Error red
lightOrange := "#f57d26" // Warning orange
lightGreen := "#8da101" // Success green
lightCyan := "#35a77c" // Info aqua
lightYellow := "#dfa000" // Emphasized text
// Unused variables. These could be used for hover states
_ = darkStep4
_ = darkStep5
_ = darkStep10
_ = lightStep4
_ = lightStep5
_ = lightStep10
theme := &EverforestTheme{}
// Base colors
theme.PrimaryColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkPrimary),
Light: lipgloss.Color(lightPrimary),
}
theme.SecondaryColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkSecondary),
Light: lipgloss.Color(lightSecondary),
}
theme.AccentColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkAccent),
Light: lipgloss.Color(lightAccent),
}
// Status colors
theme.ErrorColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkRed),
Light: lipgloss.Color(lightRed),
}
theme.WarningColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkOrange),
Light: lipgloss.Color(lightOrange),
}
theme.SuccessColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkGreen),
Light: lipgloss.Color(lightGreen),
}
theme.InfoColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkCyan),
Light: lipgloss.Color(lightCyan),
}
// Text colors
theme.TextColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkStep12),
Light: lipgloss.Color(lightStep12),
}
theme.TextMutedColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkStep11),
Light: lipgloss.Color(lightStep11),
}
// Background colors
theme.BackgroundColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkStep1),
Light: lipgloss.Color(lightStep1),
}
theme.BackgroundPanelColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkStep2),
Light: lipgloss.Color(lightStep2),
}
theme.BackgroundElementColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkStep3),
Light: lipgloss.Color(lightStep3),
}
// Border colors
theme.BorderColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkStep7),
Light: lipgloss.Color(lightStep7),
}
theme.BorderActiveColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkStep8),
Light: lipgloss.Color(lightStep8),
}
theme.BorderSubtleColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkStep6),
Light: lipgloss.Color(lightStep6),
}
// Diff view colors
theme.DiffAddedColor = compat.AdaptiveColor{
Dark: lipgloss.Color("#A7C080"),
Light: lipgloss.Color("#8DA101"),
}
theme.DiffRemovedColor = compat.AdaptiveColor{
Dark: lipgloss.Color("#E67E80"),
Light: lipgloss.Color("#F85552"),
}
theme.DiffContextColor = compat.AdaptiveColor{
Dark: lipgloss.Color("#7A8478"),
Light: lipgloss.Color("#A6B0A0"),
}
theme.DiffHunkHeaderColor = compat.AdaptiveColor{
Dark: lipgloss.Color("#859289"),
Light: lipgloss.Color("#939F91"),
}
theme.DiffHighlightAddedColor = compat.AdaptiveColor{
Dark: lipgloss.Color("#A7C080"),
Light: lipgloss.Color("#8DA101"),
}
theme.DiffHighlightRemovedColor = compat.AdaptiveColor{
Dark: lipgloss.Color("#E67E80"),
Light: lipgloss.Color("#F85552"),
}
theme.DiffAddedBgColor = compat.AdaptiveColor{
Dark: lipgloss.Color("#425047"),
Light: lipgloss.Color("#F0F1D2"),
}
theme.DiffRemovedBgColor = compat.AdaptiveColor{
Dark: lipgloss.Color("#543A48"),
Light: lipgloss.Color("#FBE3DA"),
}
theme.DiffContextBgColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkStep2),
Light: lipgloss.Color(lightStep2),
}
theme.DiffLineNumberColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkStep3),
Light: lipgloss.Color(lightStep3),
}
theme.DiffAddedLineNumberBgColor = compat.AdaptiveColor{
Dark: lipgloss.Color("#3A4A3F"),
Light: lipgloss.Color("#E8F2D1"),
}
theme.DiffRemovedLineNumberBgColor = compat.AdaptiveColor{
Dark: lipgloss.Color("#4A3A40"),
Light: lipgloss.Color("#FBDAD2"),
}
// Markdown colors
theme.MarkdownTextColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkStep12),
Light: lipgloss.Color(lightStep12),
}
theme.MarkdownHeadingColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkSecondary),
Light: lipgloss.Color(lightSecondary),
}
theme.MarkdownLinkColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkPrimary),
Light: lipgloss.Color(lightPrimary),
}
theme.MarkdownLinkTextColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkCyan),
Light: lipgloss.Color(lightCyan),
}
theme.MarkdownCodeColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkGreen),
Light: lipgloss.Color(lightGreen),
}
theme.MarkdownBlockQuoteColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkYellow),
Light: lipgloss.Color(lightYellow),
}
theme.MarkdownEmphColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkYellow),
Light: lipgloss.Color(lightYellow),
}
theme.MarkdownStrongColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkAccent),
Light: lipgloss.Color(lightAccent),
}
theme.MarkdownHorizontalRuleColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkStep11),
Light: lipgloss.Color(lightStep11),
}
theme.MarkdownListItemColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkPrimary),
Light: lipgloss.Color(lightPrimary),
}
theme.MarkdownListEnumerationColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkCyan),
Light: lipgloss.Color(lightCyan),
}
theme.MarkdownImageColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkPrimary),
Light: lipgloss.Color(lightPrimary),
}
theme.MarkdownImageTextColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkCyan),
Light: lipgloss.Color(lightCyan),
}
theme.MarkdownCodeBlockColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkStep12),
Light: lipgloss.Color(lightStep12),
}
// Syntax highlighting colors
theme.SyntaxCommentColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkStep11),
Light: lipgloss.Color(lightStep11),
}
theme.SyntaxKeywordColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkPrimary),
Light: lipgloss.Color(lightPrimary),
}
theme.SyntaxFunctionColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkSecondary),
Light: lipgloss.Color(lightSecondary),
}
theme.SyntaxVariableColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkRed),
Light: lipgloss.Color(lightRed),
}
theme.SyntaxStringColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkGreen),
Light: lipgloss.Color(lightGreen),
}
theme.SyntaxNumberColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkAccent),
Light: lipgloss.Color(lightAccent),
}
theme.SyntaxTypeColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkYellow),
Light: lipgloss.Color(lightYellow),
}
theme.SyntaxOperatorColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkCyan),
Light: lipgloss.Color(lightCyan),
}
theme.SyntaxPunctuationColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkStep12),
Light: lipgloss.Color(lightStep12),
}
return theme
}
func init() {
// Register the Everforest theme with the theme manager
RegisterTheme("everforest", NewEverforestTheme())
}
+1 -1
View File
@@ -146,7 +146,7 @@ func LoadCustomTheme(customTheme map[string]any) (Theme, error) {
case "background": case "background":
theme.BackgroundColor = adaptiveColor theme.BackgroundColor = adaptiveColor
case "backgroundsubtle": case "backgroundsubtle":
theme.BackgroundSubtleColor = adaptiveColor theme.BackgroundPanelColor = adaptiveColor
case "backgroundelement": case "backgroundelement":
theme.BackgroundElementColor = adaptiveColor theme.BackgroundElementColor = adaptiveColor
case "border": case "border":
+1 -1
View File
@@ -119,7 +119,7 @@ func NewOpenCodeTheme() *OpenCodeTheme {
Dark: lipgloss.Color(darkStep1), Dark: lipgloss.Color(darkStep1),
Light: lipgloss.Color(lightStep1), Light: lipgloss.Color(lightStep1),
} }
theme.BackgroundSubtleColor = compat.AdaptiveColor{ theme.BackgroundPanelColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkStep2), Dark: lipgloss.Color(darkStep2),
Light: lipgloss.Color(lightStep2), Light: lipgloss.Color(lightStep2),
} }
+3 -3
View File
@@ -14,7 +14,7 @@ import (
type Theme interface { type Theme interface {
// Background colors // Background colors
Background() compat.AdaptiveColor // Radix 1 Background() compat.AdaptiveColor // Radix 1
BackgroundSubtle() compat.AdaptiveColor // Radix 2 BackgroundPanel() compat.AdaptiveColor // Radix 2
BackgroundElement() compat.AdaptiveColor // Radix 3 BackgroundElement() compat.AdaptiveColor // Radix 3
// Border colors // Border colors
@@ -84,7 +84,7 @@ type Theme interface {
type BaseTheme struct { type BaseTheme struct {
// Background colors // Background colors
BackgroundColor compat.AdaptiveColor BackgroundColor compat.AdaptiveColor
BackgroundSubtleColor compat.AdaptiveColor BackgroundPanelColor compat.AdaptiveColor
BackgroundElementColor compat.AdaptiveColor BackgroundElementColor compat.AdaptiveColor
// Border colors // Border colors
@@ -163,7 +163,7 @@ func (t *BaseTheme) Text() compat.AdaptiveColor { return t.TextColor }
func (t *BaseTheme) TextMuted() compat.AdaptiveColor { return t.TextMutedColor } func (t *BaseTheme) TextMuted() compat.AdaptiveColor { return t.TextMutedColor }
func (t *BaseTheme) Background() compat.AdaptiveColor { return t.BackgroundColor } func (t *BaseTheme) Background() compat.AdaptiveColor { return t.BackgroundColor }
func (t *BaseTheme) BackgroundSubtle() compat.AdaptiveColor { return t.BackgroundSubtleColor } func (t *BaseTheme) BackgroundPanel() compat.AdaptiveColor { return t.BackgroundPanelColor }
func (t *BaseTheme) BackgroundElement() compat.AdaptiveColor { return t.BackgroundElementColor } func (t *BaseTheme) BackgroundElement() compat.AdaptiveColor { return t.BackgroundElementColor }
func (t *BaseTheme) Border() compat.AdaptiveColor { return t.BorderColor } func (t *BaseTheme) Border() compat.AdaptiveColor { return t.BorderColor }
+1 -1
View File
@@ -117,7 +117,7 @@ func NewTokyoNightTheme() *TokyoNightTheme {
Dark: lipgloss.Color(darkStep1), Dark: lipgloss.Color(darkStep1),
Light: lipgloss.Color(lightStep1), Light: lipgloss.Color(lightStep1),
} }
theme.BackgroundSubtleColor = compat.AdaptiveColor{ theme.BackgroundPanelColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkStep2), Dark: lipgloss.Color(darkStep2),
Light: lipgloss.Color(lightStep2), Light: lipgloss.Color(lightStep2),
} }
+471 -237
View File
@@ -3,52 +3,58 @@ package tui
import ( import (
"context" "context"
"log/slog" "log/slog"
"os"
"os/exec"
"strings"
"github.com/charmbracelet/bubbles/v2/cursor"
"github.com/charmbracelet/bubbles/v2/key" "github.com/charmbracelet/bubbles/v2/key"
"github.com/charmbracelet/bubbles/v2/spinner"
tea "github.com/charmbracelet/bubbletea/v2" tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands" "github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/core" "github.com/sst/opencode/internal/completions"
"github.com/sst/opencode/internal/components/chat"
"github.com/sst/opencode/internal/components/dialog" "github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/components/modal" "github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/components/status"
"github.com/sst/opencode/internal/components/toast"
"github.com/sst/opencode/internal/layout" "github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/page"
"github.com/sst/opencode/internal/state"
"github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util" "github.com/sst/opencode/internal/util"
"github.com/sst/opencode/pkg/client" "github.com/sst/opencode/pkg/client"
) )
type appModel struct { type appModel struct {
width, height int width, height int
currentPage page.PageID app *app.App
previousPage page.PageID modal layout.Modal
pages map[page.PageID]layout.ModelWithView status status.StatusComponent
loadedPages map[page.PageID]bool editor chat.EditorComponent
status core.StatusComponent messages chat.MessagesComponent
app *app.App editorContainer layout.Container
modal layout.Modal layout layout.FlexLayout
completions dialog.CompletionDialog
completionManager *completions.CompletionManager
showCompletionDialog bool
leaderBinding *key.Binding
isLeaderSequence bool
toastManager *toast.ToastManager
} }
func (a appModel) Init() tea.Cmd { func (a appModel) Init() tea.Cmd {
t := theme.CurrentTheme()
var cmds []tea.Cmd var cmds []tea.Cmd
// https://github.com/charmbracelet/bubbletea/issues/1440
// https://github.com/sst/opencode/issues/127
if !util.IsWsl() {
cmds = append(cmds, tea.RequestBackgroundColor)
}
cmds = append(cmds, a.app.InitializeProvider()) cmds = append(cmds, a.app.InitializeProvider())
cmds = append(cmds, a.editor.Init())
cmds = append(cmds, tea.SetBackgroundColor(t.Background())) cmds = append(cmds, a.messages.Init())
cmds = append(cmds, tea.RequestBackgroundColor) cmds = append(cmds, a.status.Init())
cmds = append(cmds, a.completions.Init())
cmd := a.pages[a.currentPage].Init() cmds = append(cmds, a.toastManager.Init())
a.loadedPages[a.currentPage] = true
cmds = append(cmds, cmd)
cmd = a.status.Init()
cmds = append(cmds, cmd)
// Check if we should show the init dialog // Check if we should show the init dialog
cmds = append(cmds, func() tea.Msg { cmds = append(cmds, func() tea.Msg {
@@ -59,149 +65,184 @@ func (a appModel) Init() tea.Cmd {
return tea.Batch(cmds...) return tea.Batch(cmds...)
} }
func (a appModel) updateAllPages(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
for id := range a.pages {
updated, cmd := a.pages[id].Update(msg)
a.pages[id] = updated.(layout.ModelWithView)
cmds = append(cmds, cmd)
}
s, cmd := a.status.Update(msg)
cmds = append(cmds, cmd)
a.status = s.(core.StatusComponent)
return a, tea.Batch(cmds...)
}
func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd var cmds []tea.Cmd
var cmd tea.Cmd
if a.modal != nil { switch msg := msg.(type) {
bypassModal := false case tea.KeyPressMsg:
keyString := msg.String()
if _, ok := msg.(modal.CloseModalMsg); ok { // 1. Handle active modal
a.modal = nil if a.modal != nil {
return a, nil switch keyString {
} // Escape always closes current modal
case "esc", "ctrl+c":
if msg, ok := msg.(tea.KeyMsg); ok {
switch msg.String() {
case "esc":
a.modal = nil a.modal = nil
return a, nil return a, nil
case "ctrl+c":
return a, tea.Quit
} }
// don't send commands to the modal // Pass all other key presses to the modal
for _, cmdDef := range a.app.Commands {
if key.Matches(msg, cmdDef.KeyBinding) {
bypassModal = true
break
}
}
}
// thanks i hate this
switch msg.(type) {
case tea.WindowSizeMsg:
bypassModal = true
case client.EventSessionUpdated:
bypassModal = true
case client.EventMessageUpdated:
bypassModal = true
case cursor.BlinkMsg:
bypassModal = true
case spinner.TickMsg:
bypassModal = true
}
if !bypassModal {
updatedModal, cmd := a.modal.Update(msg) updatedModal, cmd := a.modal.Update(msg)
a.modal = updatedModal.(layout.Modal) a.modal = updatedModal.(layout.Modal)
return a, cmd return a, cmd
} }
}
switch msg := msg.(type) { // 2. Check for commands that require leader
case commands.ExecuteCommandMsg: if a.isLeaderSequence {
switch msg.Name { matches := a.app.Commands.Matches(msg, a.isLeaderSequence)
case "quit": a.isLeaderSequence = false
return a, tea.Quit if len(matches) > 0 {
case "new": return a, util.CmdHandler(commands.ExecuteCommandsMsg(matches))
a.app.Session = &client.SessionInfo{}
a.app.Messages = []client.MessageInfo{}
cmds = append(cmds, util.CmdHandler(state.SessionClearedMsg{}))
case "sessions":
sessionDialog := dialog.NewSessionDialog(a.app)
a.modal = sessionDialog
case "model":
modelDialog := dialog.NewModelDialog(a.app)
a.modal = modelDialog
case "theme":
themeDialog := dialog.NewThemeDialog()
a.modal = themeDialog
case "share":
a.app.Client.PostSessionShareWithResponse(context.Background(), client.PostSessionShareJSONRequestBody{
SessionID: a.app.Session.Id,
})
case "init":
return a, a.app.InitializeProject(context.Background())
// case "compact":
// return a, a.app.CompactSession(context.Background())
case "help":
var helpBindings []key.Binding
for _, cmd := range a.app.Commands {
// Create a new binding for help display
helpBindings = append(helpBindings, key.NewBinding(
key.WithKeys(cmd.KeyBinding.Keys()...),
key.WithHelp("/"+cmd.Name, cmd.Description),
))
} }
helpDialog := dialog.NewHelpDialog(helpBindings...)
a.modal = helpDialog
} }
slog.Info("Execute command", "cmds", cmds)
return a, tea.Batch(cmds...)
// 3. Handle completions trigger
if keyString == "/" && !a.showCompletionDialog {
a.showCompletionDialog = true
initialValue := "/"
currentInput := a.editor.Value()
// if the input doesn't end with a space,
// then we want to include the last word
// (ie, `packages/`)
if !strings.HasSuffix(currentInput, " ") {
words := strings.Split(a.editor.Value(), " ")
if len(words) > 0 {
lastWord := words[len(words)-1]
lastWord = strings.TrimSpace(lastWord)
initialValue = lastWord + "/"
}
}
updated, cmd := a.completions.Update(
app.CompletionDialogTriggerdMsg{
InitialValue: initialValue,
},
)
a.completions = updated.(dialog.CompletionDialog)
cmds = append(cmds, cmd)
updated, cmd = a.editor.Update(msg)
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
updated, cmd = a.updateCompletions(msg)
a.completions = updated.(dialog.CompletionDialog)
cmds = append(cmds, cmd)
return a, tea.Sequence(cmds...)
}
if a.showCompletionDialog {
switch keyString {
case "tab", "enter", "esc", "ctrl+c":
updated, cmd := a.updateCompletions(msg)
a.completions = updated.(dialog.CompletionDialog)
cmds = append(cmds, cmd)
return a, tea.Batch(cmds...)
}
updated, cmd := a.editor.Update(msg)
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
updated, cmd = a.updateCompletions(msg)
a.completions = updated.(dialog.CompletionDialog)
cmds = append(cmds, cmd)
return a, tea.Batch(cmds...)
}
// 4. Maximize editor responsiveness for printable characters
if msg.Text != "" {
updated, cmd := a.editor.Update(msg)
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
return a, tea.Batch(cmds...)
}
// 5. Check for leader key activation
if a.leaderBinding != nil &&
!a.isLeaderSequence &&
key.Matches(msg, *a.leaderBinding) {
a.isLeaderSequence = true
return a, nil
}
// 6. Check again for commands that don't require leader
matches := a.app.Commands.Matches(msg, a.isLeaderSequence)
if len(matches) > 0 {
return a, util.CmdHandler(commands.ExecuteCommandsMsg(matches))
}
// 7. Fallback to editor. This is for other characters
// likek backspace, tab, etc.
updatedEditor, cmd := a.editor.Update(msg)
a.editor = updatedEditor.(chat.EditorComponent)
return a, cmd
case tea.MouseWheelMsg:
if a.modal != nil {
return a, nil
}
updated, cmd := a.messages.Update(msg)
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case tea.BackgroundColorMsg: case tea.BackgroundColorMsg:
styles.Terminal = &styles.TerminalInfo{ styles.Terminal = &styles.TerminalInfo{
BackgroundIsDark: msg.IsDark(), BackgroundIsDark: msg.IsDark(),
} }
slog.Debug("Background color", "isDark", msg.IsDark())
case cursor.BlinkMsg: case modal.CloseModalMsg:
return a.updateAllPages(msg) a.modal = nil
return a, nil
case spinner.TickMsg: case commands.ExecuteCommandMsg:
return a.updateAllPages(msg) updated, cmd := a.executeCommand(commands.Command(msg))
return updated, cmd
case commands.ExecuteCommandsMsg:
for _, command := range msg {
updated, cmd := a.executeCommand(command)
if cmd != nil {
return updated, cmd
}
}
case app.SendMsg:
a.showCompletionDialog = false
cmd := a.app.SendChatMessage(context.Background(), msg.Text, msg.Attachments)
cmds = append(cmds, cmd)
case dialog.CompletionDialogCloseMsg:
a.showCompletionDialog = false
case client.EventInstallationUpdated:
return a, toast.NewSuccessToast(
"opencode updated to "+msg.Properties.Version+", restart to apply.",
toast.WithTitle("New version installed"),
)
case client.EventSessionUpdated: case client.EventSessionUpdated:
if msg.Properties.Info.Id == a.app.Session.Id { if msg.Properties.Info.Id == a.app.Session.Id {
a.app.Session = &msg.Properties.Info a.app.Session = &msg.Properties.Info
return a.updateAllPages(state.StateUpdatedMsg{State: nil})
} }
case client.EventMessageUpdated: case client.EventMessageUpdated:
if msg.Properties.Info.Metadata.SessionID == a.app.Session.Id { if msg.Properties.Info.Metadata.SessionID == a.app.Session.Id {
exists := false
for i, m := range a.app.Messages { for i, m := range a.app.Messages {
if m.Id == msg.Properties.Info.Id { if m.Id == msg.Properties.Info.Id {
a.app.Messages[i] = msg.Properties.Info a.app.Messages[i] = msg.Properties.Info
return a.updateAllPages(state.StateUpdatedMsg{State: nil}) exists = true
} }
} }
a.app.Messages = append(a.app.Messages, msg.Properties.Info) if !exists {
return a.updateAllPages(state.StateUpdatedMsg{State: nil}) a.app.Messages = append(a.app.Messages, msg.Properties.Info)
}
}
case client.EventSessionError:
unknownError, err := msg.Properties.Error.AsUnknownError()
if err == nil {
slog.Error("Server error", "name", unknownError.Name, "message", unknownError.Data.Message)
return a, toast.NewErrorToast(unknownError.Data.Message, toast.WithTitle(unknownError.Name))
} }
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
msg.Height -= 2 // Make space for the status bar msg.Height -= 2 // Make space for the status bar
a.width, a.height = msg.Width, msg.Height a.width, a.height = msg.Width, msg.Height
// TODO: move away from global state
layout.Current = &layout.LayoutInfo{ layout.Current = &layout.LayoutInfo{
Viewport: layout.Dimensions{ Viewport: layout.Dimensions{
Width: a.width, Width: a.width,
@@ -211,139 +252,332 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
Width: min(a.width, 80), Width: min(a.width, 80),
}, },
} }
a.layout.SetSize(a.width, a.height)
s, cmd := a.status.Update(msg) case app.SessionSelectedMsg:
a.status = s.(core.StatusComponent) messages, err := a.app.ListMessages(context.Background(), msg.Id)
if cmd != nil { if err != nil {
cmds = append(cmds, cmd) slog.Error("Failed to list messages", "error", err)
return a, toast.NewErrorToast("Failed to open session")
} }
updated, cmd := a.pages[a.currentPage].Update(msg)
a.pages[a.currentPage] = updated.(layout.ModelWithView)
if cmd != nil {
cmds = append(cmds, cmd)
}
if a.modal != nil {
s, cmd := a.modal.Update(msg)
a.modal = s.(layout.Modal)
if cmd != nil {
cmds = append(cmds, cmd)
}
}
return a, tea.Batch(cmds...)
case page.PageChangeMsg:
return a, a.moveToPage(msg.ID)
case state.SessionSelectedMsg:
a.app.Session = msg a.app.Session = msg
a.app.Messages, _ = a.app.ListMessages(context.Background(), msg.Id) a.app.Messages = messages
return a.updateAllPages(msg) case app.ModelSelectedMsg:
case state.ModelSelectedMsg:
a.app.Provider = &msg.Provider a.app.Provider = &msg.Provider
a.app.Model = &msg.Model a.app.Model = &msg.Model
a.app.Config.Provider = msg.Provider.Id a.app.State.Provider = msg.Provider.Id
a.app.Config.Model = msg.Model.Id a.app.State.Model = msg.Model.Id
a.app.SaveConfig() a.app.SaveState()
return a.updateAllPages(msg) case dialog.ThemeSelectedMsg:
a.app.State.Theme = msg.ThemeName
case dialog.ThemeChangedMsg: a.app.SaveState()
a.app.Config.Theme = msg.ThemeName case toast.ShowToastMsg:
a.app.SaveConfig() tm, cmd := a.toastManager.Update(msg)
a.toastManager = tm
updated, cmd := a.pages[a.currentPage].Update(msg) cmds = append(cmds, cmd)
a.pages[a.currentPage] = updated.(layout.ModelWithView) case toast.DismissToastMsg:
if cmd != nil { tm, cmd := a.toastManager.Update(msg)
cmds = append(cmds, cmd) a.toastManager = tm
}
s, cmd := a.status.Update(msg)
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
a.status = s.(core.StatusComponent)
t := theme.CurrentTheme()
cmds = append(cmds, tea.SetBackgroundColor(t.Background()))
return a, tea.Batch(cmds...)
case tea.KeyMsg:
switch msg.String() {
// give the editor a chance to clear input
case "ctrl+c":
updated, cmd := a.pages[a.currentPage].Update(msg)
a.pages[a.currentPage] = updated.(layout.ModelWithView)
if cmd != nil {
return a, cmd
}
}
// First, check for modal triggers from the command registry
if a.modal == nil {
for _, cmdDef := range a.app.Commands {
if key.Matches(msg, cmdDef.KeyBinding) {
// If a key matches, send an ExecuteCommandMsg to self.
// This unifies keybinding and slash command handling.
return a, util.CmdHandler(commands.ExecuteCommandMsg{Name: cmdDef.Name})
}
}
}
} }
// update status bar // update status bar
s, cmd := a.status.Update(msg) s, cmd := a.status.Update(msg)
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
a.status = s.(core.StatusComponent) a.status = s.(status.StatusComponent)
// update current page // update editor
updated, cmd := a.pages[a.currentPage].Update(msg) u, cmd := a.editor.Update(msg)
a.pages[a.currentPage] = updated.(layout.ModelWithView) a.editor = u.(chat.EditorComponent)
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
// update messages
u, cmd = a.messages.Update(msg)
a.messages = u.(chat.MessagesComponent)
cmds = append(cmds, cmd)
// update modal
if a.modal != nil {
u, cmd := a.modal.Update(msg)
a.modal = u.(layout.Modal)
cmds = append(cmds, cmd)
}
if a.showCompletionDialog {
u, cmd := a.completions.Update(msg)
a.completions = u.(dialog.CompletionDialog)
cmds = append(cmds, cmd)
}
return a, tea.Batch(cmds...) return a, tea.Batch(cmds...)
} }
func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
var cmds []tea.Cmd
if _, ok := a.loadedPages[pageID]; !ok {
cmd := a.pages[pageID].Init()
cmds = append(cmds, cmd)
a.loadedPages[pageID] = true
}
a.previousPage = a.currentPage
a.currentPage = pageID
if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
cmd := sizable.SetSize(a.width, a.height)
cmds = append(cmds, cmd)
}
return tea.Batch(cmds...)
}
func (a appModel) View() string { func (a appModel) View() string {
layoutView := a.layout.View()
editorWidth, _ := a.editorContainer.GetSize()
editorX, editorY := a.editorContainer.GetPosition()
if a.editor.Lines() > 1 {
editorY = editorY - a.editor.Lines() + 1
layoutView = layout.PlaceOverlay(
editorX,
editorY,
a.editor.Content(),
layoutView,
)
}
if a.showCompletionDialog {
a.completions.SetWidth(editorWidth)
overlay := a.completions.View()
layoutView = layout.PlaceOverlay(
editorX,
editorY-lipgloss.Height(overlay)+2,
overlay,
layoutView,
)
}
components := []string{ components := []string{
a.pages[a.currentPage].View(), layoutView,
a.status.View(),
} }
components = append(components, a.status.View())
appView := lipgloss.JoinVertical(lipgloss.Top, components...) appView := lipgloss.JoinVertical(lipgloss.Top, components...)
if a.modal != nil { if a.modal != nil {
appView = a.modal.Render(appView) appView = a.modal.Render(appView)
} }
appView = a.toastManager.RenderOverlay(appView)
return appView return appView
} }
func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) {
cmds := []tea.Cmd{
util.CmdHandler(commands.CommandExecutedMsg(command)),
}
switch command.Name {
case commands.AppHelpCommand:
helpDialog := dialog.NewHelpDialog(a.app.Commands.Sorted())
a.modal = helpDialog
case commands.EditorOpenCommand:
if a.app.IsBusy() {
// status.Warn("Agent is working, please wait...")
return a, nil
}
editor := os.Getenv("EDITOR")
if editor == "" {
return a, toast.NewErrorToast("No EDITOR set, can't open editor")
}
value := a.editor.Value()
updated, cmd := a.editor.Clear()
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
tmpfile, err := os.CreateTemp("", "msg_*.md")
tmpfile.WriteString(value)
if err != nil {
slog.Error("Failed to create temp file", "error", err)
return a, toast.NewErrorToast("Something went wrong, couldn't open editor")
}
tmpfile.Close()
c := exec.Command(editor, tmpfile.Name()) //nolint:gosec
c.Stdin = os.Stdin
c.Stdout = os.Stdout
c.Stderr = os.Stderr
cmd = tea.ExecProcess(c, func(err error) tea.Msg {
if err != nil {
slog.Error("Failed to open editor", "error", err)
return nil
}
content, err := os.ReadFile(tmpfile.Name())
if err != nil {
slog.Error("Failed to read file", "error", err)
return nil
}
if len(content) == 0 {
slog.Warn("Message is empty")
return nil
}
os.Remove(tmpfile.Name())
// attachments := m.attachments
// m.attachments = nil
return app.SendMsg{
Text: string(content),
Attachments: []app.Attachment{}, // attachments,
}
})
cmds = append(cmds, cmd)
case commands.SessionNewCommand:
if a.app.Session.Id == "" {
return a, nil
}
a.app.Session = &client.SessionInfo{}
a.app.Messages = []client.MessageInfo{}
cmds = append(cmds, util.CmdHandler(app.SessionClearedMsg{}))
case commands.SessionListCommand:
sessionDialog := dialog.NewSessionDialog(a.app)
a.modal = sessionDialog
case commands.SessionShareCommand:
if a.app.Session.Id == "" {
return a, nil
}
response, err := a.app.Client.PostSessionShareWithResponse(
context.Background(),
client.PostSessionShareJSONRequestBody{
SessionID: a.app.Session.Id,
},
)
if err != nil {
slog.Error("Failed to share session", "error", err)
return a, toast.NewErrorToast("Failed to share session")
}
if response.JSON200 != nil && response.JSON200.Share != nil {
shareUrl := response.JSON200.Share.Url
cmds = append(cmds, tea.SetClipboard(shareUrl))
cmds = append(cmds, toast.NewSuccessToast("Share URL copied to clipboard!"))
}
case commands.SessionInterruptCommand:
if a.app.Session.Id == "" {
return a, nil
}
a.app.Cancel(context.Background(), a.app.Session.Id)
return a, nil
case commands.SessionCompactCommand:
if a.app.Session.Id == "" {
return a, nil
}
// TODO: block until compaction is complete
a.app.CompactSession(context.Background())
case commands.ToolDetailsCommand:
message := "Tool details are now visible"
if a.messages.ToolDetailsVisible() {
message = "Tool details are now hidden"
}
cmds = append(cmds, util.CmdHandler(chat.ToggleToolDetailsMsg{}))
cmds = append(cmds, toast.NewInfoToast(message))
case commands.ModelListCommand:
modelDialog := dialog.NewModelDialog(a.app)
a.modal = modelDialog
case commands.ThemeListCommand:
themeDialog := dialog.NewThemeDialog()
a.modal = themeDialog
case commands.ProjectInitCommand:
cmds = append(cmds, a.app.InitializeProject(context.Background()))
case commands.InputClearCommand:
if a.editor.Value() == "" {
return a, nil
}
updated, cmd := a.editor.Clear()
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
case commands.InputPasteCommand:
updated, cmd := a.editor.Paste()
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
case commands.InputSubmitCommand:
updated, cmd := a.editor.Submit()
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
case commands.InputNewlineCommand:
updated, cmd := a.editor.Newline()
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
case commands.HistoryPreviousCommand:
if a.showCompletionDialog {
return a, nil
}
updated, cmd := a.editor.Previous()
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
case commands.HistoryNextCommand:
if a.showCompletionDialog {
return a, nil
}
updated, cmd := a.editor.Next()
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
case commands.MessagesFirstCommand:
updated, cmd := a.messages.First()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case commands.MessagesLastCommand:
updated, cmd := a.messages.Last()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case commands.MessagesPageUpCommand:
updated, cmd := a.messages.PageUp()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case commands.MessagesPageDownCommand:
updated, cmd := a.messages.PageDown()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case commands.MessagesHalfPageUpCommand:
updated, cmd := a.messages.HalfPageUp()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case commands.MessagesHalfPageDownCommand:
updated, cmd := a.messages.HalfPageDown()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case commands.AppExitCommand:
return a, tea.Quit
}
return a, tea.Batch(cmds...)
}
func (a appModel) updateCompletions(msg tea.Msg) (tea.Model, tea.Cmd) {
currentInput := a.editor.Value()
if currentInput != "" {
provider := a.completionManager.GetProvider(currentInput)
a.completions.SetProvider(provider)
}
return a.completions.Update(msg)
}
func NewModel(app *app.App) tea.Model { func NewModel(app *app.App) tea.Model {
startPage := page.ChatPage completionManager := completions.NewCompletionManager(app)
initialProvider := completionManager.DefaultProvider()
messages := chat.NewMessagesComponent(app)
editor := chat.NewEditorComponent(app)
completions := dialog.NewCompletionDialogComponent(initialProvider)
editorContainer := layout.NewContainer(
editor,
layout.WithMaxWidth(layout.Current.Container.Width),
layout.WithAlignCenter(),
)
messagesContainer := layout.NewContainer(messages)
var leaderBinding *key.Binding
if (*app.Config.Keybinds).Leader != nil {
binding := key.NewBinding(key.WithKeys(*app.Config.Keybinds.Leader))
leaderBinding = &binding
}
model := &appModel{ model := &appModel{
currentPage: startPage, status: status.NewStatusCmp(app),
loadedPages: make(map[page.PageID]bool), app: app,
status: core.NewStatusCmp(app), editor: editor,
app: app, messages: messages,
pages: map[page.PageID]layout.ModelWithView{ completions: completions,
page.ChatPage: page.NewChatPage(app), completionManager: completionManager,
}, leaderBinding: leaderBinding,
isLeaderSequence: false,
showCompletionDialog: false,
editorContainer: editorContainer,
toastManager: toast.NewToastManager(),
layout: layout.NewFlexLayout(
[]tea.ViewModel{messagesContainer, editorContainer},
layout.WithDirection(layout.FlexDirectionVertical),
layout.WithSizes(
layout.FlexChildSizeGrow,
layout.FlexChildSizeFixed(5),
),
),
} }
return model return model

Some files were not shown because too many files have changed in this diff Show More