Compare commits

..
128 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
137 changed files with 8339 additions and 3232 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
+11 -91
View File
@@ -7,19 +7,17 @@
</picture> </picture>
</a> </a>
</p> </p>
<p align="center">AI coding agent, built for the terminal.</p>
<p align="center"> <p align="center">
<a href="https://opencode.ai/docs"><img alt="View docs" src="https://img.shields.io/badge/view-docs-blue?style=flat-square" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a> <a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://github.com/sst/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/sst/opencode/publish.yml?style=flat-square&branch=dev" /></a> <a href="https://github.com/sst/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/sst/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p> </p>
[![opencode Terminal UI](packages/web/src/assets/themes/opencode.png)](https://opencode.ai)
--- ---
AI coding agent, built for the terminal.
**Note:** Version 0.1.x is a full rewrite, and we do not have proper documentation for it yet. Should have this out week of June 17th 2025.
[![opencode Terminal UI](screenshot.png)](https://opencode.ai)
### Installation ### Installation
```bash ```bash
@@ -34,86 +32,9 @@ paru -S opencode-bin # Arch Linux
> **Note:** Remove versions older than 0.1.x before installing > **Note:** Remove versions older than 0.1.x before installing
### Providers ### Documentation
The recommended approach is to sign up for Claude Pro or Max, run `opencode auth login`, and select Anthropic. It's the most cost-effective way to use opencode. For more info on how to configure opencode [**head over to our docs**](https://opencode.ai/docs).
opencode is powered by the provider list at [Models.dev](https://models.dev), so you can use `opencode auth login` to configure API keys for any provider you'd like to use. This is stored in `~/.local/share/opencode/auth.json`.
```bash
$ opencode auth login
┌ Add credential
◆ Select provider
│ ● Anthropic (recommended)
│ ○ OpenAI
│ ○ Google
│ ○ Amazon Bedrock
│ ○ Azure
│ ○ DeepSeek
│ ○ Groq
│ ...
```
The Models.dev dataset is also used to detect common environment variables like `OPENAI_API_KEY` to autoload that provider.
If there are additional providers you want to use you can submit a PR to the [Models.dev repo](https://github.com/sst/models.dev). If configuring just for yourself check out the Config section below.
### Project Config
Project configuration is optional. You can place an `opencode.json` file in the root of your repo, and it'll be loaded.
```json title="opencode.json"
{
"$schema": "http://opencode.ai/config.json"
}
```
#### MCP
```json title="opencode.json"
{
"$schema": "http://opencode.ai/config.json",
"mcp": {
"localmcp": {
"type": "local",
"command": ["bun", "x", "my-mcp-command"],
"environment": {
"MY_ENV_VAR": "my_env_var_value"
}
},
"remotemcp": {
"type": "remote",
"url": "https://my-mcp-server.com"
}
}
}
```
#### Providers
You can use opencode with any provider listed at [here](https://ai-sdk.dev/providers/ai-sdk-providers). Be sure to specify the npm package to use to load the provider.
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"ollama": {
"npm": "@ai-sdk/openai-compatible",
"options": {
"baseURL": "http://localhost:11434/v1"
},
"models": {
"llama2": {
"name": "llama2"
}
}
}
}
}
```
### Contributing ### Contributing
@@ -122,12 +43,11 @@ To run opencode locally you need.
- Bun - Bun
- Golang 1.24.x - Golang 1.24.x
To run. And run.
```bash ```bash
$ bun install $ bun install
$ cd packages/opencode $ bun run packages/opencode/src/index.ts
$ bun run src/index.ts
``` ```
### FAQ ### FAQ
@@ -143,9 +63,7 @@ OpenRouter is not in the Models.dev database yet, but you can configure it manua
"openrouter": { "openrouter": {
"npm": "@openrouter/ai-sdk-provider", "npm": "@openrouter/ai-sdk-provider",
"name": "OpenRouter", "name": "OpenRouter",
"options": { "options": {},
"apiKey": "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
},
"models": { "models": {
"anthropic/claude-3.5-sonnet": { "anthropic/claude-3.5-sonnet": {
"name": "Claude 3.5 Sonnet" "name": "Claude 3.5 Sonnet"
@@ -156,6 +74,8 @@ OpenRouter is not in the Models.dev database yet, but you can configure it manua
} }
``` ```
And then to configure an api key you can do `opencode auth login` and select "Other -> 'openrouter'"
#### How is this different than Claude Code? #### How is this different than Claude Code?
It's very similar to Claude Code in terms of capability. Here are the key differences: It's very similar to Claude Code in terms of capability. Here are the key differences:
+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 -2
View File
@@ -23,8 +23,9 @@ export const api = new sst.cloudflare.Worker("Api", {
}, },
]) ])
args.migrations = { args.migrations = {
oldTag: "v1", // Note: when releasing the next tag, make sure all stages use tag v2
newTag: "v1", oldTag: $app.stage === "production" ? "" : "v1",
newTag: $app.stage === "production" ? "" : "v1",
//newSqliteClasses: ["SyncServer"], //newSqliteClasses: ["SyncServer"],
} }
}, },
+1 -3
View File
@@ -1,5 +1,3 @@
{ {
"$schema": "https://opencode.ai/config.json", "$schema": "https://opencode.ai/config.json"
"mcp": {},
"provider": {}
} }
+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())
},
)
}, },
}) })
+3 -68
View File
@@ -1,15 +1,14 @@
import type { Argv } from "yargs" import type { Argv } from "yargs"
import { UI } from "../ui" import { UI } from "../ui"
import { VERSION } from "../version"
import * as prompts from "@clack/prompts" import * as prompts from "@clack/prompts"
import { Installation } from "../../installation" import { Installation } from "../../installation"
export const UpgradeCommand = { export const UpgradeCommand = {
command: "upgrade [target]", command: "upgrade [target]",
describe: "Upgrade opencode to the latest version or a specific version", describe: "upgrade opencode to the latest version or a specific version",
builder: (yargs: Argv) => { builder: (yargs: Argv) => {
return yargs.positional("target", { return yargs.positional("target", {
describe: "Specific version to upgrade to (e.g., '0.1.48' or 'v0.1.48')", describe: "specific version to upgrade to (e.g., '0.1.48' or 'v0.1.48')",
type: "string", type: "string",
}) })
}, },
@@ -27,7 +26,7 @@ export const UpgradeCommand = {
return return
} }
const target = args.target ?? (await Installation.latest()) const target = args.target ?? (await Installation.latest())
prompts.log.info(`From ${VERSION}${target}`) prompts.log.info(`From ${Installation.VERSION}${target}`)
const spinner = prompts.spinner() const spinner = prompts.spinner()
spinner.start("Upgrading...") spinner.start("Upgrading...")
const err = await Installation.upgrade(method, target).catch((err) => err) const err = await Installation.upgrade(method, target).catch((err) => err)
@@ -41,69 +40,5 @@ export const UpgradeCommand = {
} }
spinner.stop("Upgrade complete") spinner.stop("Upgrade complete")
prompts.outro("Done") prompts.outro("Done")
return
/*
if (!process.execPath.includes(path.join(".opencode", "bin")) && false) {
return
}
const release = args.target
? await specific(args.target).catch(() => {})
: await latest().catch(() => {})
if (!release) {
prompts.log.error("Failed to fetch release information")
prompts.outro("Done")
return
}
const target = release.tag_name
if (VERSION !== "dev" && compare(VERSION, target) >= 0) {
prompts.log.success(`Already up to date`)
prompts.outro("Done")
return
}
prompts.log.info(`From ${VERSION} → ${target}`)
const name = asset()
const found = release.assets.find((a) => a.name === name)
if (!found) {
prompts.log.error(`No binary found for platform: ${name}`)
prompts.outro("Done")
return
}
const spinner = prompts.spinner()
spinner.start("Downloading update...")
const downloadPath = await download(found.browser_download_url).catch(
() => {},
)
if (!downloadPath) {
spinner.stop("Download failed")
prompts.log.error("Download failed")
prompts.outro("Done")
return
}
spinner.stop("Download complete")
const renamed = await fs
.rename(downloadPath, process.execPath)
.catch(() => {})
if (renamed === undefined) {
prompts.log.error("Install failed")
await fs.unlink(downloadPath).catch(() => {})
prompts.outro("Done")
return
}
prompts.log.success(`Successfully upgraded to ${target}`)
prompts.outro("Done")
*/
}, },
} }
+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,
})) {
}
}
}
-24
View File
@@ -1,24 +0,0 @@
import { z } from "zod"
import { Global } from "."
import { lazy } from "../util/lazy"
import path from "path"
export namespace GlobalConfig {
export const Info = z.object({
autoupdate: z.boolean().optional(),
provider: z.string().optional(),
model: z.string().optional(),
})
export type Info = z.infer<typeof Info>
export const get = lazy(async () => {
const toml = await import(path.join(Global.Path.config, "config"), {
with: {
type: "toml",
},
})
.then((mod) => mod.default)
.catch(() => ({}))
return Info.parse(toml)
})
}
+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 }),
])
+84 -61
View File
@@ -9,28 +9,21 @@ import yargs from "yargs"
import { hideBin } from "yargs/helpers" import { hideBin } from "yargs/helpers"
import { RunCommand } from "./cli/cmd/run" import { RunCommand } from "./cli/cmd/run"
import { GenerateCommand } from "./cli/cmd/generate" import { GenerateCommand } from "./cli/cmd/generate"
import { VERSION } from "./cli/version"
import { ScrapCommand } from "./cli/cmd/scrap" import { ScrapCommand } from "./cli/cmd/scrap"
import { Log } from "./util/log" import { Log } from "./util/log"
import { AuthCommand, AuthLoginCommand } from "./cli/cmd/auth" import { AuthCommand, AuthLoginCommand } from "./cli/cmd/auth"
import { UpgradeCommand } from "./cli/cmd/upgrade" import { UpgradeCommand } from "./cli/cmd/upgrade"
import { Provider } from "./provider/provider" import { Provider } from "./provider/provider"
import { UI } from "./cli/ui" import { UI } from "./cli/ui"
import { GlobalConfig } from "./global/config"
import { Installation } from "./installation" import { Installation } from "./installation"
;(async () => { import { Bus } from "./bus"
if (Installation.VERSION === "dev") return import { Config } from "./config/config"
const config = await GlobalConfig.get() import { NamedError } from "./util/error"
if (config.autoupdate === false) return import { FormatError } from "./cli/error"
const latest = await Installation.latest()
if (Installation.VERSION === latest) return
const method = await Installation.method()
await Installation.upgrade(method, latest).catch(() => {})
})()
const cli = yargs(hideBin(process.argv)) const cli = yargs(hideBin(process.argv))
.scriptName("opencode") .scriptName("opencode")
.version(VERSION) .version(Installation.VERSION)
.option("print-logs", { .option("print-logs", {
describe: "Print logs to stderr", describe: "Print logs to stderr",
type: "boolean", type: "boolean",
@@ -38,14 +31,14 @@ const cli = yargs(hideBin(process.argv))
.middleware(async () => { .middleware(async () => {
await Log.init({ print: process.argv.includes("--print-logs") }) await Log.init({ print: process.argv.includes("--print-logs") })
Log.Default.info("opencode", { Log.Default.info("opencode", {
version: VERSION, version: Installation.VERSION,
args: process.argv.slice(2), args: process.argv.slice(2),
}) })
}) })
.usage("\n" + UI.logo()) .usage("\n" + UI.logo())
.command({ .command({
command: "$0 [project]", command: "$0 [project]",
describe: "Start opencode TUI", describe: "start opencode TUI",
builder: (yargs) => builder: (yargs) =>
yargs.positional("project", { yargs.positional("project", {
type: "string", type: "string",
@@ -55,52 +48,65 @@ const cli = yargs(hideBin(process.argv))
while (true) { while (true) {
const cwd = args.project ? path.resolve(args.project) : process.cwd() const cwd = args.project ? path.resolve(args.project) : process.cwd()
process.chdir(cwd) process.chdir(cwd)
const result = await App.provide( const result = await App.provide({ cwd }, async (app) => {
{ cwd, version: VERSION }, const providers = await Provider.list()
async (app) => { if (Object.keys(providers).length === 0) {
const providers = await Provider.list() return "needs_provider"
if (Object.keys(providers).length === 0) { }
return "needs_provider"
await Share.init()
const server = Server.listen()
let cmd = ["go", "run", "./main.go"]
let cwd = new URL("../../tui/cmd/opencode", import.meta.url).pathname
if (Bun.embeddedFiles.length > 0) {
const blob = Bun.embeddedFiles[0] as File
const binary = path.join(Global.Path.cache, "tui", blob.name)
const file = Bun.file(binary)
if (!(await file.exists())) {
await Bun.write(file, blob, { mode: 0o755 })
await fs.chmod(binary, 0o755)
} }
cwd = process.cwd()
cmd = [binary]
}
const proc = Bun.spawn({
cmd: [...cmd, ...process.argv.slice(2)],
cwd,
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
env: {
...process.env,
OPENCODE_SERVER: server.url.toString(),
OPENCODE_APP_INFO: JSON.stringify(app),
},
onExit: () => {
server.stop()
},
})
await Share.init() ;(async () => {
const server = Server.listen() if (Installation.VERSION === "dev") return
if (Installation.isSnapshot()) return
const config = await Config.global()
if (config.autoupdate === false) return
const latest = await Installation.latest()
if (Installation.VERSION === latest) return
const method = await Installation.method()
if (method === "unknown") return
await Installation.upgrade(method, latest)
.then(() => {
Bus.publish(Installation.Event.Updated, { version: latest })
})
.catch(() => {})
})()
let cmd = ["go", "run", "./main.go"] await proc.exited
let cwd = new URL("../../tui/cmd/opencode", import.meta.url) server.stop()
.pathname
if (Bun.embeddedFiles.length > 0) {
const blob = Bun.embeddedFiles[0] as File
const binary = path.join(Global.Path.cache, "tui", blob.name)
const file = Bun.file(binary)
if (!(await file.exists())) {
await Bun.write(file, blob, { mode: 0o755 })
await fs.chmod(binary, 0o755)
}
cwd = process.cwd()
cmd = [binary]
}
const proc = Bun.spawn({
cmd: [...cmd, ...process.argv.slice(2)],
cwd,
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
env: {
...process.env,
OPENCODE_SERVER: server.url.toString(),
OPENCODE_APP_INFO: JSON.stringify(app),
},
onExit: () => {
server.stop()
},
})
await proc.exited
await server.stop()
return "done" return "done"
}, })
)
if (result === "done") break if (result === "done") break
if (result === "needs_provider") { if (result === "needs_provider") {
UI.empty() UI.empty()
@@ -116,21 +122,38 @@ const cli = yargs(hideBin(process.argv))
.command(ScrapCommand) .command(ScrapCommand)
.command(AuthCommand) .command(AuthCommand)
.command(UpgradeCommand) .command(UpgradeCommand)
.fail((msg, err) => { .fail((msg) => {
if ( if (
msg.startsWith("Unknown argument") || msg.startsWith("Unknown argument") ||
msg.startsWith("Not enough non-option arguments") msg.startsWith("Not enough non-option arguments")
) { ) {
cli.showHelp("log") cli.showHelp("log")
} }
Log.Default.error(msg, {
err,
})
}) })
.strict() .strict()
try { try {
await cli.parse() await cli.parse()
} catch (e) { } catch (e) {
Log.Default.error(e) const data: Record<string, any> = {}
if (e instanceof NamedError) {
const obj = e.toObject()
Object.assign(data, {
...obj.data,
})
}
if (e instanceof Error) {
Object.assign(data, {
name: e.name,
message: e.message,
cause: e.cause?.toString(),
})
}
Log.Default.error("fatal", data)
const formatted = FormatError(e)
if (formatted) UI.error(formatted)
if (!formatted)
UI.error(
"Unexpected error, check log file at " + Log.file() + " for more details",
)
} }
+23 -1
View File
@@ -2,10 +2,24 @@ import path from "path"
import { $ } from "bun" import { $ } from "bun"
import { z } from "zod" import { z } from "zod"
import { NamedError } from "../util/error" import { NamedError } from "../util/error"
import { Bus } from "../bus"
declare global {
const OPENCODE_VERSION: string
}
export namespace Installation { export namespace Installation {
export type Method = Awaited<ReturnType<typeof method>> export type Method = Awaited<ReturnType<typeof method>>
export const Event = {
Updated: Bus.event(
"installation.updated",
z.object({
version: z.string(),
}),
),
}
export const Info = z export const Info = z
.object({ .object({
version: z.string(), version: z.string(),
@@ -23,6 +37,14 @@ export namespace Installation {
} }
} }
export function isSnapshot() {
return VERSION.startsWith("0.0.0")
}
export function isDev() {
return VERSION === "dev"
}
export async function method() { export async function method() {
if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl" if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl"
const exec = process.execPath.toLowerCase() const exec = process.execPath.toLowerCase()
@@ -99,6 +121,6 @@ export namespace Installation {
export async function latest() { export async function latest() {
return fetch("https://api.github.com/repos/sst/opencode/releases/latest") return fetch("https://api.github.com/repos/sst/opencode/releases/latest")
.then((res) => res.json()) .then((res) => res.json())
.then((data) => data.tag_name.slice(1)) .then((data) => data.tag_name.slice(1) as string)
} }
} }
+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
}
}
+20 -1
View File
@@ -12,10 +12,10 @@ import { App } from "../app/app"
import { Global } from "../global" import { Global } from "../global"
import { mapValues } from "remeda" import { mapValues } from "remeda"
import { NamedError } from "../util/error" import { NamedError } from "../util/error"
import { Fzf } from "../external/fzf"
import { ModelsDev } from "../provider/models" import { ModelsDev } from "../provider/models"
import { Ripgrep } from "../external/ripgrep" import { Ripgrep } from "../external/ripgrep"
import { Installation } from "../installation" import { Installation } from "../installation"
import { Config } from "../config/config"
const ERRORS = { const ERRORS = {
400: { 400: {
@@ -141,6 +141,25 @@ export namespace Server {
return c.json(App.info()) return c.json(App.info())
}, },
) )
.post(
"/config_get",
describeRoute({
description: "Get config info",
responses: {
200: {
description: "Get config info",
content: {
"application/json": {
schema: resolver(Config.Info),
},
},
},
},
}),
async (c) => {
return c.json(await Config.get())
},
)
.post( .post(
"/app_initialize", "/app_initialize",
describeRoute({ describeRoute({
+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),
} }

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