Compare commits

...

131 Commits

Author SHA1 Message Date
Dax Raad 1252b65166 stop loading models.dev format from global config 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
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 Raad 3423415e49 docs: improve keybinds configuration format in README
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 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 2025-06-18 14:03:16 -04:00
Dax Raad f99904bc1c track version on session info 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 2025-06-18 11:06:16 -04:00
Dax Raad b8bbee4718 fix issue with provider cache 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 2025-06-18 10:35:41 -04:00
Dax Raad 5110fbdaf9 fix issue when running opencode in empty directory 2025-06-18 10:29:09 -04:00
Dax Raad e6ddb474fc ignore: sync 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 2025-06-17 20:51:39 -04:00
Jay V d1f9311931 ignore: share page polish 2025-06-17 20:26:12 -04:00
Dax Raad 1c58023df9 improve anthropic oauth token caching and authentication handling
🤖 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 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 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 2025-06-17 11:27:07 -04:00
Dax Raad 57b3051024 fix agent getting caught in summary loop 2025-06-17 10:50:03 -04:00
Dax Raad ae5cf3cc23 ci: fix 2025-06-17 10:38:01 -04:00
Dax Raad 68e1b3c46c Fix TypeScript compilation errors and consolidate version handling
🤖 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 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 Raad 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 2025-06-17 01:18:32 -04:00
Dax Raad 3fe163416d autoupgrade 2025-06-17 01:05:05 -04:00
Dax d054f88130 Improve upgrade command with installation method detection (#158) 2025-06-17 00:07:17 -04:00
Jay b929b4f4b9 docs: Update README.md 2025-06-16 21:01:38 -04:00
Jay V 4c0c83b02d docs: readme 2025-06-16 20:10:19 -04:00
adamdottv d6d45bdc63 feat: share and init commands 2025-06-16 15:58:52 -05:00
Dax Raad 13a83721b0 ci: fixed ci issue 2025-06-16 16:58:25 -04:00
Dax Raad f0edffbae9 docs: readme 2025-06-16 16:53:43 -04:00
Dax Raad 8131bee49a ignore: logs 2025-06-16 16:02:45 -04:00
Dax Raad b5f44ae13f docs: update readme 2025-06-16 15:42:35 -04:00
Miles Till 0d23f2a7fd fix: incorrect lipgloss version (#131) 2025-06-16 14:35:46 -05:00
Dax Raad ac096d84ad remove windows builds 2025-06-16 15:11:14 -04:00
Dax Raad fcaf0e6dbf opencode auth login: validation on provider id and better error messages 2025-06-16 15:09:49 -04:00
Dax Raad 19e259d90d docs: readme 2025-06-16 15:04:32 -04:00
Dax Raad 2c9fd1e776 BREAKING CHANGE: the config structure has changed, custom providers have an npm field now to specify which npm package to load. see examples in README.md 2025-06-16 15:02:25 -04:00
Dax Raad 63996c4189 limit to 4 system prompts cached 2025-06-16 14:51:59 -04:00
adamdottv c7bb7ce4de fix: include cached tokens in tui 2025-06-16 12:59:38 -05:00
adamdottv c8eb1b24c3 feat: believe it or not, even faster tui init 2025-06-16 12:34:34 -05:00
adamdottv b9f894f1e9 feat: even faster tui init 2025-06-16 12:24:18 -05:00
adamdottv 7c0d10a4ce feat: faster tui init 2025-06-16 11:54:55 -05:00
Dax Raad 06af406146 properly track cache token counts 2025-06-16 12:43:22 -04:00
Dax Raad 0e3458b112 fix cache-control 2025-06-16 12:07:01 -04:00
adamdottv 2d15c683e0 fix: default provider and model 2025-06-16 10:51:01 -05:00
adamdottv 3c94d26570 chore: remove status service 2025-06-16 10:45:19 -05:00
Dax Raad 1a553e525f enable prompt caching for anthropic 2025-06-16 11:41:54 -04:00
adamdottv 3c4e966216 fix: spinner background color 2025-06-16 10:03:44 -05:00
Dax Raad 0721620ed8 docs: readme 2025-06-16 10:44:48 -04:00
Thomas Meire 9fc6734f32 ignore: remove log files and add them to gitignore (#138) 2025-06-16 09:30:07 -04:00
Jacob e1733a423d fix: typo and literal wording in packages/opencode/AGENTS.md (#134) 2025-06-16 08:18:29 -05:00
Dax Raad d42e3db7e0 docs: update README 2025-06-15 21:43:20 -04:00
Dax Raad cdb26f6d83 docs: readme 2025-06-15 21:39:02 -04:00
Dax Raad fe05edaa79 enhance ripgrep files function with query filtering and limit support
🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-15 21:26:32 -04:00
Dax Raad 7d174767b0 first pass making system prompt less fast 2025-06-15 20:25:04 -04:00
George Potoshin c5eefd1752 Fix: Improve Help UI Readability (Issue #99) (#117) 2025-06-15 18:38:44 -05:00
adamdottv 77a6b3bdd6 fix: background color rendering issues 2025-06-15 15:07:05 -05:00
Pierre B. 7effff56c0 fix: spelling, grammar and typos (#121) 2025-06-15 14:23:18 -05:00
Dax Raad e30fba0d3c Improve LSP server initialization with timeout handling and skip failed servers
🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-15 13:52:57 -04:00
Dax Raad 7fbb2ca9a6 ignore: add timer log helper 2025-06-15 13:33:24 -04:00
Dax Raad 230d0a1510 fix postinstall script for node 2025-06-15 13:11:11 -04:00
Pierre B. 46ff2c0ae0 chore: ignore intellij, vscode (#122) 2025-06-15 10:40:34 -05:00
adamdottv b8a89dab0f fix: background color rendering issues 2025-06-15 05:57:15 -05:00
szymon 7351e12886 remove .DS_Store (#112) 2025-06-15 05:34:46 -05:00
Dax Raad 38879dee2d beginning of upgrade command 2025-06-14 22:05:41 -04:00
Dax Raad c4ff8dd205 revert ctrl+d - conflicts with page down 2025-06-14 21:29:02 -04:00
Dax Raad 0e035b3115 fix aborting issue 2025-06-14 21:23:57 -04:00
Dax Raad b855511d9a fix issue with follow up tool calls and cancelation 2025-06-14 21:03:44 -04:00
Dax Raad 783faf554d fix issue continuing session after aborted 2025-06-14 20:24:50 -04:00
nitishxyz bfd4269d7d Add Ayu dark theme (#109) 2025-06-14 20:08:31 -04:00
Berr 25f78b053b fix: improve browser opening error handling in AuthLoginCommand (#111) 2025-06-14 20:07:41 -04:00
Dax Raad 87f260ee17 sync 2025-06-14 20:04:41 -04:00
Dax Raad 12931a869d ci: ignore commits 2025-06-14 18:59:05 -04:00
Dax Raad f759e1804d docs: typo 2025-06-14 18:58:27 -04:00
Rohan Godha c9b4564d36 tui: fix help dialog background (#110) 2025-06-14 18:57:15 -04:00
Conor O'Brien d097c546db nit: update commands displayed on home to match commands available (#108) 2025-06-14 18:56:44 -04:00
Gal Schlezinger adb54521b4 make ctrl+d quit too, just like shells (#105) 2025-06-14 18:56:34 -04:00
Dax Raad 2ea0399aa7 docs: use ollama example 2025-06-14 18:55:39 -04:00
Dax Raad fa1266263d downgrade to ai sdk v4.x 2025-06-14 18:44:08 -04:00
Gal Schlezinger fe109c921e add focus tracking for tui so cursor will hide when not in focus (#103) 2025-06-14 14:53:43 -05:00
Dax Raad 37bb8895fe docs: readme 2025-06-14 14:52:02 -04:00
Dax Raad 89b95be4de docs: provider config 2025-06-14 14:45:59 -04:00
Dax Raad eaf295bac7 docs: faq 2025-06-14 14:39:13 -04:00
Mantena Rama Raju 27d3cec477 typo (#94) 2025-06-14 14:36:29 -04:00
Dax Raad 574d494c3c Enhance provider system with dynamic package resolution and improved logging
- Add npm registry lookup for AI SDK packages with fallback support
- Enhance error logging with cause information
- Add timing deltas to log output for performance monitoring

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

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-14 14:35:33 -04:00
Albert Ilagan 0239761f31 tui: remove quit dialog (#97) 2025-06-14 12:47:34 -05:00
Dax Raad a53f9165e9 doc: remove dev script 2025-06-14 13:05:23 -04:00
Dax Raad ffc231bd8b docs: contributing 2025-06-14 12:45:26 -04:00
Dax Raad 3cf4ef56fb sync 2025-06-14 12:32:41 -04:00
Dax Raad c738e26438 docs: mcp 2025-06-14 12:25:26 -04:00
Dax Raad 9c6aa82ac1 docs: config schema 2025-06-14 12:22:07 -04:00
Dax Raad ef74d97491 ci: update publish script 2025-06-14 12:13:59 -04:00
Dax Raad af892e5432 docs: readme 2025-06-14 12:13:46 -04:00
Dax Raad d7aca6230d naming fixes 2025-06-14 01:54:28 -04:00
111 changed files with 5823 additions and 4030 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ name: deploy
on:
push:
branches:
- dontlook
- dev
workflow_dispatch:
concurrency: ${{ github.workflow }}-${{ github.ref }}
+2
View File
@@ -3,3 +3,5 @@ node_modules
.opencode
.sst
.env
.idea
.vscode
+199 -21
View File
@@ -1,40 +1,218 @@
[![OpenCode Terminal UI](screenshot.png)](https://github.com/sst/opencode)
<p align="center">
<a href="https://opencode.ai">
<picture>
<source srcset="packages/web/src/assets/logo-dark.svg" media="(prefers-color-scheme: dark)">
<source srcset="packages/web/src/assets/logo-light.svg" media="(prefers-color-scheme: light)">
<img src="packages/web/src/assets/logo-light.svg" alt="opencode logo">
</picture>
</a>
</p>
<p align="center">
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://github.com/sst/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/sst/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
---
AI coding agent, built for the terminal.
Note: version 0.1.x is a full rewrite and we do not have proper documentation for it yet. Should have this out week of June 17th 2025
**Note:** Version 0.1.x is a full rewrite, and we do not have proper documentation for it yet. Should have this out week of June 17th 2025.
## Installation
[![opencode Terminal UI](screenshot.png)](https://opencode.ai)
If you have a previous version of opencode < 0.1.x installed you might have to remove it first.
### Installation
### Curl
```
```bash
# YOLO
curl -fsSL https://opencode.ai/install | bash
# Package managers
npm i -g opencode-ai@latest # or bun/pnpm/yarn
brew install sst/tap/opencode # macOS
paru -S opencode-bin # Arch Linux
```
### NPM
> **Note:** Remove versions older than 0.1.x before installing
```
npm i -g opencode-ai@latest
bun i -g opencode-ai@latest
pnpm i -g opencode-ai@latest
yarn global add opencode-ai@latest
### Providers
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.
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
│ ...
```
### Brew
The Models.dev dataset is also used to detect common environment variables like `OPENAI_API_KEY` to autoload that provider.
```
brew install sst/tap/opencode
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.
### Config
Config is optional and can be placed in the root of your repo or globally in `~/.config/opencode/config.json`. It can be checked in and shared with your team.
```json title="opencode.json"
{
"$schema": "http://opencode.ai/config.json"
"theme": "opencode",
"model": "anthropic/claude-sonnet-4-20250514" // format is provider/model
"autoshare": false,
"autoupdate": true,
}
```
### AUR
#### Keybinds
```
paru -S opencode-bin
You can configure custom keybinds, the values listed below are the defaults.
```json title="opencode.json"
{
"$schema": "http://opencode.ai/config.json",
"keybinds": {
"leader": "ctrl+x",
"help": "<leader>h",
"editor_open": "<leader>e",
"session_new": "<leader>n",
"session_list": "<leader>l",
"session_share": "<leader>s",
"session_interrupt": "esc",
"session_compact": "<leader>c",
"tool_details": "<leader>d",
"model_list": "<leader>m",
"theme_list": "<leader>t",
"project_init": "<leader>i",
"input_clear": "ctrl+c",
"input_paste": "ctrl+v",
"input_submit": "enter",
"input_newline": "shift+enter",
"history_previous": "up",
"history_next": "down",
"messages_page_up": "pgup",
"messages_page_down": "pgdown",
"messages_half_page_up": "ctrl+alt+u",
"messages_half_page_down": "ctrl+alt+d",
"messages_previous": "ctrl+alt+k",
"messages_next": "ctrl+alt+j",
"messages_first": "ctrl+g",
"messages_last": "ctrl+alt+g",
"app_exit": "ctrl+c,<leader>q"
}
}
```
## Usage
#### MCP
We are working on proper keybinds - right now it's the various function keys press F1 to see them
```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. Remember most popular providers are preloaded from [models.dev](https://models.dev)
```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": {}
}
}
}
}
```
### Contributing
To run opencode locally you need.
- Bun
- Golang 1.24.x
To run.
```bash
$ bun install
$ bun run packages/opencode/src/index.ts
```
### FAQ
#### How do I use this with OpenRouter?
OpenRouter is not in the Models.dev database yet, but you can configure it manually.
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"openrouter": {
"npm": "@openrouter/ai-sdk-provider",
"name": "OpenRouter",
"options": {},
"models": {
"anthropic/claude-3.5-sonnet": {
"name": "Claude 3.5 Sonnet"
}
}
}
}
}
```
And then to configure an api key you can do `opencode auth login` and select "Other -> 'openrouter'"
#### How is this different than Claude Code?
It's very similar to Claude Code in terms of capability. Here are the key differences:
- 100% open source
- Not coupled to any provider. Although Anthropic is recommended, opencode can be used with OpenAI, Google or even local models. As models evolve the gaps between them will close and pricing will drop so being provider agnostic is important.
- A focus on TUI. opencode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal.
- A client/server architecture. This for example can allow opencode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients.
#### What about Windows support?
There are some minor problems blocking opencode from working on windows. We are working on on them now. You'll need to use WSL for now.
#### What's the other repo?
The other confusingly named repo has no relation to this one. You can [read the story behind it here](https://x.com/thdxr/status/1933561254481666466).
---
**Join our community** [YouTube](https://www.youtube.com/c/sst-dev) | [X.com](https://x.com/SST_dev)
+33 -7
View File
@@ -45,11 +45,13 @@
"zod-openapi": "4.2.4",
},
"devDependencies": {
"@ai-sdk/anthropic": "1.2.12",
"@tsconfig/bun": "1.0.7",
"@types/bun": "latest",
"@types/turndown": "5.0.5",
"@types/yargs": "17.0.33",
"typescript": "catalog:",
"zod-to-json-schema": "3.24.5",
},
},
"packages/web": {
@@ -73,10 +75,11 @@
"sharp": "0.32.5",
"shiki": "3.4.2",
"solid-js": "1.9.7",
"toolbeam-docs-theme": "0.2.4",
"toolbeam-docs-theme": "0.3.0",
},
"devDependencies": {
"@types/node": "catalog:",
"opencode": "workspace:*",
"typescript": "catalog:",
},
},
@@ -85,21 +88,28 @@
"sharp",
"esbuild",
],
"patchedDependencies": {
"ai@4.3.16": "patches/ai@4.3.16.patch",
},
"overrides": {
"zod": "3.24.2",
},
"catalog": {
"@types/node": "22.13.9",
"ai": "5.0.0-alpha.7",
"ai": "4.3.16",
"typescript": "5.8.2",
"zod": "3.24.2",
},
"packages": {
"@ai-sdk/gateway": ["@ai-sdk/gateway@1.0.0-alpha.7", "", { "dependencies": { "@ai-sdk/provider": "2.0.0-alpha.7", "@ai-sdk/provider-utils": "3.0.0-alpha.7" }, "peerDependencies": { "zod": "^3.24.0" } }, "sha512-gz1V165eiJnQIexfLyKm11vimrmQ3zdcJhPpjeLFmDU9wrvZwLuklfZ0WgfYSb+EjiP1cKypwt6JSGvWkfKIAQ=="],
"@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@2.0.0-alpha.7", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-lhdrARU3SSmt5p/GNNK7VhazvZpKSCIOjpHUfX7f5jIhVGi/vvlxP1rD6Go57nn1MtuGKNqL04AebSRFDQsQbw=="],
"@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@3.0.0-alpha.7", "", { "dependencies": { "@ai-sdk/provider": "2.0.0-alpha.7", "@standard-schema/spec": "^1.0.0", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-AYkT3jskmo7Lwzijo/yHKD1jC+UZizsROO8ULTg9aJZUwR4ABZzAxh4NxDIEy4TWRfBGufp+/9ICHAn6pkU71w=="],
"@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/react": ["@ai-sdk/react@1.2.12", "", { "dependencies": { "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/ui-utils": "1.2.11", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["zod"] }, "sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g=="],
"@ai-sdk/ui-utils": ["@ai-sdk/ui-utils@1.2.11", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w=="],
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
@@ -429,6 +439,8 @@
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
"@types/diff-match-patch": ["@types/diff-match-patch@1.0.36", "", {}, "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg=="],
"@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
@@ -473,7 +485,7 @@
"acorn-walk": ["acorn-walk@8.3.2", "", {}, "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A=="],
"ai": ["ai@5.0.0-alpha.7", "", { "dependencies": { "@ai-sdk/gateway": "1.0.0-alpha.7", "@ai-sdk/provider": "2.0.0-alpha.7", "@ai-sdk/provider-utils": "3.0.0-alpha.7", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-ShCk3frIMdVtK9knvWKiFS7N6Vwnf8mLMv670+T//W9oqfoetSVPBhTF6Dy+oDM/bjVSsBf1BuYImLDvHICOIQ=="],
"ai": ["ai@4.3.16", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/react": "1.2.12", "@ai-sdk/ui-utils": "1.2.11", "@opentelemetry/api": "1.9.0", "jsondiffpatch": "0.6.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["react"] }, "sha512-KUDwlThJ5tr2Vw0A1ZkbDKNME3wzWhuVfAOwIvFUzl1TPVDFAXDFTXio3p+jaKneB+dKNCvFFlolYmmgHttG1g=="],
"ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="],
@@ -685,6 +697,8 @@
"diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="],
"diff-match-patch": ["diff-match-patch@1.0.5", "", {}, "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="],
"direction": ["direction@2.0.1", "", { "bin": { "direction": "cli.js" } }, "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA=="],
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
@@ -969,6 +983,8 @@
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"jsondiffpatch": ["jsondiffpatch@0.6.0", "", { "dependencies": { "@types/diff-match-patch": "^1.0.36", "chalk": "^5.3.0", "diff-match-patch": "^1.0.5" }, "bin": { "jsondiffpatch": "bin/jsondiffpatch.js" } }, "sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"klona": ["klona@2.0.6", "", {}, "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA=="],
@@ -1273,6 +1289,8 @@
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
"react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
@@ -1353,6 +1371,8 @@
"sax": ["sax@1.2.1", "", {}, "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA=="],
"secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="],
"semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"send": ["send@1.2.0", "", { "dependencies": { "debug": "^4.3.5", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.0", "mime-types": "^3.0.1", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.1" } }, "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw=="],
@@ -1453,6 +1473,8 @@
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"swr": ["swr@2.3.3", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A=="],
"tar-fs": ["tar-fs@3.0.9", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-XF4w9Xp+ZQgifKakjZYmFdkLoSWd34VGKcsTCwlNWM7QG3ZbaxnTsaBwnjFZqHRf/rROxaR8rXnbtwdvaDI+lA=="],
"tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="],
@@ -1461,6 +1483,8 @@
"thread-stream": ["thread-stream@0.15.2", "", { "dependencies": { "real-require": "^0.1.0" } }, "sha512-UkEhKIg2pD+fjkHQKyJO3yoIvAP3N6RlNFt2dUhcS1FGvCD1cQa1M/PGknCLFIyZdtJOWQjejp7bdNqmN7zwdA=="],
"throttleit": ["throttleit@2.1.0", "", {}, "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw=="],
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
@@ -1471,7 +1495,7 @@
"token-types": ["token-types@6.0.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA=="],
"toolbeam-docs-theme": ["toolbeam-docs-theme@0.2.4", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-W5mdbcgRpTBDFyEdcU81USs3MFZoXMInpSznc/AFZCwqz8atk4iBNDIlhvihpGHY54Nf5crKmZwJjxVojkHFvA=="],
"toolbeam-docs-theme": ["toolbeam-docs-theme@0.3.0", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-qlBkKRp8HVYV7p7jaG9lT2lvQY7c8b9czZ0tnsJUrN2TBTtEyFJymCdkhhpZNC9U4oGZ7lLk0glRJHrndWvVsg=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
@@ -1545,6 +1569,8 @@
"url": ["url@0.10.3", "", { "dependencies": { "punycode": "1.3.2", "querystring": "0.2.0" } }, "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ=="],
"use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="],
"util": ["util@0.12.5", "", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
-11
View File
@@ -31,17 +31,6 @@ export const api = new sst.cloudflare.Worker("Api", {
},
})
// new sst.cloudflare.StaticSite("Web", {
// path: "packages/web",
// domain,
// environment: {
// VITE_API_URL: api.url,
// },
// build: {
// command: "bun run build",
// output: "dist",
// },
// })
new sst.cloudflare.x.Astro("Web", {
domain,
path: "packages/web",
+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {}
}
+6 -4
View File
@@ -5,8 +5,7 @@
"type": "module",
"packageManager": "bun@1.2.14",
"scripts": {
"typecheck": "bun run --filter='*' typecheck",
"dev": "sst dev"
"typecheck": "bun run --filter='*' typecheck"
},
"workspaces": {
"packages": [
@@ -16,7 +15,7 @@
"typescript": "5.8.2",
"@types/node": "22.13.9",
"zod": "3.24.2",
"ai": "5.0.0-alpha.7"
"ai": "4.3.16"
}
},
"devDependencies": {
@@ -38,5 +37,8 @@
"esbuild",
"protobufjs",
"sharp"
]
],
"patchedDependencies": {
"ai@4.3.16": "patches/ai@4.3.16.patch"
}
}
+6 -8
View File
@@ -19,9 +19,9 @@ export class SyncServer extends DurableObject<Env> {
this.ctx.acceptWebSocket(server)
const data = await this.ctx.storage.list()
for (const [key, content] of data.entries()) {
server.send(JSON.stringify({ key, content }))
}
Array.from(data.entries())
.filter(([key, _]) => key.startsWith("session/"))
.map(([key, content]) => server.send(JSON.stringify({ key, content })))
return new Response(null, {
status: 101,
@@ -71,11 +71,9 @@ export class SyncServer extends DurableObject<Env> {
public async getData() {
const data = await this.ctx.storage.list()
const messages = []
for (const [key, content] of data.entries()) {
messages.push({ key, content })
}
return messages
return Array.from(data.entries())
.filter(([key, _]) => key.startsWith("session/"))
.map(([key, content]) => ({ key, content }))
}
private async getSecret() {
+13 -4
View File
@@ -1,4 +1,4 @@
# OpenCode Agent Guidelines
# opencode agent guidelines
## Build/Test Commands
@@ -16,9 +16,19 @@
- **Naming**: camelCase for variables/functions, PascalCase for classes/namespaces
- **Error handling**: Use Result patterns, avoid throwing exceptions in tools
- **File structure**: Namespace-based organization (e.g., `Tool.define()`, `Session.create()`)
## IMPORTANT
- Try to keep things in one function unless composable or reusable
- DO NOT do unnecessary destructuring of variables
- DO NOT use else statements unless necessary
- DO NOT use try catch if it can be avoided
- DO NOT use `else` statements unless necessary
- DO NOT use `try`/`catch` if it can be avoided
- AVOID `try`/`catch` where possible
- AVOID `else` statements
- AVOID using `any` type
- AVOID `let` statements
- PREFER single word variable names where possible
- Use as many bun apis as possible like Bun.file()
## Architecture
@@ -27,4 +37,3 @@
- **Validation**: All inputs validated with Zod schemas
- **Logging**: Use `Log.create({ service: "name" })` pattern
- **Storage**: Use `Storage` namespace for persistence
+265
View File
@@ -0,0 +1,265 @@
{
"type": "object",
"properties": {
"$schema": {
"type": "string"
},
"theme": {
"type": "string"
},
"keybinds": {
"type": "object",
"properties": {
"leader": {
"type": "string"
},
"help": {
"type": "string"
},
"editor_open": {
"type": "string"
},
"session_new": {
"type": "string"
},
"session_list": {
"type": "string"
},
"session_share": {
"type": "string"
},
"session_interrupt": {
"type": "string"
},
"session_compact": {
"type": "string"
},
"tool_details": {
"type": "string"
},
"model_list": {
"type": "string"
},
"theme_list": {
"type": "string"
},
"project_init": {
"type": "string"
},
"input_clear": {
"type": "string"
},
"input_paste": {
"type": "string"
},
"input_submit": {
"type": "string"
},
"input_newline": {
"type": "string"
},
"history_previous": {
"type": "string"
},
"history_next": {
"type": "string"
},
"messages_page_up": {
"type": "string"
},
"messages_page_down": {
"type": "string"
},
"messages_half_page_up": {
"type": "string"
},
"messages_half_page_down": {
"type": "string"
},
"messages_previous": {
"type": "string"
},
"messages_next": {
"type": "string"
},
"messages_first": {
"type": "string"
},
"messages_last": {
"type": "string"
},
"app_exit": {
"type": "string"
}
},
"additionalProperties": false
},
"autoshare": {
"type": "boolean",
"description": "Share newly created sessions automatically"
},
"autoupdate": {
"type": "boolean",
"description": "Automatically update to the latest version"
},
"disabled_providers": {
"type": "array",
"items": {
"type": "string"
},
"description": "Disable providers that are loaded automatically"
},
"model": {
"type": "string",
"description": "Model to use in the format of provider/model, eg anthropic/claude-2"
},
"provider": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"api": {
"type": "string"
},
"name": {
"type": "string"
},
"env": {
"type": "array",
"items": {
"type": "string"
}
},
"id": {
"type": "string"
},
"npm": {
"type": "string"
},
"models": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"attachment": {
"type": "boolean"
},
"reasoning": {
"type": "boolean"
},
"temperature": {
"type": "boolean"
},
"cost": {
"type": "object",
"properties": {
"input": {
"type": "number"
},
"output": {
"type": "number"
},
"cache_read": {
"type": "number"
},
"cache_write": {
"type": "number"
}
},
"required": [
"input",
"output"
],
"additionalProperties": false
},
"limit": {
"type": "object",
"properties": {
"context": {
"type": "number"
},
"output": {
"type": "number"
}
},
"required": [
"context",
"output"
],
"additionalProperties": false
},
"id": {
"type": "string"
}
},
"additionalProperties": false
}
},
"options": {
"type": "object",
"additionalProperties": {}
}
},
"required": [
"models"
],
"additionalProperties": false
}
},
"mcp": {
"type": "object",
"additionalProperties": {
"anyOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "local"
},
"command": {
"type": "array",
"items": {
"type": "string"
}
},
"environment": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"required": [
"type",
"command"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "remote"
},
"url": {
"type": "string"
}
},
"required": [
"type",
"url"
],
"additionalProperties": false
}
]
}
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
+6 -6
View File
@@ -5,20 +5,20 @@
"type": "module",
"private": true,
"scripts": {
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"dev": "bun run ./src/index.ts"
},
"exports": {
"./*": [
"./src/*.ts",
"./src/*/index.ts"
]
"./*": "./src/*.ts"
},
"devDependencies": {
"@tsconfig/bun": "1.0.7",
"@types/bun": "latest",
"@types/turndown": "5.0.5",
"@types/yargs": "17.0.33",
"typescript": "catalog:"
"typescript": "catalog:",
"zod-to-json-schema": "3.24.5",
"@ai-sdk/anthropic": "1.2.12"
},
"dependencies": {
"@clack/prompts": "0.11.0",
+6 -5
View File
@@ -29,7 +29,7 @@ const targets = [
["linux", "x64"],
["darwin", "x64"],
["darwin", "arm64"],
["windows", "x64"],
// ["windows", "x64"],
]
await $`rm -rf dist`
@@ -64,7 +64,7 @@ for (const [os, arch] of targets) {
await $`mkdir -p ./dist/${pkg.name}`
await $`cp -r ./bin ./dist/${pkg.name}/bin`
await $`cp ./script/postinstall.js ./dist/${pkg.name}/postinstall.js`
await $`cp ./script/postinstall.mjs ./dist/${pkg.name}/postinstall.mjs`
await Bun.file(`./dist/${pkg.name}/package.json`).write(
JSON.stringify(
{
@@ -73,7 +73,7 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write(
[pkg.name]: `./bin/${pkg.name}`,
},
scripts: {
postinstall: "node ./postinstall.js",
postinstall: "node ./postinstall.mjs",
},
version,
optionalDependencies,
@@ -108,9 +108,10 @@ if (!snapshot) {
.filter((x: string) => {
const lower = x.toLowerCase()
return (
!lower.includes("chore:") &&
!lower.includes("ignore:") &&
!lower.includes("ci:") &&
!lower.includes("docs:")
!lower.includes("docs:") &&
!lower.includes("doc:")
)
})
.join("\n")
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bun
import "zod-openapi/extend"
import { Config } from "../src/config/config"
import { zodToJsonSchema } from "zod-to-json-schema"
const result = zodToJsonSchema(Config.Info)
await Bun.write("config.schema.json", JSON.stringify(result, null, 2))
+5 -7
View File
@@ -18,6 +18,7 @@ export namespace App {
data: z.string(),
root: z.string(),
cwd: z.string(),
state: z.string(),
}),
time: z.object({
initialized: z.number().optional(),
@@ -32,7 +33,7 @@ export namespace App {
const APP_JSON = "app.json"
async function create(input: { cwd: string; version: string }) {
async function create(input: { cwd: string }) {
log.info("creating", {
cwd: input.cwd,
})
@@ -49,9 +50,7 @@ export namespace App {
const stateFile = Bun.file(path.join(data, APP_JSON))
const state = (await stateFile.json().catch(() => ({}))) as {
initialized: number
version: string
}
state.version = input.version
await stateFile.write(JSON.stringify(state))
const services = new Map<
@@ -70,13 +69,13 @@ export namespace App {
git: git !== undefined,
path: {
config: Global.Path.config,
state: Global.Path.state,
data,
root: git ?? input.cwd,
cwd: input.cwd,
},
}
const result = {
version: input.version,
services,
info,
}
@@ -108,7 +107,7 @@ export namespace App {
}
export async function provide<T>(
input: { cwd: string; version: string },
input: { cwd: string },
cb: (app: Info) => Promise<T>,
) {
const app = await create(input)
@@ -124,12 +123,11 @@ export namespace App {
}
export async function initialize() {
const { info, version } = ctx.use()
const { info } = ctx.use()
info.time.initialized = Date.now()
await Bun.write(
path.join(info.path.data, APP_JSON),
JSON.stringify({
version,
initialized: Date.now(),
}),
)
+3 -1
View File
@@ -1,5 +1,4 @@
import { generatePKCE } from "@openauthjs/openauth/pkce"
import fs from "fs/promises"
import { Auth } from "./index"
export namespace AuthAnthropic {
@@ -49,6 +48,7 @@ export namespace AuthAnthropic {
await Auth.set("anthropic", {
type: "oauth",
refresh: json.refresh_token as string,
access: json.access_token as string,
expires: Date.now() + json.expires_in * 1000,
})
}
@@ -56,6 +56,7 @@ export namespace AuthAnthropic {
export async function access() {
const info = await Auth.get("anthropic")
if (!info || info.type !== "oauth") return
if (info.access && info.expires > Date.now()) return info.access
const response = await fetch(
"https://console.anthropic.com/v1/oauth/token",
{
@@ -75,6 +76,7 @@ export namespace AuthAnthropic {
await Auth.set("anthropic", {
type: "oauth",
refresh: json.refresh_token as string,
access: json.access_token as string,
expires: Date.now() + json.expires_in * 1000,
})
return json.access_token as string
+1
View File
@@ -7,6 +7,7 @@ export namespace Auth {
export const Oauth = z.object({
type: z.literal("oauth"),
refresh: z.string(),
access: z.string(),
expires: z.number(),
})
+20 -10
View File
@@ -43,16 +43,26 @@ export namespace BunProc {
version: z.string(),
}),
)
export async function install(pkg: string, version = "latest") {
const dir = path.join(Global.Path.cache, `node_modules`, pkg)
if (!(await Bun.file(path.join(dir, "package.json")).exists())) {
log.info("installing", { pkg })
await BunProc.run(["add", `${pkg}@${version}`], {
cwd: Global.Path.cache,
}).catch(() => {
throw new InstallFailedError({ pkg, version })
})
}
return dir
const mod = path.join(Global.Path.cache, "node_modules", pkg)
const pkgjson = Bun.file(path.join(Global.Path.cache, "package.json"))
const parsed = await pkgjson.json().catch(() => ({
dependencies: {},
}))
if (parsed.dependencies[pkg] === version) return mod
parsed.dependencies[pkg] = version
await Bun.write(pkgjson, JSON.stringify(parsed, null, 2))
await BunProc.run(["install"], {
cwd: Global.Path.cache,
}).catch((e) => {
new InstallFailedError(
{ pkg, version },
{
cause: e,
},
)
})
return mod
}
}
+18 -5
View File
@@ -5,7 +5,7 @@ import * as prompts from "@clack/prompts"
import open from "open"
import { UI } from "../ui"
import { ModelsDev } from "../../provider/models"
import { map, pipe, sort, sortBy, values } from "remeda"
import { map, pipe, sortBy, values } from "remeda"
export const AuthCommand = cmd({
command: "auth",
@@ -16,7 +16,7 @@ export const AuthCommand = cmd({
.command(AuthLogoutCommand)
.command(AuthListCommand)
.demandCommand(),
async handler(args) {},
async handler() {},
})
export const AuthListCommand = cmd({
@@ -78,9 +78,16 @@ export const AuthLoginCommand = cmd({
if (provider === "other") {
provider = await prompts.text({
message: "Enter provider - must match @ai-sdk/<provider>",
message: "Enter provider id",
validate: (x) =>
x.match(/^[a-z-]+$/) ? undefined : "a-z and hyphens only",
})
if (prompts.isCancel(provider)) throw new UI.CancelledError()
provider = provider.replace(/^@ai-sdk\//, "")
if (prompts.isCancel(provider)) throw new UI.CancelledError()
prompts.log.warn(
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
)
}
if (provider === "amazon-bedrock") {
@@ -111,8 +118,14 @@ export const AuthLoginCommand = cmd({
// some weird bug where program exits without this
await new Promise((resolve) => setTimeout(resolve, 10))
const { url, verifier } = await AuthAnthropic.authorize()
prompts.note("Opening browser...")
await open(url)
prompts.note("Trying to open browser...")
try {
await open(url)
} catch (e) {
prompts.log.error(
"Failed to open browser perhaps you are running without a display or X server, please open the following URL in your browser:",
)
}
prompts.log.info(url)
const code = await prompts.text({
+27 -22
View File
@@ -6,14 +6,9 @@ import { Session } from "../../session"
import { Share } from "../../share/share"
import { Message } from "../../session/message"
import { UI } from "../ui"
import { VERSION } from "../version"
const COLOR = [
UI.Style.TEXT_SUCCESS_BOLD,
UI.Style.TEXT_INFO_BOLD,
UI.Style.TEXT_HIGHLIGHT_BOLD,
UI.Style.TEXT_WARNING_BOLD,
]
import { cmd } from "./cmd"
import { Flag } from "../../flag/flag"
import { Config } from "../../config/config"
const TOOL: Record<string, [string, string]> = {
opencode_todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
@@ -27,7 +22,7 @@ const TOOL: Record<string, [string, string]> = {
opencode_write: ["Write", UI.Style.TEXT_SUCCESS_BOLD],
}
export const RunCommand = {
export const RunCommand = cmd({
command: "run [message..]",
describe: "Run OpenCode with a message",
builder: (yargs: Argv) => {
@@ -42,17 +37,16 @@ export const RunCommand = {
describe: "Session ID to continue",
type: "string",
})
.option("share", {
type: "boolean",
describe: "Share the session",
})
},
handler: async (args: {
message: string[]
session?: string
printLogs?: boolean
}) => {
handler: async (args) => {
const message = args.message.join(" ")
await App.provide(
{
cwd: process.cwd(),
version: VERSION,
},
async () => {
await Share.init()
@@ -60,14 +54,27 @@ export const RunCommand = {
? await Session.get(args.session)
: await Session.create()
UI.println(UI.Style.TEXT_HIGHLIGHT_BOLD + "◍ OpenCode", VERSION)
UI.empty()
UI.println(UI.logo())
UI.empty()
UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message)
UI.empty()
const cfg = await Config.get()
if (cfg.autoshare || Flag.OPENCODE_AUTO_SHARE || args.share) {
await Session.share(session.id)
UI.println(
UI.Style.TEXT_INFO_BOLD +
"~ https://dev.opencode.ai/s/" +
session.id.slice(-8),
)
}
UI.empty()
const { providerID, modelID } = await Provider.defaultModel()
UI.println(
UI.Style.TEXT_INFO_BOLD +
"~ https://dev.opencode.ai/s/" +
session.id.slice(-8),
UI.Style.TEXT_NORMAL_BOLD + "@ ",
UI.Style.TEXT_NORMAL + `${providerID}/${modelID}`,
)
UI.empty()
@@ -113,8 +120,6 @@ export const RunCommand = {
printEvent(UI.Style.TEXT_NORMAL_BOLD, "Text", part.text)
}
})
const { providerID, modelID } = await Provider.defaultModel()
await Session.chat({
sessionID: session.id,
providerID,
@@ -130,4 +135,4 @@ export const RunCommand = {
},
)
},
}
})
+7 -5
View File
@@ -1,6 +1,5 @@
import { App } from "../../app/app"
import { LSP } from "../../lsp"
import { VERSION } from "../version"
import { cmd } from "./cmd"
export const ScrapCommand = cmd({
@@ -8,9 +7,12 @@ export const ScrapCommand = cmd({
builder: (yargs) =>
yargs.positional("file", { type: "string", demandOption: true }),
async handler(args) {
await App.provide({ cwd: process.cwd(), version: VERSION }, async (app) => {
await LSP.touchFile(args.file, true)
console.log(await LSP.diagnostics())
})
await App.provide(
{ cwd: process.cwd() },
async () => {
await LSP.touchFile(args.file, true)
console.log(await LSP.diagnostics())
},
)
},
})
+44
View File
@@ -0,0 +1,44 @@
import type { Argv } from "yargs"
import { UI } from "../ui"
import * as prompts from "@clack/prompts"
import { Installation } from "../../installation"
export const UpgradeCommand = {
command: "upgrade [target]",
describe: "Upgrade opencode to the latest version or a specific version",
builder: (yargs: Argv) => {
return yargs.positional("target", {
describe: "Specific version to upgrade to (e.g., '0.1.48' or 'v0.1.48')",
type: "string",
})
},
handler: async (args: { target?: string }) => {
UI.empty()
UI.println(UI.logo(" "))
UI.empty()
prompts.intro("Upgrade")
const method = await Installation.method()
if (method === "unknown") {
prompts.log.error(
`opencode is installed to ${process.execPath} and seems to be managed by a package manager`,
)
prompts.outro("Done")
return
}
const target = args.target ?? (await Installation.latest())
prompts.log.info(`From ${Installation.VERSION}${target}`)
const spinner = prompts.spinner()
spinner.start("Upgrading...")
const err = await Installation.upgrade(method, target).catch((err) => err)
if (err) {
spinner.stop("Upgrade failed")
if (err instanceof Installation.UpgradeFailedError)
prompts.log.error(err.data.stderr)
else if (err instanceof Error) prompts.log.error(err.message)
prompts.outro("Done")
return
}
spinner.stop("Upgrade complete")
prompts.outro("Done")
},
}
-193
View File
@@ -1,193 +0,0 @@
import { createCli, type TrpcCliMeta } from "trpc-cli"
import { initTRPC } from "@trpc/server"
import { z } from "zod"
import { Server } from "../server/server"
import { AuthAnthropic } from "../auth/anthropic"
import { UI } from "./ui"
import { App } from "../app/app"
import { Bus } from "../bus"
import { Provider } from "../provider/provider"
import { Session } from "../session"
import { Share } from "../share/share"
import { Message } from "../session/message"
import { VERSION } from "./version"
import { LSP } from "../lsp"
import fs from "fs/promises"
import path from "path"
const t = initTRPC.meta<TrpcCliMeta>().create()
export const router = t.router({
generate: t.procedure
.meta({
description: "Generate OpenAPI and event specs",
})
.input(z.object({}))
.mutation(async () => {
const specs = await Server.openapi()
const dir = "gen"
await fs.rmdir(dir, { recursive: true }).catch(() => {})
await fs.mkdir(dir, { recursive: true })
await Bun.write(
path.join(dir, "openapi.json"),
JSON.stringify(specs, null, 2),
)
return "Generated OpenAPI specs in gen/ directory"
}),
run: t.procedure
.meta({
description: "Run OpenCode with a message",
})
.input(
z.object({
message: z.array(z.string()).default([]).describe("Message to send"),
session: z.string().optional().describe("Session ID to continue"),
}),
)
.mutation(
async ({ input }: { input: { message: string[]; session?: string } }) => {
const message = input.message.join(" ")
await App.provide(
{
cwd: process.cwd(),
version: "0.0.0",
},
async () => {
await Share.init()
const session = input.session
? await Session.get(input.session)
: await Session.create()
UI.println(UI.Style.TEXT_HIGHLIGHT_BOLD + "◍ OpenCode", VERSION)
UI.empty()
UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message)
UI.empty()
UI.println(
UI.Style.TEXT_INFO_BOLD +
"~ https://dev.opencode.ai/s?id=" +
session.id.slice(-8),
)
UI.empty()
function printEvent(color: string, type: string, title: string) {
UI.println(
color + `|`,
UI.Style.TEXT_NORMAL +
UI.Style.TEXT_DIM +
` ${type.padEnd(7, " ")}`,
"",
UI.Style.TEXT_NORMAL + title,
)
}
Bus.subscribe(Message.Event.PartUpdated, async (message) => {
const part = message.properties.part
if (
part.type === "tool-invocation" &&
part.toolInvocation.state === "result"
) {
if (part.toolInvocation.toolName === "opencode_todowrite")
return
const args = part.toolInvocation.args as any
const tool = part.toolInvocation.toolName
if (tool === "opencode_edit")
printEvent(UI.Style.TEXT_SUCCESS_BOLD, "Edit", args.filePath)
if (tool === "opencode_bash")
printEvent(
UI.Style.TEXT_WARNING_BOLD,
"Execute",
args.command,
)
if (tool === "opencode_read")
printEvent(UI.Style.TEXT_INFO_BOLD, "Read", args.filePath)
if (tool === "opencode_write")
printEvent(
UI.Style.TEXT_SUCCESS_BOLD,
"Create",
args.filePath,
)
if (tool === "opencode_list")
printEvent(UI.Style.TEXT_INFO_BOLD, "List", args.path)
if (tool === "opencode_glob")
printEvent(
UI.Style.TEXT_INFO_BOLD,
"Glob",
args.pattern + (args.path ? " in " + args.path : ""),
)
}
if (part.type === "text") {
if (part.text.includes("\n")) {
UI.empty()
UI.println(part.text)
UI.empty()
return
}
printEvent(UI.Style.TEXT_NORMAL_BOLD, "Text", part.text)
}
})
const { providerID, modelID } = await Provider.defaultModel()
await Session.chat({
sessionID: session.id,
providerID,
modelID,
parts: [
{
type: "text",
text: message,
},
],
})
UI.empty()
},
)
return "Session completed"
},
),
scrap: t.procedure
.meta({
description: "Test command for scraping files",
})
.input(
z.object({
file: z.string().describe("File to process"),
}),
)
.mutation(async ({ input }: { input: { file: string } }) => {
await App.provide({ cwd: process.cwd(), version: VERSION }, async () => {
await LSP.touchFile(input.file, true)
await LSP.diagnostics()
})
return `Processed file: ${input.file}`
}),
login: t.router({
anthropic: t.procedure
.meta({
description: "Login to Anthropic",
})
.input(z.object({}))
.mutation(async () => {
const { url, verifier } = await AuthAnthropic.authorize()
UI.println("Login to Anthropic")
UI.println("Open the following URL in your browser:")
UI.println(url)
UI.println("")
const code = await UI.input("Paste the authorization code here: ")
await AuthAnthropic.exchange(code, verifier)
return "Successfully logged in to Anthropic"
}),
}),
})
export function createOpenCodeCli() {
return createCli({ router })
}
+7 -9
View File
@@ -3,9 +3,9 @@ import { NamedError } from "../util/error"
export namespace UI {
const LOGO = [
`█▀▀█ █▀▀█ █▀▀ █▀▀▄ █▀▀ █▀▀█ █▀▀▄ █▀▀`,
`█░░█ █░░█ █▀▀ █░░█ █░░ █░░█ █░░█ █▀▀`,
`▀▀▀▀ █▀▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀`,
[`█▀▀█ █▀▀█ █▀▀ █▀▀▄ `, `█▀▀ █▀▀█ █▀▀▄ █▀▀`],
[`█░░█ █░░█ █▀▀ █░░█ `, `█░░ █░░█ █░░█ █▀▀`],
[`▀▀▀▀ █▀▀▀ ▀▀▀ ▀ ▀ `, `▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀`],
]
export const CancelledError = NamedError.create("UICancelledError", z.void())
@@ -48,12 +48,10 @@ export namespace UI {
const result = []
for (const row of LOGO) {
if (pad) result.push(pad)
for (let i = 0; i < row.length; i++) {
const color =
i > 18 ? Bun.color("white", "ansi") : Bun.color("gray", "ansi")
const char = row[i]
result.push(color + char)
}
result.push(Bun.color("gray", "ansi"))
result.push(row[0])
result.push("\x1b[0m")
result.push(row[1])
result.push("\n")
}
return result.join("").trimEnd()
-6
View File
@@ -1,6 +0,0 @@
declare global {
const OPENCODE_VERSION: string
}
export const VERSION =
typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "dev"
+109 -17
View File
@@ -1,14 +1,19 @@
import { Log } from "../util/log"
import path from "path"
import { z } from "zod"
import { App } from "../app/app"
import { Filesystem } from "../util/filesystem"
import { ModelsDev } from "../provider/models"
import { mergeDeep } from "remeda"
import { Global } from "../global"
import fs from "fs/promises"
import { lazy } from "../util/lazy"
export namespace Config {
const log = Log.create({ service: "config" })
export const state = App.state("config", async (app) => {
let result: Info = {}
let result = await global()
for (const file of ["opencode.jsonc", "opencode.json"]) {
const [resolved] = await Filesystem.findUp(
file,
@@ -17,7 +22,10 @@ export namespace Config {
)
if (!resolved) continue
try {
result = await import(resolved).then((mod) => Info.parse(mod.default))
result = mergeDeep(
result,
await import(resolved).then((mod) => Info.parse(mod.default)),
)
log.info("found", { path: resolved })
break
} catch (e) {
@@ -34,22 +42,84 @@ export namespace Config {
return result
})
export const McpLocal = z.object({
type: z.literal("local"),
command: z.string().array(),
environment: z.record(z.string(), z.string()).optional(),
})
export const McpLocal = z
.object({
type: z.literal("local"),
command: z.string().array(),
environment: z.record(z.string(), z.string()).optional(),
})
.openapi({
ref: "Config.McpLocal",
})
export const McpRemote = z.object({
type: z.literal("remote"),
url: z.string(),
})
export const McpRemote = z
.object({
type: z.literal("remote"),
url: z.string(),
})
.openapi({
ref: "Config.McpRemote",
})
export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
export type Mcp = z.infer<typeof Mcp>
export const Keybinds = z
.object({
leader: z.string().optional(),
help: z.string().optional(),
editor_open: z.string().optional(),
session_new: z.string().optional(),
session_list: z.string().optional(),
session_share: z.string().optional(),
session_interrupt: z.string().optional(),
session_compact: z.string().optional(),
tool_details: z.string().optional(),
model_list: z.string().optional(),
theme_list: z.string().optional(),
project_init: z.string().optional(),
input_clear: z.string().optional(),
input_paste: z.string().optional(),
input_submit: z.string().optional(),
input_newline: z.string().optional(),
history_previous: z.string().optional(),
history_next: z.string().optional(),
messages_page_up: z.string().optional(),
messages_page_down: z.string().optional(),
messages_half_page_up: z.string().optional(),
messages_half_page_down: z.string().optional(),
messages_previous: z.string().optional(),
messages_next: z.string().optional(),
messages_first: z.string().optional(),
messages_last: z.string().optional(),
app_exit: z.string().optional(),
})
.openapi({
ref: "Config.Keybinds",
})
export const Info = z
.object({
$schema: z.string().optional(),
theme: z.string().optional(),
keybinds: Keybinds.optional(),
autoshare: z
.boolean()
.optional()
.describe("Share newly created sessions automatically"),
autoupdate: z
.boolean()
.optional()
.describe("Automatically update to the latest version"),
disabled_providers: z
.array(z.string())
.optional()
.describe("Disable providers that are loaded automatically"),
model: z
.string()
.describe(
"Model to use in the format of provider/model, eg anthropic/claude-2",
)
.optional(),
provider: z
.record(
ModelsDev.Provider.partial().extend({
@@ -58,17 +128,39 @@ export namespace Config {
}),
)
.optional(),
tool: z
.object({
provider: z.record(z.string(), z.string().array()).optional(),
})
.optional(),
mcp: z.record(z.string(), Mcp).optional(),
})
.strict()
.openapi({
ref: "Config.Info",
})
export type Info = z.output<typeof Info>
export const global = lazy(async () => {
let result = await Bun.file(path.join(Global.Path.config, "config.json"))
.json()
.then((mod) => Info.parse(mod))
.catch(() => ({}) as Info)
await import(path.join(Global.Path.config, "config"), {
with: {
type: "toml",
},
})
.then(async (mod) => {
const { provider, model, ...rest } = mod.default
if (provider && model) result.model = `${provider}/${model}`
result = mergeDeep(result, rest)
await Bun.write(
path.join(Global.Path.config, "config.json"),
JSON.stringify(result, null, 2),
)
await fs.unlink(path.join(Global.Path.config, "config"))
})
.catch(() => {})
return Info.parse(result)
})
export function get() {
return state()
}
+7 -3
View File
@@ -116,11 +116,15 @@ export namespace Fzf {
return filepath
}
export async function search(cwd: string, query: string) {
const results = await $`${await filepath()} --filter ${query}`
export async function search(input: {
cwd: string
query: string
limit?: number
}) {
const results = await $`${await filepath()} --filter=${input.query}`
.quiet()
.throws(false)
.cwd(cwd)
.cwd(input.cwd)
.text()
const split = results
.trim()
+16 -1
View File
@@ -1,10 +1,11 @@
import { App } from "../app/app"
import path from "path"
import { Global } from "../global"
import fs from "fs/promises"
import { z } from "zod"
import { NamedError } from "../util/error"
import { lazy } from "../util/lazy"
import { $ } from "bun"
import { Fzf } from "./fzf"
export namespace Ripgrep {
const PLATFORM = {
@@ -111,4 +112,18 @@ export namespace Ripgrep {
const { filepath } = await state()
return filepath
}
export async function files(input: {
cwd: string
query?: string
limit?: number
}) {
const commands = [`${await filepath()} --files --hidden --glob='!.git/*'`]
if (input.query)
commands.push(`${await Fzf.filepath()} --filter=${input.query}`)
if (input.limit) commands.push(`head -n ${input.limit}`)
const joined = commands.join(" | ")
const result = await $`${{ raw: joined }}`.cwd(input.cwd).text()
return result.split("\n").filter(Boolean)
}
}
-10
View File
@@ -1,10 +0,0 @@
export namespace File {
const glob = new Bun.Glob("**/*")
export async function search(path: string, query: string) {
for await (const entry of glob.scan({
cwd: path,
onlyFiles: true,
})) {
}
}
}
+12 -7
View File
@@ -1,5 +1,5 @@
import fs from "fs/promises"
import { xdgData, xdgCache, xdgConfig } from "xdg-basedir"
import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir"
import path from "path"
const app = "opencode"
@@ -7,18 +7,23 @@ const app = "opencode"
const data = path.join(xdgData!, app)
const cache = path.join(xdgCache!, app)
const config = path.join(xdgConfig!, app)
await Promise.all([
fs.mkdir(data, { recursive: true }),
fs.mkdir(config, { recursive: true }),
fs.mkdir(cache, { recursive: true }),
])
const state = path.join(xdgState!, app)
export namespace Global {
export const Path = {
data,
bin: path.join(data, "bin"),
providers: path.join(config, "providers"),
cache,
config,
state,
} as const
}
await Promise.all([
fs.mkdir(Global.Path.data, { recursive: true }),
fs.mkdir(Global.Path.config, { recursive: true }),
fs.mkdir(Global.Path.cache, { recursive: true }),
fs.mkdir(Global.Path.providers, { recursive: true }),
fs.mkdir(Global.Path.state, { recursive: true }),
])
+67 -53
View File
@@ -3,25 +3,25 @@ import { App } from "./app/app"
import { Server } from "./server/server"
import fs from "fs/promises"
import path from "path"
import { Share } from "./share/share"
import { Global } from "./global"
import yargs from "yargs"
import { hideBin } from "yargs/helpers"
import { RunCommand } from "./cli/cmd/run"
import { GenerateCommand } from "./cli/cmd/generate"
import { VERSION } from "./cli/version"
import { ScrapCommand } from "./cli/cmd/scrap"
import { Log } from "./util/log"
import { AuthCommand, AuthLoginCommand } from "./cli/cmd/auth"
import { UpgradeCommand } from "./cli/cmd/upgrade"
import { Provider } from "./provider/provider"
import { UI } from "./cli/ui"
import { Installation } from "./installation"
import { Bus } from "./bus"
import { Config } from "./config/config"
const cli = yargs(hideBin(process.argv))
.scriptName("opencode")
.version(VERSION)
.version(Installation.VERSION)
.option("print-logs", {
describe: "Print logs to stderr",
type: "boolean",
@@ -29,14 +29,14 @@ const cli = yargs(hideBin(process.argv))
.middleware(async () => {
await Log.init({ print: process.argv.includes("--print-logs") })
Log.Default.info("opencode", {
version: VERSION,
version: Installation.VERSION,
args: process.argv.slice(2),
})
})
.usage("\n" + UI.logo())
.command({
command: "$0 [project]",
describe: "Start OpenCode TUI",
describe: "Start opencode TUI",
builder: (yargs) =>
yargs.positional("project", {
type: "string",
@@ -46,51 +46,65 @@ const cli = yargs(hideBin(process.argv))
while (true) {
const cwd = args.project ? path.resolve(args.project) : process.cwd()
process.chdir(cwd)
const result = await App.provide(
{ cwd, version: VERSION },
async () => {
const providers = await Provider.list()
if (Object.keys(providers).length === 0) {
return "needs_provider"
const result = await App.provide({ cwd }, async (app) => {
const providers = await Provider.list()
if (Object.keys(providers).length === 0) {
return "needs_provider"
}
await Share.init()
const server = Server.listen()
let cmd = ["go", "run", "./main.go"]
let cwd = new URL("../../tui/cmd/opencode", import.meta.url).pathname
if (Bun.embeddedFiles.length > 0) {
const blob = Bun.embeddedFiles[0] as File
const binary = path.join(Global.Path.cache, "tui", blob.name)
const file = Bun.file(binary)
if (!(await file.exists())) {
await Bun.write(file, blob, { mode: 0o755 })
await fs.chmod(binary, 0o755)
}
cwd = process.cwd()
cmd = [binary]
}
const proc = Bun.spawn({
cmd: [...cmd, ...process.argv.slice(2)],
cwd,
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
env: {
...process.env,
OPENCODE_SERVER: server.url.toString(),
OPENCODE_APP_INFO: JSON.stringify(app),
},
onExit: () => {
server.stop()
},
})
await Share.init()
const server = Server.listen()
;(async () => {
if (Installation.VERSION === "dev") return
if (Installation.isSnapshot()) return
const config = await Config.global()
if (config.autoupdate === false) return
const latest = await Installation.latest()
if (Installation.VERSION === latest) return
const method = await Installation.method()
if (method === "unknown") return
await Installation.upgrade(method, latest)
.then(() => {
Bus.publish(Installation.Event.Updated, { version: latest })
})
.catch(() => {})
})()
let cmd = ["go", "run", "./main.go"]
let cwd = new URL("../../tui/cmd/opencode", import.meta.url)
.pathname
if (Bun.embeddedFiles.length > 0) {
const blob = Bun.embeddedFiles[0] as File
const binary = path.join(Global.Path.cache, "tui", blob.name)
const file = Bun.file(binary)
if (!(await file.exists())) {
await Bun.write(file, blob, { mode: 0o755 })
await fs.chmod(binary, 0o755)
}
cwd = process.cwd()
cmd = [binary]
}
const proc = Bun.spawn({
cmd,
cwd,
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
env: {
...process.env,
OPENCODE_SERVER: server.url.toString(),
},
onExit: () => {
server.stop()
},
})
await proc.exited
await server.stop()
await proc.exited
server.stop()
return "done"
},
)
return "done"
})
if (result === "done") break
if (result === "needs_provider") {
UI.empty()
@@ -105,21 +119,21 @@ const cli = yargs(hideBin(process.argv))
.command(GenerateCommand)
.command(ScrapCommand)
.command(AuthCommand)
.fail((msg, err) => {
.command(UpgradeCommand)
.fail((msg) => {
if (
msg.startsWith("Unknown argument") ||
msg.startsWith("Not enough non-option arguments")
) {
cli.showHelp("log")
}
Log.Default.error(msg, {
err,
})
})
.strict()
try {
await cli.parse()
} catch (e) {
Log.Default.error(e)
Log.Default.error(e, {
stack: e instanceof Error ? e.stack : undefined,
})
}
+122
View File
@@ -0,0 +1,122 @@
import path from "path"
import { $ } from "bun"
import { z } from "zod"
import { NamedError } from "../util/error"
import { Bus } from "../bus"
declare global {
const OPENCODE_VERSION: string
}
export namespace Installation {
export type Method = Awaited<ReturnType<typeof method>>
export const Event = {
Updated: Bus.event(
"installation.updated",
z.object({
version: z.string(),
}),
),
}
export const Info = z
.object({
version: z.string(),
latest: z.string(),
})
.openapi({
ref: "InstallationInfo",
})
export type Info = z.infer<typeof Info>
export async function info() {
return {
version: VERSION,
latest: await latest(),
}
}
export function isSnapshot() {
return VERSION.startsWith("0.0.0")
}
export async function method() {
if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl"
const exec = process.execPath.toLowerCase()
const checks = [
{
name: "npm" as const,
command: () => $`npm list -g --depth=0`.throws(false).text(),
},
{
name: "yarn" as const,
command: () => $`yarn global list`.throws(false).text(),
},
{
name: "pnpm" as const,
command: () => $`pnpm list -g --depth=0`.throws(false).text(),
},
{
name: "bun" as const,
command: () => $`bun pm ls -g`.throws(false).text(),
},
]
checks.sort((a, b) => {
const aMatches = exec.includes(a.name)
const bMatches = exec.includes(b.name)
if (aMatches && !bMatches) return -1
if (!aMatches && bMatches) return 1
return 0
})
for (const check of checks) {
const output = await check.command()
if (output.includes("opencode-ai")) {
return check.name
}
}
return "unknown"
}
export const UpgradeFailedError = NamedError.create(
"UpgradeFailedError",
z.object({
stderr: z.string(),
}),
)
export async function upgrade(method: Method, target: string) {
const cmd = (() => {
switch (method) {
case "curl":
return $`curl -fsSL https://opencode.ai/install | bash`
case "npm":
return $`npm install -g opencode-ai@${target}`
case "pnpm":
return $`pnpm install -g opencode-ai@${target}`
case "bun":
return $`bun install -g opencode-ai@${target}`
default:
throw new Error(`Unknown method: ${method}`)
}
})()
const result = await cmd.quiet().throws(false)
if (result.exitCode !== 0)
throw new UpgradeFailedError({
stderr: result.stderr.toString("utf8"),
})
}
export const VERSION =
typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "dev"
export async function latest() {
return fetch("https://api.github.com/repos/sst/opencode/releases/latest")
.then((res) => res.json())
.then((data) => data.tag_name.slice(1) as string)
}
}
+39 -23
View File
@@ -11,6 +11,7 @@ import { LANGUAGE_EXTENSIONS } from "./language"
import { Bus } from "../bus"
import z from "zod"
import type { LSPServer } from "./server"
import { NamedError } from "../util/error"
export namespace LSPClient {
const log = Log.create({ service: "lsp.client" })
@@ -19,6 +20,13 @@ export namespace LSPClient {
export type Diagnostic = VSCodeDiagnostic
export const InitializeError = NamedError.create(
"LSPInitializeError",
z.object({
serverID: z.string(),
}),
)
export const Event = {
Diagnostics: Bus.event(
"lsp.client.diagnostics",
@@ -52,32 +60,40 @@ export namespace LSPClient {
})
connection.listen()
await connection.sendRequest("initialize", {
processId: server.process.pid,
workspaceFolders: [
{
name: "workspace",
uri: "file://" + app.path.cwd,
},
],
initializationOptions: {
...server.initialization,
},
capabilities: {
workspace: {
configuration: true,
},
textDocument: {
synchronization: {
didOpen: true,
didChange: true,
log.info("sending initialize", { id: serverID })
await Promise.race([
connection.sendRequest("initialize", {
processId: server.process.pid,
workspaceFolders: [
{
name: "workspace",
uri: "file://" + app.path.cwd,
},
publishDiagnostics: {
versionSupport: true,
],
initializationOptions: {
...server.initialization,
},
capabilities: {
workspace: {
configuration: true,
},
textDocument: {
synchronization: {
didOpen: true,
didChange: true,
},
publishDiagnostics: {
versionSupport: true,
},
},
},
},
})
}),
new Promise((_, reject) => {
setTimeout(() => {
reject(new InitializeError({ serverID }))
}, 5_000)
}),
])
await connection.sendNotification("initialized", {})
log.info("initialized")
+12 -3
View File
@@ -12,9 +12,10 @@ export namespace LSP {
async () => {
log.info("initializing")
const clients = new Map<string, LSPClient.Info>()
const skip = new Set<string>()
return {
clients,
skip,
}
},
async (state) => {
@@ -31,11 +32,19 @@ export namespace LSP {
x.extensions.includes(extension),
)
for (const match of matches) {
if (s.skip.has(match.id)) continue
const existing = s.clients.get(match.id)
if (existing) continue
const handle = await match.spawn(App.info())
if (!handle) continue
const client = await LSPClient.create(match.id, handle)
if (!handle) {
s.skip.add(match.id)
continue
}
const client = await LSPClient.create(match.id, handle).catch(() => {})
if (!client) {
s.skip.add(match.id)
continue
}
s.clients.set(match.id, client)
}
if (waitForDiagnostics) {
+4 -2
View File
@@ -16,8 +16,8 @@ export namespace ModelsDev {
cost: z.object({
input: z.number(),
output: z.number(),
inputCached: z.number(),
outputCached: z.number(),
cache_read: z.number().optional(),
cache_write: z.number().optional(),
}),
limit: z.object({
context: z.number(),
@@ -32,9 +32,11 @@ export namespace ModelsDev {
export const Provider = z
.object({
api: z.string().optional(),
name: z.string(),
env: z.array(z.string()),
id: z.string(),
npm: z.string().optional(),
models: z.record(Model),
})
.openapi({
+87 -27
View File
@@ -1,4 +1,5 @@
import z from "zod"
import path from "path"
import { App } from "../app/app"
import { Config } from "../config/config"
import { mergeDeep, sortBy } from "remeda"
@@ -23,6 +24,7 @@ import { ModelsDev } from "./models"
import { NamedError } from "../util/error"
import { Auth } from "../auth"
import { TaskTool } from "../tool/task"
import { Global } from "../global"
export namespace Provider {
const log = Log.create({ service: "provider" })
@@ -40,16 +42,23 @@ export namespace Provider {
for (const model of Object.values(provider.models)) {
model.cost = {
input: 0,
inputCached: 0,
output: 0,
outputCached: 0,
}
}
return {
apiKey: "",
headers: {
authorization: `Bearer ${access}`,
"anthropic-beta": "oauth-2025-04-20",
async fetch(input: any, init: any) {
const access = await AuthAnthropic.access()
const headers = {
...init.headers,
authorization: `Bearer ${access}`,
"anthropic-beta": "oauth-2025-04-20",
}
delete headers["x-api-key"]
return fetch(input, {
...init,
headers,
})
},
}
},
@@ -82,7 +91,7 @@ export namespace Provider {
>()
const sdk = new Map<string, SDK>()
log.info("loading")
log.info("init")
function mergeProvider(
id: string,
@@ -91,9 +100,12 @@ export namespace Provider {
) {
const provider = providers[id]
if (!provider) {
const info = database[id]
if (!info) return
if (info.api) options["baseURL"] = info.api
providers[id] = {
source,
info: database[id],
info,
options,
}
return
@@ -102,12 +114,13 @@ export namespace Provider {
provider.source = source
}
for (const [providerID, provider] of Object.entries(
config.provider ?? {},
)) {
const configProviders = Object.entries(config.provider ?? {})
for (const [providerID, provider] of configProviders) {
const existing = database[providerID]
const parsed: ModelsDev.Provider = {
id: providerID,
npm: provider.npm ?? existing?.npm,
name: provider.name ?? existing?.name ?? providerID,
env: provider.env ?? existing?.env ?? [],
models: existing?.models ?? {},
@@ -139,8 +152,12 @@ export namespace Provider {
database[providerID] = parsed
}
const disabled = await Config.get().then(
(cfg) => new Set(cfg.disabled_providers ?? []),
)
// load env
for (const [providerID, provider] of Object.entries(database)) {
if (disabled.has(providerID)) continue
if (provider.env.some((item) => process.env[item])) {
mergeProvider(providerID, {}, "env")
}
@@ -148,6 +165,7 @@ export namespace Provider {
// load apikeys
for (const [providerID, provider] of Object.entries(await Auth.all())) {
if (disabled.has(providerID)) continue
if (provider.type === "api") {
mergeProvider(providerID, { apiKey: provider.key }, "api")
}
@@ -155,19 +173,18 @@ export namespace Provider {
// load custom
for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) {
if (disabled.has(providerID)) continue
const result = await fn(database[providerID])
if (result) mergeProvider(providerID, result, "custom")
}
// load config
for (const [providerID, provider] of Object.entries(
config.provider ?? {},
)) {
for (const [providerID, provider] of configProviders) {
mergeProvider(providerID, provider.options ?? {}, "config")
}
for (const providerID of Object.keys(providers)) {
log.info("loaded", { providerID })
log.info("found", { providerID })
}
return {
@@ -181,20 +198,22 @@ export namespace Provider {
return state().then((state) => state.providers)
}
async function getSDK(providerID: string) {
async function getSDK(provider: ModelsDev.Provider) {
return (async () => {
using _ = log.time("getSDK", {
providerID: provider.id,
})
const s = await state()
const existing = s.sdk.get(providerID)
const existing = s.sdk.get(provider.id)
if (existing) return existing
const mod = await import(
await BunProc.install(`@ai-sdk/${providerID}`, "alpha")
)
const pkg = provider.npm ?? provider.id
const mod = await import(await BunProc.install(pkg, "latest"))
const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!]
const loaded = fn(s.providers[providerID]?.options)
s.sdk.set(providerID, loaded)
const loaded = fn(s.providers[provider.id]?.options)
s.sdk.set(provider.id, loaded)
return loaded as SDK
})().catch((e) => {
throw new InitError({ providerID: providerID }, { cause: e })
throw new InitError({ providerID: provider.id }, { cause: e })
})
}
@@ -203,7 +222,7 @@ export namespace Provider {
const s = await state()
if (s.models.has(key)) return s.models.get(key)!
log.info("loading", {
log.info("getModel", {
providerID,
modelID,
})
@@ -212,8 +231,7 @@ export namespace Provider {
if (!provider) throw new ModelNotFoundError({ providerID, modelID })
const info = provider.info.models[modelID]
if (!info) throw new ModelNotFoundError({ providerID, modelID })
const sdk = await getSDK(providerID)
const sdk = await getSDK(provider.info)
try {
const language =
@@ -255,7 +273,10 @@ export namespace Provider {
}
export async function defaultModel() {
const [provider] = await list().then((val) => Object.values(val))
const cfg = await Config.get()
const provider = await list()
.then((val) => Object.values(val))
.then((x) => x.find((p) => !cfg.provider || cfg.provider === p.info.id))
if (!provider) throw new Error("no providers found")
const [model] = sort(Object.values(provider.info.models))
if (!model) throw new Error("no models found")
@@ -283,20 +304,59 @@ export namespace Provider {
TaskTool,
TodoReadTool,
]
const TOOL_MAPPING: Record<string, Tool.Info[]> = {
anthropic: TOOLS.filter((t) => t.id !== "opencode.patch"),
openai: TOOLS,
openai: TOOLS.map((t) => ({
...t,
parameters: optionalToNullable(t.parameters),
})),
google: TOOLS,
}
export async function tools(providerID: string) {
/*
const cfg = await Config.get()
if (cfg.tool?.provider?.[providerID])
return cfg.tool.provider[providerID].map(
(id) => TOOLS.find((t) => t.id === id)!,
)
*/
return TOOL_MAPPING[providerID] ?? TOOLS
}
function optionalToNullable(schema: z.ZodTypeAny): z.ZodTypeAny {
if (schema instanceof z.ZodObject) {
const shape = schema.shape
const newShape: Record<string, z.ZodTypeAny> = {}
for (const [key, value] of Object.entries(shape)) {
const zodValue = value as z.ZodTypeAny
if (zodValue instanceof z.ZodOptional) {
newShape[key] = zodValue.unwrap().nullable()
} else {
newShape[key] = optionalToNullable(zodValue)
}
}
return z.object(newShape)
}
if (schema instanceof z.ZodArray) {
return z.array(optionalToNullable(schema.element))
}
if (schema instanceof z.ZodUnion) {
return z.union(
schema.options.map((option: z.ZodTypeAny) =>
optionalToNullable(option),
) as [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]],
)
}
return schema
}
export const ModelNotFoundError = NamedError.create(
"ProviderModelNotFoundError",
z.object({
+46 -2
View File
@@ -12,8 +12,10 @@ import { App } from "../app/app"
import { Global } from "../global"
import { mapValues } from "remeda"
import { NamedError } from "../util/error"
import { Fzf } from "../external/fzf"
import { ModelsDev } from "../provider/models"
import { Ripgrep } from "../external/ripgrep"
import { Installation } from "../installation"
import { Config } from "../config/config"
const ERRORS = {
400: {
@@ -139,6 +141,25 @@ export namespace Server {
return c.json(App.info())
},
)
.post(
"/config_get",
describeRoute({
description: "Get config info",
responses: {
200: {
description: "Get config info",
content: {
"application/json": {
schema: resolver(Config.Info),
},
},
},
},
}),
async (c) => {
return c.json(await Config.get())
},
)
.post(
"/app_initialize",
describeRoute({
@@ -457,10 +478,33 @@ export namespace Server {
async (c) => {
const body = c.req.valid("json")
const app = App.info()
const result = await Fzf.search(app.path.cwd, body.query)
const result = await Ripgrep.files({
cwd: app.path.cwd,
query: body.query,
limit: 10,
})
return c.json(result)
},
)
.post(
"installation_info",
describeRoute({
description: "Get installation info",
responses: {
200: {
description: "Get installation info",
content: {
"application/json": {
schema: resolver(Installation.Info),
},
},
},
},
}),
async (c) => {
return c.json(Installation.info())
},
)
return result
}
+319 -141
View File
@@ -4,15 +4,16 @@ import { Identifier } from "../id/id"
import { Storage } from "../storage/storage"
import { Log } from "../util/log"
import {
convertToModelMessages,
generateText,
LoadAPIKeyError,
stepCountIs,
convertToCoreMessages,
streamText,
tool,
type Tool as AITool,
type LanguageModelUsage,
type CoreMessage,
type UIMessage,
type ProviderMetadata,
} from "ai"
import { z, ZodSchema } from "zod"
import { Decimal } from "decimal.js"
@@ -28,6 +29,9 @@ import { NamedError } from "../util/error"
import type { Tool } from "../tool/tool"
import { SystemPrompt } from "./system"
import { Flag } from "../flag/flag"
import type { ModelsDev } from "../provider/models"
import { Installation } from "../installation"
import { Config } from "../config/config"
export namespace Session {
const log = Log.create({ service: "session" })
@@ -38,11 +42,11 @@ export namespace Session {
parentID: Identifier.schema("session").optional(),
share: z
.object({
secret: z.string(),
url: z.string(),
})
.optional(),
title: z.string(),
version: z.string(),
time: z.object({
created: z.number(),
updated: z.number(),
@@ -53,6 +57,12 @@ export namespace Session {
})
export type Info = z.output<typeof Info>
export const ShareInfo = z.object({
secret: z.string(),
url: z.string(),
})
export type ShareInfo = z.output<typeof ShareInfo>
export const Event = {
Updated: Bus.event(
"session.updated",
@@ -68,19 +78,30 @@ export namespace Session {
),
}
const state = App.state("session", () => {
const sessions = new Map<string, Info>()
const messages = new Map<string, Message.Info[]>()
const state = App.state(
"session",
() => {
const sessions = new Map<string, Info>()
const messages = new Map<string, Message.Info[]>()
const pending = new Map<string, AbortController>()
return {
sessions,
messages,
}
})
return {
sessions,
messages,
pending,
}
},
async (state) => {
for (const [_, controller] of state.pending) {
controller.abort()
}
},
)
export async function create(parentID?: string) {
const result: Info = {
id: Identifier.descending("session"),
version: Installation.VERSION,
parentID,
title:
(parentID ? "Child session - " : "New Session - ") +
@@ -93,7 +114,8 @@ export namespace Session {
log.info("created", result)
state().sessions.set(result.id, result)
await Storage.writeJSON("session/info/" + result.id, result)
if (!result.parentID && Flag.OPENCODE_AUTO_SHARE)
const cfg = await Config.get()
if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.autoshare))
share(result.id).then((share) => {
update(result.id, (draft) => {
draft.share = share
@@ -115,13 +137,20 @@ export namespace Session {
return read as Info
}
export async function getShare(id: string) {
return Storage.readJSON<ShareInfo>("session/share/" + id)
}
export async function share(id: string) {
const session = await get(id)
if (session.share) return session.share
const share = await Share.create(id)
await update(id, (draft) => {
draft.share = share
draft.share = {
url: share.url,
}
})
await Storage.writeJSON<ShareInfo>("session/share/" + id, share)
for (const msg of await messages(id)) {
await Share.sync("session/message/" + id + "/" + msg.id, msg)
}
@@ -167,10 +196,10 @@ export namespace Session {
}
export function abort(sessionID: string) {
const controller = pending.get(sessionID)
const controller = state().pending.get(sessionID)
if (!controller) return false
controller.abort()
pending.delete(sessionID)
state().pending.delete(sessionID)
return true
}
@@ -202,10 +231,13 @@ export namespace Session {
if (previous?.metadata.assistant) {
const tokens =
previous.metadata.assistant.tokens.input +
previous.metadata.assistant.tokens.cache.read +
previous.metadata.assistant.tokens.cache.write +
previous.metadata.assistant.tokens.output
if (
model.info.limit.context &&
tokens >
(model.info.limit.context - (model.info.limit.output ?? 0)) * 0.9
(model.info.limit.context - (model.info.limit.output ?? 0)) * 0.9
) {
await summarize({
sessionID: input.sessionID,
@@ -227,32 +259,38 @@ export namespace Session {
const session = await get(input.sessionID)
if (msgs.length === 0 && !session.parentID) {
generateText({
maxOutputTokens: 20,
messages: convertToModelMessages([
maxTokens: input.providerID === "google" ? 1024 : 20,
messages: [
...SystemPrompt.title(input.providerID).map(
(x): UIMessage => ({
id: Identifier.ascending("message"),
(x): CoreMessage => ({
role: "system",
parts: [
{
type: "text",
text: x,
},
],
content: x,
providerOptions: {
...(input.providerID === "anthropic"
? {
anthropic: {
cacheControl: { type: "ephemeral" },
},
}
: {}),
},
}),
),
{
role: "user",
parts: input.parts,
},
]),
temperature: 0,
...convertToCoreMessages([
{
role: "user",
content: "",
parts: toParts(input.parts),
},
]),
],
model: model.language,
})
.then((result) => {
return Session.update(input.sessionID, (draft) => {
draft.title = result.text
})
if (result.text)
return Session.update(input.sessionID, (draft) => {
draft.title = result.text
})
})
.catch(() => {})
}
@@ -272,7 +310,7 @@ export namespace Session {
msgs.push(msg)
const system = input.system ?? SystemPrompt.provider(input.providerID)
system.push(...(await SystemPrompt.environment(input.sessionID)))
system.push(...(await SystemPrompt.environment()))
system.push(...(await SystemPrompt.custom()))
const next: Message.Info = {
@@ -291,6 +329,7 @@ export namespace Session {
input: 0,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: input.modelID,
providerID: input.providerID,
@@ -317,6 +356,16 @@ export namespace Session {
sessionID: input.sessionID,
abort: abort.signal,
messageID: next.id,
metadata: async (val) => {
next.metadata.tool[opts.toolCallId] = {
...val,
time: {
start: 0,
end: 0,
},
}
await updateMessage(next)
},
})
next.metadata!.tool![opts.toolCallId] = {
...result.metadata,
@@ -381,13 +430,29 @@ export namespace Session {
}
let text: Message.TextPart | undefined
await Bun.write(
"/tmp/message.json",
JSON.stringify(
[
...system.map(
(x): CoreMessage => ({
role: "system",
content: x,
}),
),
...convertToCoreMessages(
msgs.map(toUIMessage).filter((x) => x.parts.length > 0),
),
],
null,
2,
),
)
const result = streamText({
onStepFinish: async (step) => {
log.info("step finish", {
finishReason: step.finishReason,
})
log.info("step finish", { finishReason: step.finishReason })
const assistant = next.metadata!.assistant!
const usage = getUsage(step.usage, model.info)
const usage = getUsage(model.info, step.usage, step.providerMetadata)
assistant.cost += usage.cost
assistant.tokens = usage.tokens
await updateMessage(next)
@@ -400,18 +465,99 @@ export namespace Session {
}
text = undefined
},
async onChunk(input) {
const value = input.chunk
async onFinish(input) {
log.info("message finish", {
reason: input.finishReason,
})
const assistant = next.metadata!.assistant!
const usage = getUsage(model.info, input.usage, input.providerMetadata)
assistant.cost = usage.cost
await updateMessage(next)
},
onError(err) {
log.error("callback error", err)
switch (true) {
case LoadAPIKeyError.isInstance(err.error):
next.metadata.error = new Provider.AuthError(
{
providerID: input.providerID,
message: err.error.message,
},
{ cause: err.error },
).toObject()
break
case err.error instanceof Error:
next.metadata.error = new NamedError.Unknown(
{ message: err.error.toString() },
{ cause: err.error },
).toObject()
break
default:
next.metadata.error = new NamedError.Unknown(
{ message: JSON.stringify(err.error) },
{ cause: err.error },
)
}
Bus.publish(Event.Error, {
error: next.metadata.error,
})
},
// async prepareStep(step) {
// next.parts.push({
// type: "step-start",
// })
// await updateMessage(next)
// return step
// },
toolCallStreaming: true,
abortSignal: abort.signal,
maxSteps: 1000,
messages: [
...system.map(
(x, index): CoreMessage => ({
role: "system",
content: x,
providerOptions: {
...(input.providerID === "anthropic" && index < 4
? {
anthropic: {
cacheControl: { type: "ephemeral" },
},
}
: {}),
},
}),
),
...convertToCoreMessages(
msgs.map(toUIMessage).filter((x) => x.parts.length > 0),
),
],
temperature: model.info.temperature ? 0 : undefined,
tools: {
...tools,
},
model: model.language,
})
try {
for await (const value of result.fullStream) {
l.info("part", {
type: value.type,
})
switch (value.type) {
case "text":
case "step-start":
next.parts.push({
type: "step-start",
})
break
case "text-delta":
if (!text) {
text = value
next.parts.push(value)
text = {
type: "text",
text: value.textDelta,
}
next.parts.push(text)
break
} else text.text += value.text
} else text.text += value.textDelta
break
case "tool-call": {
@@ -452,18 +598,25 @@ export namespace Session {
case "tool-call-delta":
break
// for some reason ai sdk claims to not send this part but it does
// @ts-expect-error
case "tool-result":
const match = next.parts.find(
(p) =>
p.type === "tool-invocation" &&
// @ts-expect-error
p.toolInvocation.toolCallId === value.toolCallId,
)
if (match && match.type === "tool-invocation") {
match.toolInvocation = {
// @ts-expect-error
args: value.args,
// @ts-expect-error
toolCallId: value.toolCallId,
// @ts-expect-error
toolName: value.toolName,
state: "result",
// @ts-expect-error
result: value.result as string,
}
Bus.publish(Message.Event.PartUpdated, {
@@ -480,80 +633,37 @@ export namespace Session {
})
}
await updateMessage(next)
},
async onFinish(input) {
const assistant = next.metadata!.assistant!
const usage = getUsage(input.totalUsage, model.info)
assistant.cost = usage.cost
await updateMessage(next)
},
onError(err) {
log.error("error", err)
switch (true) {
case LoadAPIKeyError.isInstance(err.error):
next.metadata.error = new Provider.AuthError(
{
providerID: input.providerID,
message: err.error.message,
},
{ cause: err.error },
).toObject()
break
case err.error instanceof Error:
next.metadata.error = new NamedError.Unknown(
{ message: err.error.toString() },
{ cause: err.error },
).toObject()
break
default:
next.metadata.error = new NamedError.Unknown(
{ message: JSON.stringify(err.error) },
{ cause: err.error },
)
}
Bus.publish(Event.Error, {
error: next.metadata.error,
})
},
async prepareStep(step) {
next.parts.push({
type: "step-start",
})
await updateMessage(next)
return step
},
toolCallStreaming: true,
abortSignal: abort.signal,
stopWhen: stepCountIs(1000),
messages: convertToModelMessages([
...system.map(
(x): UIMessage => ({
id: Identifier.ascending("message"),
role: "system",
parts: [
{
type: "text",
text: x,
},
],
}),
),
...msgs,
]),
temperature: model.info.id === "codex-mini-latest" ? undefined : 0,
tools: {
...(await MCP.tools()),
...tools,
},
model: model.language,
})
await result.consumeStream({
onError: (err) => {
log.error("stream error", {
err,
})
},
})
}
} catch (e: any) {
log.error("stream error", {
error: e,
})
switch (true) {
case LoadAPIKeyError.isInstance(e):
next.metadata.error = new Provider.AuthError(
{
providerID: input.providerID,
message: e.message,
},
{ cause: e },
).toObject()
break
case e instanceof Error:
next.metadata.error = new NamedError.Unknown(
{ message: e.toString() },
{ cause: e },
).toObject()
break
default:
next.metadata.error = new NamedError.Unknown(
{ message: JSON.stringify(e) },
{ cause: e },
)
}
Bus.publish(Event.Error, {
error: next.metadata.error,
})
}
next.metadata!.time.completed = Date.now()
for (const part of next.parts) {
if (
@@ -607,6 +717,7 @@ export namespace Session {
input: 0,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
},
time: {
@@ -618,67 +729,80 @@ export namespace Session {
const result = await generateText({
abortSignal: abort.signal,
model: model.language,
messages: convertToModelMessages([
messages: [
...system.map(
(x): UIMessage => ({
id: Identifier.ascending("message"),
(x): CoreMessage => ({
role: "system",
parts: [
{
type: "text",
text: x,
},
],
content: x,
}),
),
...filtered,
...convertToCoreMessages(filtered.map(toUIMessage)),
{
role: "user",
parts: [
content: [
{
type: "text",
text: "Provide a detailed but concise summary of our conversation above. Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next.",
},
],
},
]),
],
})
next.parts.push({
type: "text",
text: result.text,
})
const assistant = next.metadata!.assistant!
const usage = getUsage(result.usage, model.info)
const usage = getUsage(model.info, result.usage, result.providerMetadata)
assistant.cost = usage.cost
assistant.tokens = usage.tokens
await updateMessage(next)
}
const pending = new Map<string, AbortController>()
function lock(sessionID: string) {
log.info("locking", { sessionID })
if (pending.has(sessionID)) throw new BusyError(sessionID)
if (state().pending.has(sessionID)) throw new BusyError(sessionID)
const controller = new AbortController()
pending.set(sessionID, controller)
state().pending.set(sessionID, controller)
return {
signal: controller.signal,
[Symbol.dispose]() {
log.info("unlocking", { sessionID })
pending.delete(sessionID)
state().pending.delete(sessionID)
},
}
}
function getUsage(usage: LanguageModelUsage, model: Provider.Model) {
function getUsage(
model: ModelsDev.Model,
usage: LanguageModelUsage,
metadata?: ProviderMetadata,
) {
const tokens = {
input: usage.inputTokens ?? 0,
output: usage.outputTokens ?? 0,
reasoning: usage.reasoningTokens ?? 0,
input: usage.promptTokens ?? 0,
output: usage.completionTokens ?? 0,
reasoning: 0,
cache: {
write: (metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
0) as number,
read: (metadata?.["anthropic"]?.["cacheReadInputTokens"] ??
0) as number,
},
}
return {
cost: new Decimal(0)
.add(new Decimal(tokens.input).mul(model.cost.input).div(1_000_000))
.add(new Decimal(tokens.output).mul(model.cost.output).div(1_000_000))
.add(
new Decimal(tokens.cache.read)
.mul(model.cost.cache_read ?? 0)
.div(1_000_000),
)
.add(
new Decimal(tokens.cache.write)
.mul(model.cost.cache_write ?? 0)
.div(1_000_000),
)
.toNumber(),
tokens,
}
@@ -710,3 +834,57 @@ export namespace Session {
await App.initialize()
}
}
function toUIMessage(msg: Message.Info): UIMessage {
if (msg.role === "assistant") {
return {
id: msg.id,
role: "assistant",
content: "",
parts: toParts(msg.parts),
}
}
if (msg.role === "user") {
return {
id: msg.id,
role: "user",
content: "",
parts: toParts(msg.parts),
}
}
throw new Error("not implemented")
}
function toParts(parts: Message.Part[]): UIMessage["parts"] {
const result: UIMessage["parts"] = []
for (const part of parts) {
switch (part.type) {
case "text":
result.push({ type: "text", text: part.text })
break
case "file":
result.push({
type: "file",
data: part.url,
mimeType: part.mediaType,
})
break
case "tool-invocation":
result.push({
type: "tool-invocation",
toolInvocation: part.toolInvocation,
})
break
case "step-start":
result.push({
type: "step-start",
})
break
default:
break
}
}
return result
}
+4
View File
@@ -174,6 +174,10 @@ export namespace Message {
input: z.number(),
output: z.number(),
reasoning: z.number(),
cache: z.object({
read: z.number(),
write: z.number(),
}),
}),
})
.optional(),
@@ -1,14 +1,14 @@
You are OpenCode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
You are opencode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
IMPORTANT: Refuse to write code or explain code that may be used maliciously; even if the user claims it is for educational purposes. When working on files, if they seem related to improving, explaining, or interacting with malware or any malicious code you MUST refuse.
IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure. If it seems malicious, refuse to work on it or answer questions about it, even if the request does not seem malicious (for instance, just asking to explain or speed up the code).
IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.
If the user asks for help or wants to give feedback inform them of the following:
- /help: Get help with using OpenCode
- /help: Get help with using opencode
- To give feedback, users should report the issue at https://github.com/sst/opencode/issues
When the user directly asks about OpenCode (eg 'can OpenCode do...', 'does OpenCode have...') or asks in second person (eg 'are you able...', 'can you do...'), first use the WebFetch tool to gather information to answer the question from OpenCode docs at https://opencode.ai
When the user directly asks about opencode (eg 'can opencode do...', 'does opencode have...') or asks in second person (eg 'are you able...', 'can you do...'), first use the WebFetch tool to gather information to answer the question from opencode docs at https://opencode.ai
# Tone and style
You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
@@ -1,4 +1,4 @@
you will generate a short title based on the first message a user begins a conversation with
You will generate a short title based on the first message a user begins a conversation with
- ensure it is not more than 50 characters long
- the title should be a summary of the user's message
- it should be one line long
+52 -3
View File
@@ -1,5 +1,5 @@
import { App } from "../app/app"
import { ListTool } from "../tool/ls"
import { Ripgrep } from "../external/ripgrep"
import { Filesystem } from "../util/filesystem"
import PROMPT_ANTHROPIC from "./prompt/anthropic.txt"
@@ -22,8 +22,57 @@ export namespace SystemPrompt {
return result
}
export async function environment(sessionID: string) {
export async function environment() {
const app = App.info()
const tree = async () => {
const files = await Ripgrep.files({
cwd: app.path.cwd,
})
type Node = {
children: Record<string, Node>
}
const root: Node = {
children: {},
}
for (const file of files) {
const parts = file.split("/")
let node = root
for (const part of parts) {
const existing = node.children[part]
if (existing) {
node = existing
continue
}
node.children[part] = {
children: {},
}
node = node.children[part]
}
}
function render(path: string[], node: Node): string {
// if (path.length === 3) return "\t".repeat(path.length) + "..."
const lines: string[] = []
const entries = Object.entries(node.children).sort(([a], [b]) =>
a.localeCompare(b),
)
for (const [name, child] of entries) {
const currentPath = [...path, name]
const indent = "\t".repeat(path.length)
const hasChildren = Object.keys(child.children).length > 0
lines.push(`${indent}${name}` + (hasChildren ? "/" : ""))
if (hasChildren) lines.push(render(currentPath, child))
}
return lines.join("\n")
}
const result = render([], root)
return result
}
return [
[
`Here is some useful information about the environment you are running in:`,
@@ -34,7 +83,7 @@ export namespace SystemPrompt {
` Today's date: ${new Date().toDateString()}`,
`</env>`,
`<project>`,
` ${app.git ? await ListTool.execute({ path: app.path.cwd, ignore: [] }, { sessionID: sessionID, messageID: "", abort: AbortSignal.any([]) }).then((x) => x.output) : ""}`,
` ${app.git ? await tree() : ""}`,
`</project>`,
].join("\n"),
]
+5 -4
View File
@@ -19,10 +19,11 @@ export namespace Share {
export async function sync(key: string, content: any) {
const [root, ...splits] = key.split("/")
if (root !== "session") return
const [, sessionID] = splits
const session = await Session.get(sessionID)
if (!session.share) return
const { secret } = session.share
const [sub, sessionID] = splits
if (sub === "share") return
const share = await Session.getShare(sessionID).catch(() => {})
if (!share) return
const { secret } = share
pending.set(key, content)
queue = queue
.then(async () => {
-2
View File
@@ -24,8 +24,6 @@ export namespace Storage {
}
})
const locks = new Map<string, Promise<void>>()
export async function readJSON<T>(key: string) {
return Bun.file(path.join(state().dir, key + ".json")).json() as Promise<T>
}
+1 -1
View File
@@ -35,7 +35,7 @@ export const BashTool = Tool.define({
.min(0)
.max(MAX_TIMEOUT)
.describe("Optional timeout in milliseconds")
.nullable(),
.optional(),
description: z
.string()
.describe(
+6 -6
View File
@@ -22,7 +22,7 @@ Usage notes:
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
- If the output exceeds 30000 characters, output will be truncated before being returned to you.
- VERY IMPORTANT: You MUST avoid using search commands like `find` and `grep`. Instead use Grep, Glob, or Task to search. You MUST avoid read tools like `cat`, `head`, `tail`, and `ls`, and use Read and LS to read files.
- If you _still_ need to run `grep`, STOP. ALWAYS USE ripgrep at `rg` (or /usr/bin/rg) first, which all OpenCode users have pre-installed.
- If you _still_ need to run `grep`, STOP. ALWAYS USE ripgrep at `rg` (or /usr/bin/rg) first, which all opencode users have pre-installed.
- When issuing multiple commands, use the ';' or '&&' operator to separate them. DO NOT use newlines (newlines are ok in quoted strings).
- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of `cd`. You may use `cd` if the User explicitly requests it.
<good-example>
@@ -60,9 +60,9 @@ When the user asks you to create a new git commit, follow these steps carefully:
3. You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. ALWAYS run the following commands in parallel:
- Add relevant untracked files to the staging area.
- Create the commit with a message ending with:
🤖 Generated with [OpenCode](https://opencode.ai)
🤖 Generated with [opencode](https://opencode.ai)
Co-Authored-By: OpenCode <noreply@opencode.ai>
Co-Authored-By: opencode <noreply@opencode.ai>
- Run git status to make sure the commit succeeded.
4. If the commit fails due to pre-commit hook changes, retry the commit ONCE to include these automated changes. If it fails again, it usually means a pre-commit hook is preventing the commit. If the commit succeeds but you notice that files were modified by the pre-commit hook, you MUST amend your commit to include them.
@@ -81,9 +81,9 @@ Important notes:
git commit -m "$(cat <<'EOF'
Commit message here.
🤖 Generated with [OpenCode](https://opencode.ai)
🤖 Generated with [opencode](https://opencode.ai)
Co-Authored-By: OpenCode <noreply@opencode.ai>
Co-Authored-By: opencode <noreply@opencode.ai>
EOF
)"
</example>
@@ -128,7 +128,7 @@ gh pr create --title "the pr title" --body "$(cat <<'EOF'
## Test plan
[Checklist of TODOs for testing the pull request...]
🤖 Generated with [OpenCode](https://opencode.ai)
🤖 Generated with [opencode](https://opencode.ai)
EOF
)"
</example>
+40 -2
View File
@@ -21,7 +21,7 @@ export const EditTool = Tool.define({
),
replaceAll: z
.boolean()
.nullable()
.optional()
.describe("Replace all occurences of old_string (default false)"),
}),
async execute(params, ctx) {
@@ -87,7 +87,9 @@ export const EditTool = Tool.define({
await file.write(contentNew)
})()
const diff = createTwoFilesPatch(filepath, filepath, contentOld, contentNew)
const diff = trimDiff(
createTwoFilesPatch(filepath, filepath, contentOld, contentNew),
)
FileTimes.read(ctx.sessionID, filepath)
@@ -113,3 +115,39 @@ export const EditTool = Tool.define({
}
},
})
function trimDiff(diff: string): string {
const lines = diff.split("\n")
const contentLines = lines.filter(
(line) =>
(line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) &&
!line.startsWith("---") &&
!line.startsWith("+++"),
)
if (contentLines.length === 0) return diff
let min = Infinity
for (const line of contentLines) {
const content = line.slice(1)
if (content.trim().length > 0) {
const match = content.match(/^(\s*)/)
if (match) min = Math.min(min, match[1].length)
}
}
if (min === Infinity || min === 0) return diff
const trimmedLines = lines.map((line) => {
if (
(line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) &&
!line.startsWith("---") &&
!line.startsWith("+++")
) {
const prefix = line[0]
const content = line.slice(1)
return prefix + content.slice(min)
}
return line
})
return trimmedLines.join("\n")
}
+1 -1
View File
@@ -11,7 +11,7 @@ export const GlobTool = Tool.define({
pattern: z.string().describe("The glob pattern to match files against"),
path: z
.string()
.nullable()
.optional()
.describe(
`The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`,
),
+2 -2
View File
@@ -14,13 +14,13 @@ export const GrepTool = Tool.define({
.describe("The regex pattern to search for in file contents"),
path: z
.string()
.nullable()
.optional()
.describe(
"The directory to search in. Defaults to the current working directory.",
),
include: z
.string()
.nullable()
.optional()
.describe(
'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")',
),
+8 -7
View File
@@ -4,7 +4,7 @@ import { App } from "../app/app"
import * as path from "path"
import DESCRIPTION from "./ls.txt"
const IGNORE_PATTERNS = [
export const IGNORE_PATTERNS = [
"node_modules/",
"__pycache__/",
".git/",
@@ -18,6 +18,8 @@ const IGNORE_PATTERNS = [
".vscode/",
]
const LIMIT = 100
export const ListTool = Tool.define({
id: "opencode.list",
description: DESCRIPTION,
@@ -27,11 +29,11 @@ export const ListTool = Tool.define({
.describe(
"The absolute path to the directory to list (must be absolute, not relative)",
)
.nullable(),
.optional(),
ignore: z
.array(z.string())
.describe("List of glob patterns to ignore")
.nullable(),
.optional(),
}),
async execute(params) {
const app = App.info()
@@ -41,12 +43,11 @@ export const ListTool = Tool.define({
const files = []
for await (const file of glob.scan({ cwd: searchPath, dot: true })) {
if (file.startsWith(".") || IGNORE_PATTERNS.some((p) => file.includes(p)))
continue
if (IGNORE_PATTERNS.some((p) => file.includes(p))) continue
if (params.ignore?.some((pattern) => new Bun.Glob(pattern).match(file)))
continue
files.push(file)
if (files.length >= 1000) break
if (files.length >= LIMIT) break
}
// Build directory structure
@@ -100,7 +101,7 @@ export const ListTool = Tool.define({
return {
metadata: {
count: files.length,
truncated: files.length >= 1000,
truncated: files.length >= LIMIT,
title: path.relative(app.path.root, searchPath),
},
output,
-1
View File
@@ -4,7 +4,6 @@ import * as fs from "fs/promises"
import { Tool } from "./tool"
import { FileTimes } from "./util/file-times"
import DESCRIPTION from "./patch.txt"
import { App } from "../app/app"
const PatchParams = z.object({
patchText: z
+2 -2
View File
@@ -19,11 +19,11 @@ export const ReadTool = Tool.define({
offset: z
.number()
.describe("The line number to start reading from (0-based)")
.nullable(),
.optional(),
limit: z
.number()
.describe("The number of lines to read (defaults to 2000)")
.nullable(),
.optional(),
}),
async execute(params, ctx) {
let filePath = params.filePath
+26 -1
View File
@@ -2,6 +2,8 @@ import { Tool } from "./tool"
import DESCRIPTION from "./task.txt"
import { z } from "zod"
import { Session } from "../session"
import { Bus } from "../bus"
import { Message } from "../session/message"
export const TaskTool = Tool.define({
id: "opencode.task",
@@ -17,6 +19,28 @@ export const TaskTool = Tool.define({
const msg = await Session.getMessage(ctx.sessionID, ctx.messageID)
const metadata = msg.metadata.assistant!
function summary(input: Message.Info) {
const result = []
for (const part of input.parts) {
if (part.type === "tool-invocation") {
result.push({
toolInvocation: part.toolInvocation,
metadata: input.metadata.tool[part.toolInvocation.toolCallId],
})
}
}
return result
}
const unsub = Bus.subscribe(Message.Event.Updated, async (evt) => {
if (evt.properties.info.metadata.sessionID !== ctx.sessionID) return
ctx.metadata({
title: params.description,
summary: summary(evt.properties.info),
})
})
const result = await Session.chat({
sessionID: session.id,
modelID: metadata.modelID,
@@ -28,10 +52,11 @@ export const TaskTool = Tool.define({
},
],
})
unsub()
return {
metadata: {
title: params.description,
summary: summary(result),
},
output: result.parts.findLast((x) => x.type === "text")!.text,
}
+2 -1
View File
@@ -5,10 +5,11 @@ export namespace Tool {
title: string
[key: string]: any
}
export type Context = {
export type Context<M extends Metadata = Metadata> = {
sessionID: string
messageID: string
abort: AbortSignal
metadata(meta: M): void
}
export interface Info<
Parameters extends StandardSchemaV1 = StandardSchemaV1,
+1 -1
View File
@@ -22,7 +22,7 @@ export const WebFetchTool = Tool.define({
.min(0)
.max(MAX_TIMEOUT / 1000)
.describe("Optional timeout in seconds (max 120)")
.nullable(),
.optional(),
}),
async execute(params, ctx) {
// Validate URL
+14 -10
View File
@@ -11,15 +11,16 @@ export abstract class NamedError extends Error {
name: Name,
data: Data,
) {
const schema = z
.object({
name: z.literal(name),
data,
})
.openapi({
ref: name,
})
const result = class extends NamedError {
public static readonly Schema = z
.object({
name: z.literal(name),
data: data,
})
.openapi({
ref: name,
})
public static readonly Schema = schema
public readonly name = name as Name
@@ -29,7 +30,10 @@ export abstract class NamedError extends Error {
) {
super(name, options)
this.name = name
log.error(name, this.data)
log.error(name, {
...this.data,
cause: options?.cause?.toString(),
})
}
static isInstance(input: any): input is InstanceType<typeof result> {
@@ -37,7 +41,7 @@ export abstract class NamedError extends Error {
}
schema() {
return result.Schema
return schema
}
toObject() {
+24 -2
View File
@@ -45,6 +45,7 @@ export namespace Log {
)
}
let last = Date.now()
export function create(tags?: Record<string, any>) {
tags = tags || {}
@@ -56,9 +57,13 @@ export namespace Log {
.filter(([_, value]) => value !== undefined && value !== null)
.map(([key, value]) => `${key}=${value}`)
.join(" ")
const next = new Date()
const diff = next.getTime() - last
last = next.getTime()
return (
[new Date().toISOString(), prefix, message].filter(Boolean).join(" ") +
"\n"
[next.toISOString().split(".")[0], "+" + diff + "ms", prefix, message]
.filter(Boolean)
.join(" ") + "\n"
)
}
const result = {
@@ -78,6 +83,23 @@ export namespace Log {
clone() {
return Log.create({ ...tags })
},
time(message: string, extra?: Record<string, any>) {
const now = Date.now()
result.info(message, { status: "started", ...extra })
function stop() {
result.info(message, {
status: "completed",
duration: Date.now() - now,
...extra,
})
}
return {
stop,
[Symbol.dispose]() {
stop()
},
}
},
}
return result
+24 -15
View File
@@ -3,21 +3,33 @@ import { App } from "../../src/app/app"
import { GlobTool } from "../../src/tool/glob"
import { ListTool } from "../../src/tool/ls"
const ctx = {
sessionID: "test",
messageID: "",
abort: AbortSignal.any([]),
metadata: () => {},
}
describe("tool.glob", () => {
test("truncate", async () => {
await App.provide({ cwd: process.cwd(), version: "test" }, async () => {
await App.provide({ cwd: process.cwd() }, async () => {
let result = await GlobTool.execute(
{ pattern: "./node_modules/**/*" },
{ sessionID: "test" },
{
pattern: "./node_modules/**/*",
path: undefined,
},
ctx,
)
expect(result.metadata.truncated).toBe(true)
})
})
test("basic", async () => {
await App.provide({ cwd: process.cwd(), version: "test" }, async () => {
await App.provide({ cwd: process.cwd() }, async () => {
let result = await GlobTool.execute(
{ pattern: "*.json" },
{ sessionID: "test" },
{
pattern: "*.json",
path: undefined,
},
ctx,
)
expect(result.metadata).toMatchObject({
truncated: false,
@@ -29,15 +41,12 @@ describe("tool.glob", () => {
describe("tool.ls", () => {
test("basic", async () => {
const result = await App.provide(
{ cwd: process.cwd(), version: "test" },
async () => {
return await ListTool.execute(
{ path: "./example" },
{ sessionID: "test" },
)
},
)
const result = await App.provide({ cwd: process.cwd() }, async () => {
return await ListTool.execute(
{ path: "./example", ignore: [".git"] },
ctx,
)
})
expect(result.output).toMatchSnapshot()
})
})
-219
View File
@@ -1,219 +0,0 @@
time=2025-05-30T22:01:45.386-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-05-30T22:01:45.391-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-05-30T22:01:50.683-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-05-30T22:01:50.683-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-05-30T22:01:50.683-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-05-30T22:01:50.683-04:00 level=INFO msg="TUI message channel closed"
time=2025-05-30T22:01:50.683-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-05-30T22:01:50.683-04:00 level=INFO msg="TUI exited" result="{width:98 height:57 currentPage:chat previousPage: pages:map[chat:0xc00013b450] loadedPages:map[chat:true] status:{app:0xc0002e05b0 queue:[] width:98 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0002e05b0 showPermissions:false permissions:0xc000159408 showHelp:false help:0xc0006822d0 showQuit:true quit:0xc00024b479 showSessionDialog:false sessionDialog:0xc0001f0240 showCommandDialog:false commandDialog:0xc0003cbba0 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a7a0} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a620}] showModelDialog:false modelDialog:0xc0001f45a0 showInitDialog:false initDialog:{width:98 height:57 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0001f0480 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0001f04c0}"
time=2025-05-30T22:13:24.046-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-05-30T22:13:24.051-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-05-30T22:13:25.991-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-05-30T22:13:25.991-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-05-30T22:13:25.991-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-05-30T22:13:25.991-04:00 level=INFO msg="TUI message channel closed"
time=2025-05-30T22:13:25.991-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-05-30T22:13:25.991-04:00 level=INFO msg="TUI exited" result="{width:199 height:57 currentPage:chat previousPage: pages:map[chat:0xc00025f950] loadedPages:map[chat:true] status:{app:0xc0000ca230 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0000ca230 showPermissions:false permissions:0xc00029f908 showHelp:false help:0xc00045d9b0 showQuit:true quit:0xc0005a0be9 showSessionDialog:false sessionDialog:0xc00012e3c0 showCommandDialog:false commandDialog:0xc0004379e0 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a7a0} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a620}] showModelDialog:false modelDialog:0xc0002f2e60 showInitDialog:false initDialog:{width:199 height:57 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc00013ac88 showThemeDialog:false themeDialog:0xc00012e600 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc00012e640}"
time=2025-05-31T16:00:29.137-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-05-31T16:00:29.141-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-05-31T16:00:36.530-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-05-31T16:00:36.531-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-05-31T16:00:36.531-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-05-31T16:00:36.531-04:00 level=INFO msg="TUI message channel closed"
time=2025-05-31T16:00:36.531-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-05-31T16:00:36.531-04:00 level=INFO msg="TUI exited" result="{width:106 height:54 currentPage:chat previousPage: pages:map[chat:0xc000157450] loadedPages:map[chat:true] status:{app:0xc00020c5b0 queue:[] width:106 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc00020c5b0 showPermissions:false permissions:0xc000175408 showHelp:false help:0xc00070c270 showQuit:true quit:0xc000299979 showSessionDialog:false sessionDialog:0xc0001f02c0 showCommandDialog:false commandDialog:0xc0003cbba0 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a7a0} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a620}] showModelDialog:false modelDialog:0xc00021a5a0 showInitDialog:false initDialog:{width:106 height:54 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0001f0500 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0001f0540}"
time=2025-05-31T16:06:20.089-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-05-31T16:06:20.094-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-05-31T16:06:20.095-04:00 level=ERROR msg="Failed to subscribe to events" error="Get \"http://localhost:16713/event\": dial tcp [::1]:16713: connect: connection refused"
time=2025-05-31T17:54:04.009-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-05-31T17:54:04.014-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-05-31T17:54:06.337-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-05-31T17:54:06.337-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-05-31T17:54:06.337-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-05-31T17:54:06.337-04:00 level=INFO msg="TUI message channel closed"
time=2025-05-31T17:54:06.337-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-05-31T17:54:06.337-04:00 level=INFO msg="TUI exited" result="{width:106 height:25 currentPage:chat previousPage: pages:map[chat:0xc0002332c0] loadedPages:map[chat:true] status:{app:0xc0002b1810 queue:[] width:106 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0002b1810 showPermissions:false permissions:0xc000267408 showHelp:false help:0xc00048dbc0 showQuit:true quit:0xc0004a2719 showSessionDialog:false sessionDialog:0xc000319ec0 showCommandDialog:false commandDialog:0xc000387980 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a7a0} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a620}] showModelDialog:false modelDialog:0xc0000c6960 showInitDialog:false initDialog:{width:106 height:25 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d6c88 showThemeDialog:false themeDialog:0xc0000ac480 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000ac4c0}"
time=2025-05-31T17:54:17.103-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-05-31T17:54:17.108-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-05-31T17:54:18.391-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-05-31T17:54:18.392-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-05-31T17:54:18.392-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-05-31T17:54:18.392-04:00 level=INFO msg="TUI message channel closed"
time=2025-05-31T17:54:18.392-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-05-31T17:54:18.392-04:00 level=INFO msg="TUI exited" result="{width:106 height:25 currentPage:chat previousPage: pages:map[chat:0xc00042a960] loadedPages:map[chat:true] status:{app:0xc000163ce0 queue:[] width:106 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc000163ce0 showPermissions:false permissions:0xc0001df408 showHelp:false help:0xc0005198f0 showQuit:true quit:0xc0003a5ef9 showSessionDialog:false sessionDialog:0xc000323840 showCommandDialog:false commandDialog:0xc00043b0e0 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a7a0} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a620}] showModelDialog:false modelDialog:0xc0004028c0 showInitDialog:false initDialog:{width:106 height:25 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d6c88 showThemeDialog:false themeDialog:0xc000323a80 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc000323ac0}"
time=2025-05-31T17:59:54.360-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-05-31T17:59:54.364-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-05-31T17:59:55.814-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-05-31T17:59:55.815-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-05-31T17:59:55.815-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-05-31T17:59:55.815-04:00 level=INFO msg="TUI message channel closed"
time=2025-05-31T17:59:55.815-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-05-31T17:59:55.815-04:00 level=INFO msg="TUI exited" result="{width:106 height:25 currentPage:chat previousPage: pages:map[chat:0xc0002787d0] loadedPages:map[chat:true] status:{app:0xc0003fed90 queue:[] width:106 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0003fed90 showPermissions:false permissions:0xc0002b1908 showHelp:false help:0xc000126150 showQuit:true quit:0xc00011d439 showSessionDialog:false sessionDialog:0xc00025e380 showCommandDialog:false commandDialog:0xc00047fc00 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a7a0} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a620}] showModelDialog:false modelDialog:0xc0002f6d20 showInitDialog:false initDialog:{width:106 height:25 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0001b2c88 showThemeDialog:false themeDialog:0xc00025e5c0 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc00025e600}"
time=2025-05-31T17:59:56.746-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-05-31T17:59:56.750-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-05-31T18:00:10.757-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-05-31T18:00:10.757-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-05-31T18:00:10.757-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-05-31T18:00:10.757-04:00 level=INFO msg="TUI message channel closed"
time=2025-05-31T18:00:10.757-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-05-31T18:00:10.757-04:00 level=INFO msg="TUI exited" result="{width:211 height:54 currentPage:chat previousPage: pages:map[chat:0xc00053b090] loadedPages:map[chat:true] status:{app:0xc000300cb0 queue:[] width:211 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc000300cb0 showPermissions:false permissions:0xc0002c5408 showHelp:false help:0xc000682f90 showQuit:true quit:0xc0006134d9 showSessionDialog:false sessionDialog:0xc00031f980 showCommandDialog:false commandDialog:0xc0003d9520 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a7a0} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a620}] showModelDialog:false modelDialog:0xc000395220 showInitDialog:false initDialog:{width:211 height:54 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc00013ac88 showThemeDialog:false themeDialog:0xc00031fbc0 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc00031fc00}"
time=2025-05-31T18:35:42.289-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-05-31T18:35:42.294-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-05-31T18:36:54.773-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-05-31T18:36:54.773-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-05-31T18:36:54.773-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-05-31T18:36:54.773-04:00 level=INFO msg="TUI message channel closed"
time=2025-05-31T18:36:54.773-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-05-31T18:36:54.773-04:00 level=INFO msg="TUI exited" result="{width:106 height:25 currentPage:chat previousPage: pages:map[chat:0xc00012f0e0] loadedPages:map[chat:true] status:{app:0xc0002aa070 queue:[] width:106 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0002aa070 showPermissions:false permissions:0xc000267408 showHelp:false help:0xc00041b8f0 showQuit:true quit:0xc000345ee9 showSessionDialog:false sessionDialog:0xc00032ba40 showCommandDialog:false commandDialog:0xc00043b300 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a7a0} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a620}] showModelDialog:false modelDialog:0xc000426f00 showInitDialog:false initDialog:{width:106 height:25 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d6c88 showThemeDialog:false themeDialog:0xc00032bc80 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc00032bcc0}"
time=2025-05-31T18:36:56.011-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-05-31T18:36:56.015-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-05-31T18:37:44.063-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-05-31T18:37:44.064-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-05-31T18:37:44.064-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-05-31T18:37:44.064-04:00 level=INFO msg="TUI message channel closed"
time=2025-05-31T18:37:44.064-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-05-31T18:37:44.064-04:00 level=INFO msg="TUI exited" result="{width:211 height:54 currentPage:chat previousPage: pages:map[chat:0xc000420280] loadedPages:map[chat:true] status:{app:0xc0002d8000 queue:[] width:211 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0002d8000 showPermissions:false permissions:0xc000271408 showHelp:false help:0xc00048da70 showQuit:true quit:0xc000390809 showSessionDialog:false sessionDialog:0xc000323b80 showCommandDialog:false commandDialog:0xc0003e5920 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a7a0} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a620}] showModelDialog:false modelDialog:0xc00025f9a0 showInitDialog:false initDialog:{width:211 height:54 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d6c88 showThemeDialog:false themeDialog:0xc000323dc0 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc000323e00}"
time=2025-05-31T20:32:32.443-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-05-31T20:32:32.448-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-05-31T20:33:09.783-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-05-31T20:33:09.783-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-05-31T20:33:09.783-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-05-31T20:33:09.783-04:00 level=INFO msg="TUI message channel closed"
time=2025-05-31T20:33:09.783-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-05-31T20:33:09.783-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc00032c960] loadedPages:map[chat:true] status:{app:0xc000279420 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc000279420 showPermissions:false permissions:0xc0001fb408 showHelp:false help:0xc000154150 showQuit:true quit:0xc000528849 showSessionDialog:false sessionDialog:0xc000309e40 showCommandDialog:false commandDialog:0xc0003a3800 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a800} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a680}] showModelDialog:false modelDialog:0xc0002e7cc0 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0000ac400 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000ac440}"
time=2025-06-01T14:37:36.423-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-06-01T14:37:36.427-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-06-01T14:38:19.951-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-06-01T14:38:19.951-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-06-01T14:38:19.951-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-06-01T14:38:19.951-04:00 level=INFO msg="TUI message channel closed"
time=2025-06-01T14:38:19.951-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-06-01T14:38:19.951-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc00035b9f0] loadedPages:map[chat:true] status:{app:0xc000226d90 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc000226d90 showPermissions:false permissions:0xc00027f908 showHelp:false help:0xc0005139e0 showQuit:true quit:0xc000510d49 showSessionDialog:false sessionDialog:0xc0001e84c0 showCommandDialog:false commandDialog:0xc00051a160 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a800} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a680}] showModelDialog:false modelDialog:0xc0002675e0 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc00013ac88 showThemeDialog:false themeDialog:0xc0001e8700 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0001e8740}"
time=2025-06-01T14:38:50.886-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-06-01T14:38:50.891-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-06-01T14:38:53.495-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-06-01T14:38:53.495-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-06-01T14:38:53.495-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-06-01T14:38:53.495-04:00 level=INFO msg="TUI message channel closed"
time=2025-06-01T14:38:53.495-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-06-01T14:38:53.495-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc0005ac8c0] loadedPages:map[chat:true] status:{app:0xc0002796c0 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0002796c0 showPermissions:false permissions:0xc00028b408 showHelp:false help:0xc000490d80 showQuit:true quit:0xc000582589 showSessionDialog:false sessionDialog:0xc0003359c0 showCommandDialog:false commandDialog:0xc00042d480 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a800} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a680}] showModelDialog:false modelDialog:0xc000389360 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc000335c00 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc000335c80}"
time=2025-06-01T14:39:49.852-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-06-01T14:39:49.856-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-06-01T14:39:57.071-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-06-01T14:39:57.071-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-06-01T14:39:57.071-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-06-01T14:39:57.071-04:00 level=INFO msg="TUI message channel closed"
time=2025-06-01T14:39:57.071-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-06-01T14:39:57.071-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc000616f00] loadedPages:map[chat:true] status:{app:0xc000333490 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc000333490 showPermissions:false permissions:0xc0004faa08 showHelp:false help:0xc000471140 showQuit:true quit:0xc000459299 showSessionDialog:false sessionDialog:0xc000352500 showCommandDialog:false commandDialog:0xc00041ed80 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a800} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a680}] showModelDialog:false modelDialog:0xc000515a40 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc00013ac88 showThemeDialog:false themeDialog:0xc000352740 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc000352780}"
time=2025-06-01T14:40:21.954-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-06-01T14:40:21.958-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-06-01T14:41:29.195-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-06-01T14:41:29.195-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-06-01T14:41:29.195-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-06-01T14:41:29.195-04:00 level=INFO msg="TUI message channel closed"
time=2025-06-01T14:41:29.195-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-06-01T14:41:29.195-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc0002cc280] loadedPages:map[chat:true] status:{app:0xc0002e64d0 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0002e64d0 showPermissions:false permissions:0xc00026f408 showHelp:false help:0xc00051c1b0 showQuit:true quit:0xc00051a819 showSessionDialog:false sessionDialog:0xc00030fec0 showCommandDialog:false commandDialog:0xc00042d760 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a800} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a680}] showModelDialog:false modelDialog:0xc0002ce1e0 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0000ac480 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000ac4c0}"
time=2025-06-01T14:58:27.272-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-06-01T14:58:27.276-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-06-01T14:58:59.711-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-06-01T14:58:59.711-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-06-01T14:58:59.711-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-06-01T14:58:59.711-04:00 level=INFO msg="TUI message channel closed"
time=2025-06-01T14:58:59.711-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-06-01T14:58:59.711-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc000316280] loadedPages:map[chat:true] status:{app:0xc0002b5810 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0002b5810 showPermissions:false permissions:0xc000269408 showHelp:false help:0xc000490e10 showQuit:true quit:0xc00047a929 showSessionDialog:false sessionDialog:0xc0000adb40 showCommandDialog:false commandDialog:0xc0003e59c0 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a800} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a680}] showModelDialog:false modelDialog:0xc00024fd60 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0000add80 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000addc0}"
time=2025-06-01T15:02:54.453-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-06-01T15:02:54.458-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-06-01T15:02:56.136-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-06-01T15:02:56.136-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-06-01T15:02:56.136-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-06-01T15:02:56.136-04:00 level=INFO msg="TUI message channel closed"
time=2025-06-01T15:02:56.136-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-06-01T15:02:56.136-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc000392ff0] loadedPages:map[chat:true] status:{app:0xc0001ecc40 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0001ecc40 showPermissions:false permissions:0xc000205408 showHelp:false help:0xc00051c0c0 showQuit:true quit:0xc0003b3f49 showSessionDialog:false sessionDialog:0xc000319980 showCommandDialog:false commandDialog:0xc00042d220 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc0005c52c0 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc000319bc0 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc000319c00}"
time=2025-06-01T15:02:57.053-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-06-01T15:02:57.057-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-06-01T15:02:58.135-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-06-01T15:02:58.135-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-06-01T15:02:58.135-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-06-01T15:02:58.135-04:00 level=INFO msg="TUI message channel closed"
time=2025-06-01T15:02:58.135-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-06-01T15:02:58.135-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc0004411d0] loadedPages:map[chat:true] status:{app:0xc00023ee70 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc00023ee70 showPermissions:false permissions:0xc000177408 showHelp:false help:0xc000520030 showQuit:true quit:0xc000314929 showSessionDialog:false sessionDialog:0xc000319d00 showCommandDialog:false commandDialog:0xc0003e5860 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc0002c9a40 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc000319f40 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000ac000}"
time=2025-06-01T15:15:13.582-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-06-01T15:15:13.587-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-06-01T15:15:19.009-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-06-01T15:15:19.010-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-06-01T15:15:19.010-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-06-01T15:15:19.010-04:00 level=INFO msg="TUI message channel closed"
time=2025-06-01T15:15:19.010-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-06-01T15:15:19.010-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc0001490e0] loadedPages:map[chat:true] status:{app:0xc0001efb90 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0001efb90 showPermissions:false permissions:0xc000167408 showHelp:false help:0xc00052c1b0 showQuit:true quit:0xc000254629 showSessionDialog:false sessionDialog:0xc00030fe80 showCommandDialog:false commandDialog:0xc0003a3420 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc0000c6640 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0000ac440 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000ac480}"
time=2025-06-01T15:15:20.678-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-06-01T15:15:20.683-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-06-01T15:15:23.252-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-06-01T15:15:23.253-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-06-01T15:15:23.253-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-06-01T15:15:23.253-04:00 level=INFO msg="TUI message channel closed"
time=2025-06-01T15:15:23.253-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-06-01T15:15:23.253-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc0002c47d0] loadedPages:map[chat:true] status:{app:0xc0003363f0 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0003363f0 showPermissions:false permissions:0xc0002f3408 showHelp:false help:0xc0007055f0 showQuit:true quit:0xc00041c9b9 showSessionDialog:false sessionDialog:0xc00033bd00 showCommandDialog:false commandDialog:0xc000437700 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc0003d9c20 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc00013ac88 showThemeDialog:false themeDialog:0xc00033bf40 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc00034a140}"
time=2025-06-02T11:40:21.643-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-06-02T11:40:21.648-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-06-02T11:40:24.684-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-06-02T11:40:24.684-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-06-02T11:40:24.684-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-06-02T11:40:24.684-04:00 level=INFO msg="TUI message channel closed"
time=2025-06-02T11:40:24.684-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-06-02T11:40:24.684-04:00 level=INFO msg="TUI exited" result="{width:347 height:89 currentPage:chat previousPage: pages:map[chat:0xc00020d180] loadedPages:map[chat:true] status:{app:0xc0002a8230 queue:[] width:347 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0002a8230 showPermissions:false permissions:0xc000239408 showHelp:false help:0xc00051c1e0 showQuit:true quit:0xc000598909 showSessionDialog:false sessionDialog:0xc000309f00 showCommandDialog:false commandDialog:0xc0003a3660 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc0000c4a00 showInitDialog:false initDialog:{width:347 height:89 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0000aa4c0 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000aa500}"
time=2025-06-02T11:40:55.224-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-06-02T11:40:55.228-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-06-02T11:41:03.512-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-06-02T11:41:03.512-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-06-02T11:41:03.512-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-06-02T11:41:03.512-04:00 level=INFO msg="TUI message channel closed"
time=2025-06-02T11:41:03.512-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-06-02T11:41:03.512-04:00 level=INFO msg="TUI exited" result="{width:347 height:89 currentPage:chat previousPage: pages:map[chat:0xc0001467d0] loadedPages:map[chat:true] status:{app:0xc0004feee0 queue:[] width:347 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0004feee0 showPermissions:false permissions:0xc000167408 showHelp:false help:0xc00059cd50 showQuit:true quit:0xc00038aaa9 showSessionDialog:false sessionDialog:0xc00030ff00 showCommandDialog:false commandDialog:0xc0003e5aa0 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc00029be00 showInitDialog:false initDialog:{width:347 height:89 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0000aa4c0 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000aa500}"
time=2025-06-02T11:41:05.131-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-06-02T11:41:05.136-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-06-02T11:41:06.417-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-06-02T11:41:06.417-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-06-02T11:41:06.417-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-06-02T11:41:06.417-04:00 level=INFO msg="TUI message channel closed"
time=2025-06-02T11:41:06.417-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-06-02T11:41:06.417-04:00 level=INFO msg="TUI exited" result="{width:347 height:89 currentPage:chat previousPage: pages:map[chat:0xc0002527d0] loadedPages:map[chat:true] status:{app:0xc0002e0d90 queue:[] width:347 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0002e0d90 showPermissions:false permissions:0xc00027b408 showHelp:false help:0xc0004900c0 showQuit:true quit:0xc00047ae69 showSessionDialog:false sessionDialog:0xc000319f40 showCommandDialog:false commandDialog:0xc00042d880 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc000357e00 showInitDialog:false initDialog:{width:347 height:89 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0000aa500 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000aa540}"
time=2025-06-02T19:36:04.879-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-06-02T19:36:04.883-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-06-02T19:36:07.068-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-06-02T19:36:07.068-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-06-02T19:36:07.068-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-06-02T19:36:07.068-04:00 level=INFO msg="TUI message channel closed"
time=2025-06-02T19:36:07.068-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-06-02T19:36:07.068-04:00 level=INFO msg="TUI exited" result="{width:145 height:36 currentPage:chat previousPage: pages:map[chat:0xc000544b40] loadedPages:map[chat:true] status:{app:0xc000249b90 queue:[] width:145 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc000249b90 showPermissions:false permissions:0xc000207408 showHelp:false help:0xc00011a1e0 showQuit:true quit:0xc0003890b9 showSessionDialog:false sessionDialog:0xc000319f40 showCommandDialog:false commandDialog:0xc0003e5520 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc000547220 showInitDialog:false initDialog:{width:145 height:36 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0000ac500 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000ac540}"
time=2025-06-02T19:44:20.524-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-06-02T19:44:20.529-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-06-02T19:45:43.720-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-06-02T19:45:43.720-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-06-02T19:45:43.720-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-06-02T19:45:43.720-04:00 level=INFO msg="TUI message channel closed"
time=2025-06-02T19:45:43.720-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-06-02T19:45:43.720-04:00 level=INFO msg="TUI exited" result="{width:145 height:36 currentPage:chat previousPage: pages:map[chat:0xc0001f87d0] loadedPages:map[chat:true] status:{app:0xc000270cb0 queue:[] width:145 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc000270cb0 showPermissions:false permissions:0xc00022f408 showHelp:false help:0xc000490e70 showQuit:true quit:0xc000388ab9 showSessionDialog:false sessionDialog:0xc000319f00 showCommandDialog:false commandDialog:0xc0003e55e0 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc00030bd60 showInitDialog:false initDialog:{width:145 height:36 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0000ac4c0 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000ac500}"
time=2025-06-02T19:45:47.456-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-06-02T19:45:47.462-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-06-02T19:46:50.039-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-06-02T19:46:50.039-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-06-02T19:46:50.039-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-06-02T19:46:50.039-04:00 level=INFO msg="TUI message channel closed"
time=2025-06-02T19:46:50.039-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-06-02T19:46:50.039-04:00 level=INFO msg="TUI exited" result="{width:145 height:36 currentPage:chat previousPage: pages:map[chat:0xc00035b9f0] loadedPages:map[chat:true] status:{app:0xc0000ec230 queue:[] width:145 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0000ec230 showPermissions:false permissions:0xc0005e5408 showHelp:false help:0xc0005ad950 showQuit:true quit:0xc0005a0c09 showSessionDialog:false sessionDialog:0xc00012e440 showCommandDialog:false commandDialog:0xc0003c2160 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc0002c74a0 showInitDialog:false initDialog:{width:145 height:36 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc00013ac88 showThemeDialog:false themeDialog:0xc00012e680 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc00012e6c0}"
time=2025-06-02T19:47:11.433-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-06-02T19:47:11.438-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-06-02T19:48:43.841-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-06-02T19:48:43.841-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-06-02T19:48:43.842-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-06-02T19:48:43.842-04:00 level=INFO msg="TUI message channel closed"
time=2025-06-02T19:48:43.842-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-06-02T19:48:43.842-04:00 level=INFO msg="TUI exited" result="{width:145 height:36 currentPage:chat previousPage: pages:map[chat:0xc0001f9040] loadedPages:map[chat:true] status:{app:0xc000270070 queue:[] width:145 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc000270070 showPermissions:false permissions:0xc00022f408 showHelp:false help:0xc000122090 showQuit:true quit:0xc000447c19 showSessionDialog:false sessionDialog:0xc000323b40 showCommandDialog:false commandDialog:0xc0003cb540 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc00044d5e0 showInitDialog:false initDialog:{width:145 height:36 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc000323d80 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc000323dc0}"
time=2025-06-02T19:48:57.679-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-06-02T19:48:57.685-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-06-02T19:50:56.190-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-06-02T19:50:56.190-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-06-02T19:50:56.190-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-06-02T19:50:56.190-04:00 level=INFO msg="TUI message channel closed"
time=2025-06-02T19:50:56.190-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-06-02T19:50:56.190-04:00 level=INFO msg="TUI exited" result="{width:145 height:36 currentPage:chat previousPage: pages:map[chat:0xc000564be0] loadedPages:map[chat:true] status:{app:0xc000250d20 queue:[] width:145 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc000250d20 showPermissions:false permissions:0xc0004d6a08 showHelp:false help:0xc00061d5c0 showQuit:true quit:0xc0005578a9 showSessionDialog:false sessionDialog:0xc00032a640 showCommandDialog:false commandDialog:0xc0003e51e0 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc000620aa0 showInitDialog:false initDialog:{width:145 height:36 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc00032a880 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc00032a8c0}"
+20 -145
View File
@@ -2,17 +2,14 @@ package main
import (
"context"
"encoding/json"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"time"
tea "github.com/charmbracelet/bubbletea/v2"
zone "github.com/lrstanley/bubblezone"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/pubsub"
"github.com/sst/opencode/internal/tui"
"github.com/sst/opencode/pkg/client"
)
@@ -20,18 +17,18 @@ import (
var Version = "dev"
func main() {
url := os.Getenv("OPENCODE_SERVER")
httpClient, err := client.NewClientWithResponses(url)
if err != nil {
slog.Error("Failed to create client", "error", err)
os.Exit(1)
version := Version
if version != "dev" && !strings.HasPrefix(Version, "v") {
version = "v" + Version
}
paths, err := httpClient.PostPathGetWithResponse(context.Background())
if err != nil {
panic(err)
}
logfile := filepath.Join(paths.JSON200.Data, "log", "tui.log")
url := os.Getenv("OPENCODE_SERVER")
appInfoStr := os.Getenv("OPENCODE_APP_INFO")
var appInfo client.AppInfo
json.Unmarshal([]byte(appInfoStr), &appInfo)
logfile := filepath.Join(appInfo.Path.Data, "log", "tui.log")
if _, err := os.Stat(filepath.Dir(logfile)); os.IsNotExist(err) {
err := os.MkdirAll(filepath.Dir(logfile), 0755)
if err != nil {
@@ -48,26 +45,26 @@ func main() {
logger := slog.New(slog.NewTextHandler(file, &slog.HandlerOptions{Level: slog.LevelDebug}))
slog.SetDefault(logger)
httpClient, err := client.NewClientWithResponses(url)
if err != nil {
slog.Error("Failed to create client", "error", err)
os.Exit(1)
}
// Create main context for the application
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
version := Version
if version != "dev" && !strings.HasPrefix(Version, "v") {
version = "v" + Version
}
app_, err := app.New(ctx, version, httpClient)
app_, err := app.New(ctx, version, appInfo, httpClient)
if err != nil {
panic(err)
}
// Set up the TUI
zone.NewGlobal()
program := tea.NewProgram(
tui.NewModel(app_),
// tea.WithMouseCellMotion(),
tea.WithKeyboardEnhancements(),
tea.WithAltScreen(),
tea.WithKeyboardEnhancements(),
tea.WithMouseCellMotion(),
)
eventClient, err := client.NewClient(url)
@@ -88,133 +85,11 @@ func main() {
}
}()
// Setup the subscriptions, this will send services events to the TUI
ch, cancelSubs := setupSubscriptions(app_, ctx)
// Create a context for the TUI message handler
tuiCtx, tuiCancel := context.WithCancel(ctx)
var tuiWg sync.WaitGroup
tuiWg.Add(1)
// Set up message handling for the TUI
go func() {
defer tuiWg.Done()
// defer logging.RecoverPanic("TUI-message-handler", func() {
// attemptTUIRecovery(program)
// })
for {
select {
case <-tuiCtx.Done():
slog.Info("TUI message handler shutting down")
return
case msg, ok := <-ch:
if !ok {
slog.Info("TUI message channel closed")
return
}
program.Send(msg)
}
}
}()
// Cleanup function for when the program exits
cleanup := func() {
// Cancel subscriptions first
cancelSubs()
// Then cancel TUI message handler
tuiCancel()
// Wait for TUI message handler to finish
tuiWg.Wait()
slog.Info("All goroutines cleaned up")
}
// Run the TUI
result, err := program.Run()
cleanup()
if err != nil {
slog.Error("TUI error", "error", err)
// return fmt.Errorf("TUI error: %v", err)
}
slog.Info("TUI exited", "result", result)
}
func setupSubscriber[T any](
ctx context.Context,
wg *sync.WaitGroup,
name string,
subscriber func(context.Context) <-chan pubsub.Event[T],
outputCh chan<- tea.Msg,
) {
wg.Add(1)
go func() {
defer wg.Done()
// defer logging.RecoverPanic(fmt.Sprintf("subscription-%s", name), nil)
subCh := subscriber(ctx)
if subCh == nil {
slog.Warn("subscription channel is nil", "name", name)
return
}
for {
select {
case event, ok := <-subCh:
if !ok {
slog.Info("subscription channel closed", "name", name)
return
}
var msg tea.Msg = event
select {
case outputCh <- msg:
case <-time.After(2 * time.Second):
slog.Warn("message dropped due to slow consumer", "name", name)
case <-ctx.Done():
slog.Info("subscription cancelled", "name", name)
return
}
case <-ctx.Done():
slog.Info("subscription cancelled", "name", name)
return
}
}
}()
}
func setupSubscriptions(app *app.App, parentCtx context.Context) (chan tea.Msg, func()) {
ch := make(chan tea.Msg, 100)
wg := sync.WaitGroup{}
ctx, cancel := context.WithCancel(parentCtx) // Inherit from parent context
setupSubscriber(ctx, &wg, "status", app.Status.Subscribe, ch)
cleanupFunc := func() {
slog.Info("Cancelling all subscriptions")
cancel() // Signal all goroutines to stop
waitCh := make(chan struct{})
go func() {
// defer logging.RecoverPanic("subscription-cleanup", nil)
wg.Wait()
close(waitCh)
}()
select {
case <-waitCh:
slog.Info("All subscription goroutines completed successfully")
close(ch) // Only close after all writers are confirmed done
case <-time.After(5 * time.Second):
slog.Warn("Timed out waiting for some subscription goroutines to complete")
close(ch)
}
}
return ch, cleanupFunc
}
+1 -6
View File
@@ -5,14 +5,12 @@ go 1.24.0
require (
github.com/BurntSushi/toml v1.5.0
github.com/alecthomas/chroma/v2 v2.18.0
github.com/bmatcuk/doublestar/v4 v4.8.1
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3
github.com/charmbracelet/glamour v0.10.0
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1
github.com/charmbracelet/x/ansi v0.8.0
github.com/lithammer/fuzzysearch v1.1.8
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
github.com/muesli/reflow v0.3.0
github.com/muesli/termenv v0.16.0
@@ -27,7 +25,6 @@ require (
dario.cat/mergo v1.0.2 // indirect
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/atombender/go-jsonschema v0.20.0 // indirect
github.com/charmbracelet/bubbletea v1.3.4 // indirect
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
github.com/charmbracelet/x/input v0.3.5-0.20250424101541-abb4d9a9b197 // indirect
@@ -66,13 +63,11 @@ require (
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/disintegration/imaging v1.6.2
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
+2 -13
View File
@@ -24,12 +24,8 @@ github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 h1:swACzss0FjnyPz1enfX56GKkLiuKg5FlyVmOLIlU2kE=
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3 h1:5A2e3myxXMpCES+kjEWgGsaf9VgZXjZbLi5iMTH7j40=
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3/go.mod h1:ZFDg5oPjyRYrPAa3iFrtP1DO8xy+LUQxd9JFHEcuwJY=
github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
@@ -38,8 +34,8 @@ github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 h1:SOylT6+BQzPHEjn15TIzawBPVD0QmhKXbcb3jY0ZIKU=
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc=
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 h1:D9AJJuYTN5pvz6mpIGO1ijLKpfTYSHOtKGgwoTQ4Gog=
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250501183327-ad3bc78c6a81 h1:iGrflaL5jQW6crML+pZx/ulWAVZQR3CQoRGvFsr2Tyg=
@@ -69,8 +65,6 @@ github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cn
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
@@ -126,16 +120,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231 h1:9rjt7AfnrXKNSZhp36A3/4QAZAwGGCGD/p8Bse26zms=
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231/go.mod h1:S5etECMx+sZnW0Gm100Ma9J1PgVCTgNyFaqGu2b08b4=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
@@ -267,7 +257,6 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
File diff suppressed because one or more lines are too long
Binary file not shown.
+167 -98
View File
@@ -5,122 +5,168 @@ import (
"fmt"
"path/filepath"
"sort"
"strings"
"log/slog"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/state"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
"github.com/sst/opencode/pkg/client"
)
var RootPath string
type App struct {
ConfigPath string
Config *config.Config
Client *client.ClientWithResponses
Provider *client.ProviderInfo
Model *client.ModelInfo
Session *client.SessionInfo
Messages []client.MessageInfo
Status status.Service
Commands commands.Registry
Info client.AppInfo
Version string
StatePath string
Configg *client.ConfigInfo
Client *client.ClientWithResponses
State *config.State
Provider *client.ProviderInfo
Model *client.ModelInfo
Session *client.SessionInfo
Messages []client.MessageInfo
Commands commands.CommandRegistry
}
type AppInfo struct {
client.AppInfo
Version string
type SessionSelectedMsg = *client.SessionInfo
type ModelSelectedMsg struct {
Provider client.ProviderInfo
Model client.ModelInfo
}
type SessionClearedMsg struct{}
type CompactSessionMsg struct{}
type SendMsg struct {
Text string
Attachments []Attachment
}
type CompletionDialogTriggerdMsg struct {
InitialValue string
}
var Info AppInfo
func New(
ctx context.Context,
version string,
appInfo client.AppInfo,
httpClient *client.ClientWithResponses,
) (*App, error) {
RootPath = appInfo.Path.Root
func New(ctx context.Context, version string, httpClient *client.ClientWithResponses) (*App, error) {
err := status.InitService()
if err != nil {
slog.Error("Failed to initialize status service", "error", err)
return nil, err
}
appInfoResponse, _ := httpClient.PostAppInfoWithResponse(ctx)
appInfo := appInfoResponse.JSON200
Info = AppInfo{Version: version}
Info.Git = appInfo.Git
Info.Path = appInfo.Path
Info.Time = appInfo.Time
Info.User = appInfo.User
providersResponse, err := httpClient.PostProviderListWithResponse(ctx)
configResponse, err := httpClient.PostConfigGetWithResponse(ctx)
if err != nil {
return nil, err
}
providers := []client.ProviderInfo{}
var defaultProvider *client.ProviderInfo
var defaultModel *client.ModelInfo
var anthropic *client.ProviderInfo
for _, provider := range providersResponse.JSON200.Providers {
if provider.Id == "anthropic" {
anthropic = &provider
configInfo := configResponse.JSON200
if configInfo.Keybinds == nil {
leader := "ctrl+x"
keybinds := client.ConfigKeybinds{
Leader: &leader,
}
configInfo.Keybinds = &keybinds
}
// default to anthropic if available
if anthropic != nil {
defaultProvider = anthropic
defaultModel = getDefaultModel(providersResponse, *anthropic)
}
for _, provider := range providersResponse.JSON200.Providers {
if defaultProvider == nil || defaultModel == nil {
defaultProvider = &provider
defaultModel = getDefaultModel(providersResponse, provider)
}
providers = append(providers, provider)
}
if len(providers) == 0 {
return nil, fmt.Errorf("no providers found")
}
appConfigPath := filepath.Join(Info.Path.Config, "config")
appConfig, err := config.LoadConfig(appConfigPath)
appStatePath := filepath.Join(appInfo.Path.State, "tui")
appState, err := config.LoadState(appStatePath)
if err != nil {
slog.Info("No TUI config found, using default values", "error", err)
appConfig = config.NewConfig("opencode", defaultProvider.Id, defaultModel.Id)
config.SaveConfig(appConfigPath, appConfig)
appState = config.NewState()
config.SaveState(appStatePath, appState)
}
var currentProvider *client.ProviderInfo
var currentModel *client.ModelInfo
for _, provider := range providers {
if provider.Id == appConfig.Provider {
currentProvider = &provider
if configInfo.Theme != nil {
appState.Theme = *configInfo.Theme
}
if configInfo.Model != nil {
splits := strings.Split(*configInfo.Model, "/")
appState.Provider = splits[0]
appState.Model = strings.Join(splits[1:], "/")
}
for _, model := range provider.Models {
if model.Id == appConfig.Model {
currentModel = &model
if appState.Theme != "" {
theme.SetTheme(appState.Theme)
}
slog.Debug("Loaded config", "config", configInfo)
app := &App{
Info: appInfo,
Version: version,
StatePath: appStatePath,
Configg: configInfo,
State: appState,
Client: httpClient,
Session: &client.SessionInfo{},
Messages: []client.MessageInfo{},
Commands: commands.LoadFromConfig(configInfo),
}
return app, nil
}
func (a *App) InitializeProvider() tea.Cmd {
return func() tea.Msg {
providersResponse, err := a.Client.PostProviderListWithResponse(context.Background())
if err != nil {
slog.Error("Failed to list providers", "error", err)
// TODO: notify user
return nil
}
providers := []client.ProviderInfo{}
var defaultProvider *client.ProviderInfo
var defaultModel *client.ModelInfo
var anthropic *client.ProviderInfo
for _, provider := range providersResponse.JSON200.Providers {
if provider.Id == "anthropic" {
anthropic = &provider
}
}
// default to anthropic if available
if anthropic != nil {
defaultProvider = anthropic
defaultModel = getDefaultModel(providersResponse, *anthropic)
}
for _, provider := range providersResponse.JSON200.Providers {
if defaultProvider == nil || defaultModel == nil {
defaultProvider = &provider
defaultModel = getDefaultModel(providersResponse, provider)
}
providers = append(providers, provider)
}
if len(providers) == 0 {
slog.Error("No providers configured")
return nil
}
var currentProvider *client.ProviderInfo
var currentModel *client.ModelInfo
for _, provider := range providers {
if provider.Id == a.State.Provider {
currentProvider = &provider
for _, model := range provider.Models {
if model.Id == a.State.Model {
currentModel = &model
}
}
}
}
if currentProvider == nil || currentModel == nil {
currentProvider = defaultProvider
currentModel = defaultModel
}
// TODO: handle no provider or model setup, yet
return ModelSelectedMsg{
Provider: *currentProvider,
Model: *currentModel,
}
}
app := &App{
ConfigPath: appConfigPath,
Config: appConfig,
Client: httpClient,
Provider: currentProvider,
Model: currentModel,
Session: &client.SessionInfo{},
Messages: []client.MessageInfo{},
Status: status.GetService(),
Commands: commands.NewCommandRegistry(),
}
theme.SetTheme(appConfig.Theme)
return app, nil
}
func getDefaultModel(response *client.PostProviderListResponse, provider client.ProviderInfo) *client.ModelInfo {
@@ -151,8 +197,11 @@ func (a *App) IsBusy() bool {
return lastMessage.Metadata.Time.Completed == nil
}
func (a *App) SaveConfig() {
config.SaveConfig(a.ConfigPath, a.Config)
func (a *App) SaveState() {
err := config.SaveState(a.StatePath, a.State)
if err != nil {
slog.Error("Failed to save state", "error", err)
}
}
func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
@@ -160,31 +209,47 @@ func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
session, err := a.CreateSession(ctx)
if err != nil {
status.Error(err.Error())
// status.Error(err.Error())
return nil
}
a.Session = session
cmds = append(cmds, util.CmdHandler(state.SessionSelectedMsg(session)))
cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
go func() {
// TODO: Handle no provider or model setup, yet
response, err := a.Client.PostSessionInitialize(ctx, client.PostSessionInitializeJSONRequestBody{
SessionID: a.Session.Id,
ProviderID: a.Provider.Id,
ModelID: a.Model.Id,
})
if err != nil {
status.Error(err.Error())
slog.Error("Failed to initialize project", "error", err)
// status.Error(err.Error())
}
if response != nil && response.StatusCode != 200 {
status.Error(fmt.Sprintf("failed to initialize project: %d", response.StatusCode))
slog.Error("Failed to initialize project", "error", response.StatusCode)
// status.Error(fmt.Sprintf("failed to initialize project: %d", response.StatusCode))
}
}()
return tea.Batch(cmds...)
}
func (a *App) CompactSession(ctx context.Context) tea.Cmd {
response, err := a.Client.PostSessionSummarizeWithResponse(ctx, client.PostSessionSummarizeJSONRequestBody{
SessionID: a.Session.Id,
ProviderID: a.Provider.Id,
ModelID: a.Model.Id,
})
if err != nil {
slog.Error("Failed to compact session", "error", err)
}
if response != nil && response.StatusCode() != 200 {
slog.Error("Failed to compact session", "error", response.StatusCode)
}
return nil
}
func (a *App) MarkProjectInitialized(ctx context.Context) error {
response, err := a.Client.PostAppInitialize(ctx)
if err != nil {
@@ -214,11 +279,11 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
if a.Session.Id == "" {
session, err := a.CreateSession(ctx)
if err != nil {
status.Error(err.Error())
// status.Error(err.Error())
return nil
}
a.Session = session
cmds = append(cmds, util.CmdHandler(state.SessionSelectedMsg(session)))
cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
}
// TODO: Handle attachments when API supports them
@@ -243,11 +308,11 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
})
if err != nil {
slog.Error("Failed to send message", "error", err)
status.Error(err.Error())
// status.Error(err.Error())
}
if response != nil && response.StatusCode != 200 {
slog.Error("Failed to send message", "error", fmt.Sprintf("failed to send message: %d", response.StatusCode))
status.Error(fmt.Sprintf("failed to send message: %d", response.StatusCode))
// status.Error(fmt.Sprintf("failed to send message: %d", response.StatusCode))
}
}()
@@ -262,12 +327,12 @@ func (a *App) Cancel(ctx context.Context, sessionID string) error {
})
if err != nil {
slog.Error("Failed to cancel session", "error", err)
status.Error(err.Error())
// status.Error(err.Error())
return err
}
if response != nil && response.StatusCode != 200 {
slog.Error("Failed to cancel session", "error", fmt.Sprintf("failed to cancel session: %d", response.StatusCode))
status.Error(fmt.Sprintf("failed to cancel session: %d", response.StatusCode))
// status.Error(fmt.Sprintf("failed to cancel session: %d", response.StatusCode))
return fmt.Errorf("failed to cancel session: %d", response.StatusCode)
}
return nil
@@ -323,3 +388,7 @@ func (a *App) ListProviders(ctx context.Context) ([]client.ProviderInfo, error)
providers := *resp.JSON200
return providers.Providers, nil
}
// func (a *App) loadCustomKeybinds() {
//
// }
+260 -50
View File
@@ -1,71 +1,281 @@
package commands
import (
"github.com/charmbracelet/bubbles/v2/key"
"encoding/json"
"slices"
"strings"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode/pkg/client"
)
// Command represents a user-triggerable action.
type ExecuteCommandMsg Command
type ExecuteCommandsMsg []Command
type CommandExecutedMsg Command
type Keybinding struct {
RequiresLeader bool
Key string
}
func (k Keybinding) Matches(msg tea.KeyPressMsg, leader bool) bool {
key := k.Key
key = strings.TrimSpace(key)
return key == msg.String() && (k.RequiresLeader == leader)
}
type CommandName string
type Command struct {
// Name is the identifier used for slash commands (e.g., "new").
Name string
// Description is a short explanation of what the command does.
Name CommandName
Description string
// KeyBinding is the keyboard shortcut to trigger this command.
KeyBinding key.Binding
Keybindings []Keybinding
Trigger string
}
// Registry holds all the available commands.
type Registry map[string]Command
// ExecuteCommandMsg is a message sent when a command should be executed.
type ExecuteCommandMsg struct {
Name string
func (c Command) Keys() []string {
var keys []string
for _, k := range c.Keybindings {
keys = append(keys, k.Key)
}
return keys
}
func NewCommandRegistry() Registry {
return Registry{
"help": {
Name: "help",
type CommandRegistry map[CommandName]Command
func (r CommandRegistry) Sorted() []Command {
var commands []Command
for _, command := range r {
commands = append(commands, command)
}
slices.SortFunc(commands, func(a, b Command) int {
if a.Name == AppExitCommand {
return 1
}
if b.Name == AppExitCommand {
return -1
}
return strings.Compare(string(a.Name), string(b.Name))
})
return commands
}
func (r CommandRegistry) Matches(msg tea.KeyPressMsg, leader bool) []Command {
var matched []Command
for _, command := range r.Sorted() {
if command.Matches(msg, leader) {
matched = append(matched, command)
}
}
return matched
}
const (
AppHelpCommand CommandName = "app_help"
EditorOpenCommand CommandName = "editor_open"
SessionNewCommand CommandName = "session_new"
SessionListCommand CommandName = "session_list"
SessionShareCommand CommandName = "session_share"
SessionInterruptCommand CommandName = "session_interrupt"
SessionCompactCommand CommandName = "session_compact"
ToolDetailsCommand CommandName = "tool_details"
ModelListCommand CommandName = "model_list"
ThemeListCommand CommandName = "theme_list"
ProjectInitCommand CommandName = "project_init"
InputClearCommand CommandName = "input_clear"
InputPasteCommand CommandName = "input_paste"
InputSubmitCommand CommandName = "input_submit"
InputNewlineCommand CommandName = "input_newline"
HistoryPreviousCommand CommandName = "history_previous"
HistoryNextCommand CommandName = "history_next"
MessagesPageUpCommand CommandName = "messages_page_up"
MessagesPageDownCommand CommandName = "messages_page_down"
MessagesHalfPageUpCommand CommandName = "messages_half_page_up"
MessagesHalfPageDownCommand CommandName = "messages_half_page_down"
MessagesPreviousCommand CommandName = "messages_previous"
MessagesNextCommand CommandName = "messages_next"
MessagesFirstCommand CommandName = "messages_first"
MessagesLastCommand CommandName = "messages_last"
AppExitCommand CommandName = "app_exit"
)
func (k Command) Matches(msg tea.KeyPressMsg, leader bool) bool {
for _, binding := range k.Keybindings {
if binding.Matches(msg, leader) {
return true
}
}
return false
}
func parseBindings(bindings ...string) []Keybinding {
var parsedBindings []Keybinding
for _, binding := range bindings {
for p := range strings.SplitSeq(binding, ",") {
requireLeader := strings.HasPrefix(p, "<leader>")
keybinding := strings.ReplaceAll(p, "<leader>", "")
keybinding = strings.TrimSpace(keybinding)
parsedBindings = append(parsedBindings, Keybinding{
RequiresLeader: requireLeader,
Key: keybinding,
})
}
}
return parsedBindings
}
func LoadFromConfig(config *client.ConfigInfo) CommandRegistry {
defaults := []Command{
{
Name: AppHelpCommand,
Description: "show help",
KeyBinding: key.NewBinding(
key.WithKeys("f1", "super+/", "super+h"),
),
Keybindings: parseBindings("<leader>h"),
Trigger: "help",
},
"new": {
Name: "new",
{
Name: EditorOpenCommand,
Description: "open editor",
Keybindings: parseBindings("<leader>e"),
Trigger: "editor",
},
{
Name: SessionNewCommand,
Description: "new session",
KeyBinding: key.NewBinding(
key.WithKeys("f2", "super+n"),
),
Keybindings: parseBindings("<leader>n"),
Trigger: "new",
},
"sessions": {
Name: "sessions",
Description: "switch session",
KeyBinding: key.NewBinding(
key.WithKeys("f3", "super+s"),
),
{
Name: SessionListCommand,
Description: "list sessions",
Keybindings: parseBindings("<leader>l"),
Trigger: "sessions",
},
"model": {
Name: "model",
Description: "switch model",
KeyBinding: key.NewBinding(
key.WithKeys("f4", "super+m"),
),
{
Name: SessionShareCommand,
Description: "share session",
Keybindings: parseBindings("<leader>s"),
Trigger: "share",
},
"theme": {
Name: "theme",
Description: "switch theme",
KeyBinding: key.NewBinding(
key.WithKeys("f5", "super+t"),
),
{
Name: SessionInterruptCommand,
Description: "interrupt session",
Keybindings: parseBindings("esc"),
},
"quit": {
Name: "quit",
Description: "quit",
KeyBinding: key.NewBinding(
key.WithKeys("f10", "ctrl+c", "super+q"),
),
{
Name: SessionCompactCommand,
Description: "compact the session",
Keybindings: parseBindings("<leader>c"),
Trigger: "compact",
},
{
Name: ToolDetailsCommand,
Description: "toggle tool details",
Keybindings: parseBindings("<leader>d"),
Trigger: "details",
},
{
Name: ModelListCommand,
Description: "list models",
Keybindings: parseBindings("<leader>m"),
Trigger: "models",
},
{
Name: ThemeListCommand,
Description: "list themes",
Keybindings: parseBindings("<leader>t"),
Trigger: "themes",
},
{
Name: ProjectInitCommand,
Description: "create or 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"),
},
{
Name: HistoryPreviousCommand,
Description: "previous prompt",
Keybindings: parseBindings("up"),
},
{
Name: HistoryNextCommand,
Description: "next prompt",
Keybindings: parseBindings("down"),
},
{
Name: MessagesPageUpCommand,
Description: "page up",
Keybindings: parseBindings("pgup"),
},
{
Name: MessagesPageDownCommand,
Description: "page down",
Keybindings: parseBindings("pgdown"),
},
{
Name: MessagesHalfPageUpCommand,
Description: "half page up",
Keybindings: parseBindings("ctrl+alt+u"),
},
{
Name: MessagesHalfPageDownCommand,
Description: "half page down",
Keybindings: parseBindings("ctrl+alt+d"),
},
{
Name: MessagesPreviousCommand,
Description: "previous message",
Keybindings: parseBindings("ctrl+alt+k"),
},
{
Name: MessagesNextCommand,
Description: "next message",
Keybindings: parseBindings("ctrl+alt+j"),
},
{
Name: MessagesFirstCommand,
Description: "first message",
Keybindings: parseBindings("ctrl+g"),
},
{
Name: MessagesLastCommand,
Description: "last message",
Keybindings: parseBindings("ctrl+alt+g"),
},
{
Name: AppExitCommand,
Description: "exit the app",
Keybindings: parseBindings("ctrl+c", "<leader>q"),
Trigger: "exit",
},
}
registry := make(CommandRegistry)
keybinds := map[string]string{}
marshalled, _ := json.Marshal(*config.Keybinds)
json.Unmarshal(marshalled, &keybinds)
for _, command := range defaults {
if keybind, ok := keybinds[string(command.Name)]; ok {
command.Keybindings = parseBindings(keybind)
}
registry[command.Name] = command
}
return registry
}
+38 -11
View File
@@ -2,10 +2,14 @@ package completions
import (
"sort"
"strings"
"github.com/charmbracelet/lipgloss/v2"
"github.com/lithammer/fuzzysearch/fuzzy"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/theme"
)
type CommandCompletionProvider struct {
@@ -27,15 +31,39 @@ func (c *CommandCompletionProvider) GetEntry() dialog.CompletionItemI {
})
}
func (c *CommandCompletionProvider) GetEmptyMessage() string {
return "no matching commands"
}
func getCommandCompletionItem(cmd commands.Command, space int) dialog.CompletionItemI {
t := theme.CurrentTheme()
spacer := strings.Repeat(" ", space)
title := " /" + cmd.Trigger + lipgloss.NewStyle().Foreground(t.TextMuted()).Render(spacer+cmd.Description)
value := string(cmd.Name)
return dialog.NewCompletionItem(dialog.CompletionItem{
Title: title,
Value: value,
})
}
func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
space := 1
for _, cmd := range c.app.Commands {
if lipgloss.Width(cmd.Trigger) > space {
space = lipgloss.Width(cmd.Trigger)
}
}
space += 2
if query == "" {
// If no query, return all commands
items := []dialog.CompletionItemI{}
for _, cmd := range c.app.Commands {
items = append(items, dialog.NewCompletionItem(dialog.CompletionItem{
Title: " /" + cmd.Name,
Value: "/" + cmd.Name,
}))
if cmd.Trigger == "" {
continue
}
space := space - lipgloss.Width(cmd.Trigger)
items = append(items, getCommandCompletionItem(cmd, space))
}
return items, nil
}
@@ -45,11 +73,12 @@ func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.Comp
commandMap := make(map[string]dialog.CompletionItemI)
for _, cmd := range c.app.Commands {
commandNames = append(commandNames, cmd.Name)
commandMap[cmd.Name] = dialog.NewCompletionItem(dialog.CompletionItem{
Title: " /" + cmd.Name,
Value: "/" + cmd.Name,
})
if cmd.Trigger == "" {
continue
}
space := space - lipgloss.Width(cmd.Trigger)
commandNames = append(commandNames, cmd.Trigger)
commandMap[cmd.Trigger] = getCommandCompletionItem(cmd, space)
}
// Find fuzzy matches
@@ -65,7 +94,5 @@ func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.Comp
items = append(items, item)
}
}
return items, nil
}
@@ -24,6 +24,10 @@ func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI {
})
}
func (cg *filesAndFoldersContextGroup) GetEmptyMessage() string {
return "no matching files"
}
func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) {
response, err := cg.app.Client.PostFileSearchWithResponse(context.Background(), client.PostFileSearchJSONRequestBody{
Query: query,
+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 {
if strings.HasPrefix(input, "/") {
return m.providers["commands"]
}
return m.providers["files"]
}
@@ -1,22 +0,0 @@
package chat
import (
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
type SendMsg struct {
Text string
Attachments []app.Attachment
}
func repo(width int) string {
repo := "github.com/sst/opencode"
t := theme.CurrentTheme()
return styles.BaseStyle().
Foreground(t.TextMuted()).
Width(width).
Render(repo)
}
+146 -306
View File
@@ -3,11 +3,8 @@ package chat
import (
"fmt"
"log/slog"
"os"
"os/exec"
"strings"
"github.com/charmbracelet/bubbles/v2/key"
"github.com/charmbracelet/bubbles/v2/spinner"
"github.com/charmbracelet/bubbles/v2/textarea"
tea "github.com/charmbracelet/bubbletea/v2"
@@ -17,233 +14,74 @@ import (
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/image"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
type EditorComponent interface {
tea.Model
tea.ViewModel
layout.Sizeable
Value() string
Submit() (tea.Model, tea.Cmd)
Clear() (tea.Model, tea.Cmd)
Paste() (tea.Model, tea.Cmd)
Newline() (tea.Model, tea.Cmd)
Previous() (tea.Model, tea.Cmd)
Next() (tea.Model, tea.Cmd)
}
type editorComponent struct {
width int
height int
app *app.App
width, height int
textarea textarea.Model
attachments []app.Attachment
deleteMode bool
history []string
historyIndex int
currentMessage string
spinner spinner.Model
}
type EditorKeyMaps struct {
Send key.Binding
OpenEditor key.Binding
Paste key.Binding
HistoryUp key.Binding
HistoryDown key.Binding
}
type DeleteAttachmentKeyMaps struct {
AttachmentDeleteMode key.Binding
Escape key.Binding
DeleteAllAttachments key.Binding
}
var editorMaps = EditorKeyMaps{
Send: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "send message"),
),
OpenEditor: key.NewBinding(
key.WithKeys("f12"),
key.WithHelp("f12", "open editor"),
),
Paste: key.NewBinding(
key.WithKeys("ctrl+v"),
key.WithHelp("ctrl+v", "paste content"),
),
HistoryUp: key.NewBinding(
key.WithKeys("up"),
key.WithHelp("up", "previous message"),
),
HistoryDown: key.NewBinding(
key.WithKeys("down"),
key.WithHelp("down", "next message"),
),
}
var DeleteKeyMaps = DeleteAttachmentKeyMaps{
AttachmentDeleteMode: key.NewBinding(
key.WithKeys("ctrl+r"),
key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
),
Escape: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "cancel delete mode"),
),
DeleteAllAttachments: key.NewBinding(
key.WithKeys("r"),
key.WithHelp("ctrl+r+r", "delete all attachments"),
),
}
const (
maxAttachments = 5
)
func (m *editorComponent) Init() tea.Cmd {
return tea.Batch(textarea.Blink, m.spinner.Tick)
return tea.Batch(textarea.Blink, m.spinner.Tick, tea.EnableReportFocus)
}
func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
switch msg := msg.(type) {
case dialog.ThemeChangedMsg:
m.textarea = createTextArea(&m.textarea)
case dialog.CompletionSelectedMsg:
if msg.IsCommand {
// Execute the command directly
commandName := strings.TrimPrefix(msg.CompletionValue, "/")
m.textarea.Reset()
return m, util.CmdHandler(commands.ExecuteCommandMsg{Name: commandName})
} else {
// For files, replace the text in the editor
existingValue := m.textarea.Value()
modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
m.textarea.SetValue(modifiedValue)
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)
}
case tea.KeyPressMsg:
// Maximize editor responsiveness for printable characters
if msg.Text != "" {
m.textarea, cmd = m.textarea.Update(msg)
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()
// // TODO: ?
// if key.Matches(msg, messageKeys.PageUp) ||
// key.Matches(msg, messageKeys.PageDown) ||
// key.Matches(msg, messageKeys.HalfPageUp) ||
// key.Matches(msg, messageKeys.HalfPageDown) {
// return m, nil
// }
// 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()
}
case dialog.ThemeSelectedMsg:
m.textarea = createTextArea(&m.textarea)
m.spinner = createSpinner()
return m, tea.Batch(m.spinner.Tick, textarea.Blink)
case dialog.CompletionSelectedMsg:
if msg.IsCommand {
commandName := strings.TrimPrefix(msg.CompletionValue, "/")
m.textarea.Reset()
return m, util.CmdHandler(
commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)]),
)
} else {
existingValue := m.textarea.Value()
modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
m.textarea.SetValue(modifiedValue + " ")
return m, nil
}
}
@@ -258,8 +96,8 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m *editorComponent) View() string {
t := theme.CurrentTheme()
base := styles.BaseStyle().Render
muted := styles.Muted().Render
base := styles.BaseStyle().Background(t.Background()).Render
muted := styles.Muted().Background(t.Background()).Render
promptStyle := lipgloss.NewStyle().
Padding(0, 0, 0, 1).
Bold(true).
@@ -277,37 +115,34 @@ func (m *editorComponent) View() string {
PaddingBottom(1).
Background(t.BackgroundElement()).
Border(lipgloss.ThickBorder(), false, true).
BorderForeground(t.BackgroundSubtle()).
BorderForeground(t.BackgroundElement()).
BorderBackground(t.Background()).
Render(textarea)
hint := base("enter") + muted(" send ") + base("shift") + muted("+") + base("enter") + muted(" newline")
hint := base("enter") + muted(" send ")
if m.app.IsBusy() {
hint = muted("working") + m.spinner.View() + muted(" ") + base("esc") + muted(" interrupt")
}
model := ""
if m.app.Model != nil {
model = base(m.app.Model.Name) + muted(" • /model")
model = muted(m.app.Provider.Name) + base(" "+m.app.Model.Name)
}
space := m.width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
spacer := lipgloss.NewStyle().Width(space).Render("")
spacer := lipgloss.NewStyle().Background(t.Background()).Width(space).Render("")
info := lipgloss.JoinHorizontal(lipgloss.Left, hint, spacer, model)
info = styles.Padded().Render(info)
content := lipgloss.JoinVertical(
lipgloss.Top,
// m.attachmentsContent(),
"",
textarea,
info,
)
info := hint + spacer + model
info = styles.Padded().Background(t.Background()).Render(info)
content := strings.Join([]string{"", textarea, info}, "\n")
return content
}
func (m *editorComponent) GetSize() (width, height int) {
return m.width, m.height
}
func (m *editorComponent) SetSize(width, height int) tea.Cmd {
m.width = width
m.height = height
@@ -316,54 +151,22 @@ func (m *editorComponent) SetSize(width, height int) tea.Cmd {
return nil
}
func (m *editorComponent) GetSize() (int, int) {
return m.width, m.height
func (m *editorComponent) Value() string {
return m.textarea.Value()
}
func (m *editorComponent) openEditor(value string) tea.Cmd {
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "nvim"
}
tmpfile, err := os.CreateTemp("", "msg_*.md")
tmpfile.WriteString(value)
if err != nil {
status.Error(err.Error())
return nil
}
tmpfile.Close()
c := exec.Command(editor, tmpfile.Name()) //nolint:gosec
c.Stdin = os.Stdin
c.Stdout = os.Stdout
c.Stderr = os.Stderr
return tea.ExecProcess(c, func(err error) tea.Msg {
if err != nil {
status.Error(err.Error())
return nil
}
content, err := os.ReadFile(tmpfile.Name())
if err != nil {
status.Error(err.Error())
return nil
}
if len(content) == 0 {
status.Warn("Message is empty")
return nil
}
os.Remove(tmpfile.Name())
attachments := m.attachments
m.attachments = nil
return SendMsg{
Text: string(content),
Attachments: attachments,
}
})
}
func (m *editorComponent) send() tea.Cmd {
value := strings.TrimSpace(m.textarea.Value())
func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
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
}
attachments := m.attachments
// Save to history if not empty and not a duplicate of the last entry
@@ -376,52 +179,82 @@ func (m *editorComponent) send() tea.Cmd {
}
m.attachments = nil
if value == "" {
return nil
}
// Check for slash command
// if strings.HasPrefix(value, "/") {
// commandName := strings.TrimPrefix(value, "/")
// if _, ok := m.app.Commands[commandName]; ok {
// return util.CmdHandler(commands.ExecuteCommandMsg{Name: commandName})
// }
// }
slog.Info("Send message", "value", value)
return tea.Batch(
util.CmdHandler(SendMsg{
return m, tea.Batch(
util.CmdHandler(app.SendMsg{
Text: value,
Attachments: attachments,
}),
)
}
func (m *editorComponent) attachmentsContent() string {
if len(m.attachments) == 0 {
return ""
}
func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
m.textarea.Reset()
return m, nil
}
t := theme.CurrentTheme()
var styledAttachments []string
attachmentStyles := styles.BaseStyle().
MarginLeft(1).
Background(t.TextMuted()).
Foreground(t.Text())
for i, attachment := range m.attachments {
var filename string
if len(attachment.FileName) > 10 {
filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7])
} else {
filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName)
}
if m.deleteMode {
filename = fmt.Sprintf("%d%s", i, filename)
}
styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
imageBytes, text, err := image.GetImageFromClipboard()
if err != nil {
slog.Error(err.Error())
return m, nil
}
content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
return content
if len(imageBytes) != 0 {
attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments))
attachment := app.Attachment{FilePath: attachmentName, FileName: attachmentName, Content: imageBytes, MimeType: "image/png"}
m.attachments = append(m.attachments, attachment)
} else {
m.textarea.SetValue(m.textarea.Value() + text)
}
return m, nil
}
func (m *editorComponent) Newline() (tea.Model, tea.Cmd) {
value := m.textarea.Value()
m.textarea.SetValue(value + "\n")
return m, nil
}
func (m *editorComponent) Previous() (tea.Model, tea.Cmd) {
currentLine := m.textarea.Line()
// Only navigate history if we're at the first line
if currentLine == 0 && len(m.history) > 0 {
// Save current message if we're just starting to navigate
if m.historyIndex == len(m.history) {
m.currentMessage = m.textarea.Value()
}
// Go to previous message in history
if m.historyIndex > 0 {
m.historyIndex--
m.textarea.SetValue(m.history[m.historyIndex])
}
return m, nil
}
return m, nil
}
func (m *editorComponent) Next() (tea.Model, tea.Cmd) {
currentLine := m.textarea.Line()
value := m.textarea.Value()
lines := strings.Split(value, "\n")
totalLines := len(lines)
// Only navigate history if we're at the last line
if currentLine == totalLines-1 {
if m.historyIndex < len(m.history)-1 {
// Go to next message in history
m.historyIndex++
m.textarea.SetValue(m.history[m.historyIndex])
} else if m.historyIndex == len(m.history)-1 {
// Return to the current message being composed
m.historyIndex = len(m.history)
m.textarea.SetValue(m.currentMessage)
}
return m, nil
}
return m, nil
}
func createTextArea(existing *textarea.Model) textarea.Model {
@@ -456,12 +289,19 @@ func createTextArea(existing *textarea.Model) textarea.Model {
return ta
}
func (m *editorComponent) GetValue() string {
return m.textarea.Value()
func createSpinner() spinner.Model {
return spinner.New(
spinner.WithSpinner(spinner.Ellipsis),
spinner.WithStyle(
styles.
Muted().
Background(theme.CurrentTheme().Background()).
Width(3)),
)
}
func NewEditorComponent(app *app.App) layout.ModelWithView {
s := spinner.New(spinner.WithSpinner(spinner.Ellipsis), spinner.WithStyle(styles.Muted().Width(3)))
func NewEditorComponent(app *app.App) EditorComponent {
s := createSpinner()
ta := createTextArea(nil)
return &editorComponent{
@@ -2,7 +2,6 @@ package chat
import (
"fmt"
"log/slog"
"path/filepath"
"slices"
"strings"
@@ -24,7 +23,7 @@ import (
func toMarkdown(content string, width int, backgroundColor compat.AdaptiveColor) string {
r := styles.GetMarkdownRenderer(width, backgroundColor)
content = strings.ReplaceAll(content, app.Info.Path.Root+"/", "")
content = strings.ReplaceAll(content, app.RootPath+"/", "")
rendered, _ := r.Render(content)
lines := strings.Split(rendered, "\n")
@@ -131,8 +130,8 @@ func renderContentBlock(content string, options ...renderingOption) string {
}
style := styles.BaseStyle().
MarginTop(renderer.marginTop).
MarginBottom(renderer.marginBottom).
// MarginTop(renderer.marginTop).
// MarginBottom(renderer.marginBottom).
PaddingTop(renderer.paddingTop).
PaddingBottom(renderer.paddingBottom).
PaddingLeft(renderer.paddingLeft).
@@ -180,26 +179,42 @@ func renderContentBlock(content string, options ...renderingOption) string {
layout.Current.Container.Width,
align,
content,
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
)
content = lipgloss.PlaceHorizontal(
layout.Current.Viewport.Width,
lipgloss.Center,
content,
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
)
if renderer.marginTop > 0 {
for range renderer.marginTop {
content = "\n" + content
}
}
if renderer.marginBottom > 0 {
for range renderer.marginBottom {
content = content + "\n"
}
}
return content
}
func calculatePadding() int {
if layout.Current.Viewport.Width < 80 {
return 5
} else if layout.Current.Viewport.Width < 120 {
return 15
} else {
return 20
}
}
func renderText(message client.MessageInfo, text string, author string) string {
t := theme.CurrentTheme()
width := layout.Current.Container.Width
padding := 0
if layout.Current.Viewport.Width < 80 {
padding = 5
} else if layout.Current.Viewport.Width < 120 {
padding = 15
} else {
padding = 20
}
padding := calculatePadding()
timestamp := time.UnixMilli(int64(message.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM")
if time.Now().Format("02 Jan 2006") == timestamp[:11] {
@@ -208,18 +223,13 @@ func renderText(message client.MessageInfo, text string, author string) string {
}
info := fmt.Sprintf("%s (%s)", author, timestamp)
align := lipgloss.Left
switch message.Role {
case client.User:
align = lipgloss.Right
case client.Assistant:
align = lipgloss.Left
}
textWidth := max(lipgloss.Width(text), lipgloss.Width(info))
markdownWidth := min(textWidth, width-padding-4) // -4 for the border and padding
if message.Role == client.Assistant {
markdownWidth = width - padding - 4
}
content := toMarkdown(text, markdownWidth, t.BackgroundSubtle())
content = lipgloss.JoinVertical(align, content, info)
content = strings.Join([]string{content, info}, "\n")
switch message.Role {
case client.User:
@@ -240,7 +250,8 @@ func renderToolInvocation(
toolCall client.MessageToolInvocationToolCall,
result *string,
metadata client.MessageInfo_Metadata_Tool_AdditionalProperties,
showResult bool,
showDetails bool,
isLast bool,
) string {
ignoredTools := []string{"opencode_todoread"}
if slices.Contains(ignoredTools, toolCall.ToolName) {
@@ -251,7 +262,7 @@ func renderToolInvocation(
innerWidth := outerWidth - 6
paddingTop := 0
paddingBottom := 0
if showResult {
if showDetails {
paddingTop = 1
if result == nil || *result == "" {
paddingBottom = 1
@@ -268,12 +279,26 @@ func renderToolInvocation(
PaddingRight(2).
BorderLeft(true).
BorderRight(true).
BorderBackground(t.Background()).
BorderForeground(t.BackgroundSubtle()).
BorderStyle(lipgloss.ThickBorder())
if toolCall.State == "partial-call" {
title := renderToolAction(toolCall.ToolName)
if !showDetails {
title = "∟ " + title
padding := calculatePadding()
style := lipgloss.NewStyle().Width(outerWidth - padding - 4).Background(t.BackgroundSubtle())
return renderContentBlock(style.Render(title),
WithAlign(lipgloss.Left),
WithBorderColor(t.Accent()),
WithPaddingTop(0),
WithPaddingBottom(1),
)
}
style = style.Foreground(t.TextMuted())
return style.Render(renderToolAction(toolCall.ToolName))
return style.Render(title)
}
toolArgs := ""
@@ -292,10 +317,6 @@ func renderToolInvocation(
}
}
if len(toolArgsMap) == 0 {
slog.Debug("no args")
}
body := ""
error := ""
finished := result != nil && *result != ""
@@ -331,7 +352,7 @@ func renderToolInvocation(
switch toolCall.ToolName {
case "opencode_read":
toolArgs = renderArgs(&toolArgsMap, "filePath")
title = fmt.Sprintf("Read: %s %s", toolArgs, elapsed)
title = fmt.Sprintf("READ %s %s", toolArgs, elapsed)
if preview, ok := metadata.Get("preview"); ok && toolArgsMap["filePath"] != nil {
filename := toolArgsMap["filePath"].(string)
body = preview.(string)
@@ -339,7 +360,7 @@ func renderToolInvocation(
}
case "opencode_edit":
if filename, ok := toolArgsMap["filePath"].(string); ok {
title = fmt.Sprintf("Edit: %s %s", relative(filename), elapsed)
title = fmt.Sprintf("EDIT %s %s", relative(filename), elapsed)
if d, ok := metadata.Get("diff"); ok {
patch := d.(string)
var formattedDiff string
@@ -356,12 +377,13 @@ func renderToolInvocation(
formattedDiff = strings.TrimSpace(formattedDiff)
formattedDiff = lipgloss.NewStyle().
BorderStyle(lipgloss.ThickBorder()).
BorderBackground(t.Background()).
BorderForeground(t.BackgroundSubtle()).
BorderLeft(true).
BorderRight(true).
Render(formattedDiff)
if showResult {
if showDetails {
style = style.Width(lipgloss.Width(formattedDiff))
title += "\n"
}
@@ -373,19 +395,20 @@ func renderToolInvocation(
lipgloss.Center,
lipgloss.Top,
body,
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
)
}
}
case "opencode_write":
if filename, ok := toolArgsMap["filePath"].(string); ok {
title = fmt.Sprintf("Write: %s %s", relative(filename), elapsed)
title = fmt.Sprintf("WRITE %s %s", relative(filename), elapsed)
if content, ok := toolArgsMap["content"].(string); ok {
body = renderFile(filename, content)
}
}
case "opencode_bash":
if description, ok := toolArgsMap["description"].(string); ok {
title = fmt.Sprintf("Shell: %s %s", description, elapsed)
title = fmt.Sprintf("SHELL %s %s", description, elapsed)
}
if stdout, ok := metadata.Get("stdout"); ok {
command := toolArgsMap["command"].(string)
@@ -396,7 +419,7 @@ func renderToolInvocation(
}
case "opencode_webfetch":
toolArgs = renderArgs(&toolArgsMap, "url")
title = fmt.Sprintf("Fetching: %s %s", toolArgs, elapsed)
title = fmt.Sprintf("FETCH %s %s", toolArgs, elapsed)
if format, ok := toolArgsMap["format"].(string); ok {
body = *result
body = truncateHeight(body, 10)
@@ -406,7 +429,7 @@ func renderToolInvocation(
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
}
case "opencode_todowrite":
title = fmt.Sprintf("Planning %s", elapsed)
title = fmt.Sprintf("PLAN %s", elapsed)
if to, ok := metadata.Get("todos"); ok && finished {
todos := to.([]any)
@@ -427,12 +450,28 @@ func renderToolInvocation(
}
default:
toolName := renderToolName(toolCall.ToolName)
title = fmt.Sprintf("%s: %s %s", toolName, toolArgs, elapsed)
title = fmt.Sprintf("%s %s %s", toolName, toolArgs, elapsed)
body = *result
body = truncateHeight(body, 10)
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
}
if !showDetails {
title = "∟ " + title
padding := calculatePadding()
style := lipgloss.NewStyle().Width(outerWidth - padding - 4).Background(t.BackgroundSubtle())
paddingBottom := 0
if isLast {
paddingBottom = 1
}
return renderContentBlock(style.Render(title),
WithAlign(lipgloss.Left),
WithBorderColor(t.Accent()),
WithPaddingTop(0),
WithPaddingBottom(paddingBottom),
)
}
if body == "" && error == "" {
body = *result
body = truncateHeight(body, 10)
@@ -440,11 +479,16 @@ func renderToolInvocation(
}
content := style.Render(title)
content = lipgloss.PlaceHorizontal(layout.Current.Viewport.Width, lipgloss.Center, content)
if showResult && body != "" && error == "" {
content = lipgloss.PlaceHorizontal(
layout.Current.Viewport.Width,
lipgloss.Center,
content,
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
)
if showDetails && body != "" && error == "" {
content += "\n" + body
}
if showResult && error != "" {
if showDetails && error != "" {
content += "\n" + error
}
return content
@@ -455,19 +499,17 @@ func renderToolName(name string) string {
// case agent.AgentToolName:
// return "Task"
case "opencode_ls":
return "List"
return "LIST"
case "opencode_webfetch":
return "Fetch"
case "opencode_todoread":
return "Planning"
return "FETCH"
case "opencode_todowrite":
return "Planning"
return "PLAN"
default:
normalizedName := name
if strings.HasPrefix(name, "opencode_") {
normalizedName = strings.TrimPrefix(name, "opencode_")
}
return cases.Title(language.Und).String(normalizedName)
return cases.Upper(language.Und).String(normalizedName)
}
}
@@ -533,6 +575,8 @@ func renderToolAction(name string) string {
return "Reading file..."
case "opencode_write":
return "Preparing write..."
case "opencode_todowrite", "opencode_todoread":
return "Planning..."
case "opencode_patch":
return "Preparing patch..."
case "opencode_batch":
@@ -575,7 +619,7 @@ func truncateHeight(content string, height int) string {
}
func relative(path string) string {
return strings.TrimPrefix(path, app.Info.Path.Root+"/")
return strings.TrimPrefix(path, app.RootPath+"/")
}
func extension(path string) string {
+129 -91
View File
@@ -1,10 +1,10 @@
package chat
import (
"slices"
"strings"
"time"
"github.com/charmbracelet/bubbles/v2/key"
"github.com/charmbracelet/bubbles/v2/spinner"
"github.com/charmbracelet/bubbles/v2/viewport"
tea "github.com/charmbracelet/bubbletea/v2"
@@ -12,51 +12,37 @@ import (
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/state"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/pkg/client"
)
type MessagesComponent interface {
tea.Model
tea.ViewModel
PageUp() (tea.Model, tea.Cmd)
PageDown() (tea.Model, tea.Cmd)
HalfPageUp() (tea.Model, tea.Cmd)
HalfPageDown() (tea.Model, tea.Cmd)
First() (tea.Model, tea.Cmd)
Last() (tea.Model, tea.Cmd)
// Previous() (tea.Model, tea.Cmd)
// Next() (tea.Model, tea.Cmd)
}
type messagesComponent struct {
app *app.App
width, height int
app *app.App
viewport viewport.Model
spinner spinner.Model
rendering bool
attachments viewport.Model
showToolResults bool
cache *MessageCache
rendering bool
showToolDetails bool
tail bool
}
type renderFinishedMsg struct{}
type ToggleToolMessagesMsg struct{}
type MessageKeys struct {
PageDown key.Binding
PageUp key.Binding
HalfPageUp key.Binding
HalfPageDown key.Binding
}
var messageKeys = MessageKeys{
PageDown: key.NewBinding(
key.WithKeys("pgdown"),
key.WithHelp("f/pgdn", "page down"),
),
PageUp: key.NewBinding(
key.WithKeys("pgup"),
key.WithHelp("b/pgup", "page up"),
),
HalfPageUp: key.NewBinding(
key.WithKeys("ctrl+u"),
key.WithHelp("ctrl+u", "½ page up"),
),
HalfPageDown: key.NewBinding(
key.WithKeys("ctrl+d", "ctrl+d"),
key.WithHelp("ctrl+d", "½ page down"),
),
}
type ToggleToolDetailsMsg struct{}
func (m *messagesComponent) Init() tea.Cmd {
return tea.Batch(m.viewport.Init(), m.spinner.Tick)
@@ -64,50 +50,45 @@ func (m *messagesComponent) Init() tea.Cmd {
func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case SendMsg:
switch msg.(type) {
case app.SendMsg:
m.viewport.GotoBottom()
m.tail = true
return m, nil
case dialog.ThemeChangedMsg:
case dialog.ThemeSelectedMsg:
m.cache.Clear()
m.renderView()
return m, nil
case ToggleToolMessagesMsg:
m.showToolResults = !m.showToolResults
case ToggleToolDetailsMsg:
m.showToolDetails = !m.showToolDetails
m.renderView()
return m, nil
case state.SessionSelectedMsg:
case app.SessionSelectedMsg:
m.cache.Clear()
cmd := m.Reload()
m.viewport.GotoBottom()
return m, cmd
case state.SessionClearedMsg:
case app.SessionClearedMsg:
m.cache.Clear()
cmd := m.Reload()
return m, cmd
case tea.KeyMsg:
if key.Matches(msg, messageKeys.PageUp) ||
key.Matches(msg, messageKeys.PageDown) ||
key.Matches(msg, messageKeys.HalfPageUp) ||
key.Matches(msg, messageKeys.HalfPageDown) {
u, cmd := m.viewport.Update(msg)
m.viewport = u
m.tail = m.viewport.AtBottom()
cmds = append(cmds, cmd)
}
case renderFinishedMsg:
m.rendering = false
if m.tail {
m.viewport.GotoBottom()
}
case state.StateUpdatedMsg:
case client.EventSessionUpdated, client.EventMessageUpdated:
m.renderView()
if m.tail {
m.viewport.GotoBottom()
}
}
viewport, cmd := m.viewport.Update(msg)
m.viewport = viewport
m.tail = m.viewport.AtBottom()
cmds = append(cmds, cmd)
spinner, cmd := m.spinner.Update(msg)
m.spinner = spinner
cmds = append(cmds, cmd)
@@ -135,16 +116,27 @@ func (m *messagesComponent) renderView() {
for _, message := range m.app.Messages {
var content string
var cached bool
lastToolIndex := 0
lastToolIndices := []int{}
for i, p := range message.Parts {
part, _ := p.ValueByDiscriminator()
switch part.(type) {
case client.MessagePartText:
lastToolIndices = append(lastToolIndices, lastToolIndex)
case client.MessagePartToolInvocation:
lastToolIndex = i
}
}
author := ""
switch message.Role {
case client.User:
author = app.Info.User
author = m.app.Info.User
case client.Assistant:
author = message.Metadata.Assistant.ModelID
}
for _, p := range message.Parts {
for i, p := range message.Parts {
part, err := p.ValueByDiscriminator()
if err != nil {
continue //TODO: handle error?
@@ -171,6 +163,7 @@ func (m *messagesComponent) renderView() {
previousBlockType = assistantTextBlock
}
case client.MessagePartToolInvocation:
isLastToolInvocation := slices.Contains(lastToolIndices, i)
toolInvocationPart := part.(client.MessagePartToolInvocation)
toolCall, _ := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolCall()
metadata := client.MessageInfo_Metadata_Tool_AdditionalProperties{}
@@ -186,20 +179,32 @@ func (m *messagesComponent) renderView() {
if toolCall.State == "result" {
key := m.cache.GenerateKey(message.Id,
toolCall.ToolCallId,
m.showToolResults,
m.showToolDetails,
layout.Current.Viewport.Width,
)
content, cached = m.cache.Get(key)
if !cached {
content = renderToolInvocation(toolCall, result, metadata, m.showToolResults)
content = renderToolInvocation(
toolCall,
result,
metadata,
m.showToolDetails,
isLastToolInvocation,
)
m.cache.Set(key, content)
}
} else {
// if the tool call isn't finished, never cache
content = renderToolInvocation(toolCall, result, metadata, m.showToolResults)
// if the tool call isn't finished, don't cache
content = renderToolInvocation(
toolCall,
result,
metadata,
m.showToolDetails,
isLastToolInvocation,
)
}
if previousBlockType != toolInvocationBlock {
if previousBlockType != toolInvocationBlock && m.showToolDetails {
blocks = append(blocks, "")
}
blocks = append(blocks, content)
@@ -242,8 +247,8 @@ func (m *messagesComponent) header() string {
t := theme.CurrentTheme()
width := layout.Current.Container.Width
base := styles.BaseStyle().Render
muted := styles.Muted().Render
base := styles.BaseStyle().Background(t.Background()).Render
muted := styles.Muted().Background(t.Background()).Render
headerLines := []string{}
headerLines = append(headerLines, toMarkdown("# "+m.app.Session.Title, width-6, t.Background()))
if m.app.Session.Share != nil && m.app.Session.Share.Url != "" {
@@ -257,11 +262,11 @@ func (m *messagesComponent) header() string {
Width(width).
PaddingLeft(2).
PaddingRight(2).
// Background(t.BackgroundElement()).
Background(t.Background()).
BorderLeft(true).
BorderRight(true).
BorderBackground(t.Background()).
BorderForeground(t.BackgroundSubtle()).
BorderForeground(t.BackgroundElement()).
BorderStyle(lipgloss.ThickBorder()).
Render(header)
@@ -275,23 +280,25 @@ func (m *messagesComponent) View() string {
if m.rendering {
return m.viewport.View()
}
t := theme.CurrentTheme()
return lipgloss.JoinVertical(
lipgloss.Left,
lipgloss.PlaceHorizontal(m.width, lipgloss.Center, m.header()),
lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
m.header(),
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
),
m.viewport.View(),
)
}
func (m *messagesComponent) home() string {
// t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle().Background(t.Background())
base := baseStyle.Render
muted := styles.Muted().Render
muted := styles.Muted().Background(t.Background()).Render
// mark := `
// ███▀▀█
// ███ █
// ▀▀▀▀▀▀ `
open := `
█▀▀█ █▀▀█ █▀▀ █▀▀▄
█░░█ █░░█ █▀▀ █░░█
@@ -303,9 +310,8 @@ func (m *messagesComponent) home() string {
logo := lipgloss.JoinHorizontal(
lipgloss.Top,
// styles.BaseStyle().Foreground(t.Primary()).Render(mark),
styles.Muted().Render(open),
styles.BaseStyle().Render(code),
muted(open),
base(code),
)
// cwd := app.Info.Path.Cwd
// config := app.Info.Path.Config
@@ -315,19 +321,19 @@ func (m *messagesComponent) home() string {
{"/sessions", "list sessions"},
{"/new", "start a new session"},
{"/model", "switch model"},
{"/share", "share the current session"},
{"/exit", "exit the app"},
{"/theme", "switch theme"},
{"/quit", "exit the app"},
}
commandLines := []string{}
for _, command := range commands {
commandLines = append(commandLines, (base(command[0]) + " " + muted(command[1])))
commandLines = append(commandLines, (base(command[0]+" ") + muted(command[1])))
}
logoAndVersion := lipgloss.JoinVertical(
lipgloss.Right,
logo,
muted(app.Info.Version),
muted(m.app.Version),
)
lines := []string{}
@@ -341,18 +347,21 @@ func (m *messagesComponent) home() string {
lines = append(lines, commandLines...)
lines = append(lines, "")
if m.rendering {
lines = append(lines, styles.Muted().Render("Loading session..."))
lines = append(lines, base("Loading session..."))
} else {
lines = append(lines, "")
}
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
return lipgloss.Place(
m.width,
m.height,
lipgloss.Center,
lipgloss.Center,
baseStyle.Width(lipgloss.Width(logoAndVersion)).Render(
lipgloss.JoinVertical(
lipgloss.Top,
lines...,
),
))
strings.Join(lines, "\n"),
),
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
)
}
func (m *messagesComponent) SetSize(width, height int) tea.Cmd {
@@ -385,26 +394,55 @@ 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 NewMessagesComponent(app *app.App) MessagesComponent {
customSpinner := spinner.Spinner{
Frames: []string{" ", "┃", "┃"},
FPS: time.Second / 3,
}
s := spinner.New(spinner.WithSpinner(customSpinner))
vp := viewport.New() //(0, 0)
attachments := viewport.New() //(0, 0)
vp.KeyMap.PageUp = messageKeys.PageUp
vp.KeyMap.PageDown = messageKeys.PageDown
vp.KeyMap.HalfPageUp = messageKeys.HalfPageUp
vp.KeyMap.HalfPageDown = messageKeys.HalfPageDown
vp := viewport.New()
attachments := viewport.New()
vp.KeyMap = viewport.KeyMap{}
return &messagesComponent{
app: app,
viewport: vp,
spinner: s,
attachments: attachments,
showToolResults: true,
showToolDetails: true,
cache: NewMessageCache(),
tail: true,
}
@@ -1,264 +0,0 @@
package core
import (
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/pubsub"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
type StatusComponent interface {
layout.ModelWithView
}
type statusComponent struct {
app *app.App
queue []status.StatusMessage
width int
messageTTL time.Duration
activeUntil time.Time
}
// clearMessageCmd is a command that clears status messages after a timeout
func (m statusComponent) clearMessageCmd() tea.Cmd {
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
return statusCleanupMsg{time: t}
})
}
// statusCleanupMsg is a message that triggers cleanup of expired status messages
type statusCleanupMsg struct {
time time.Time
}
func (m statusComponent) Init() tea.Cmd {
return m.clearMessageCmd()
}
func (m statusComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
return m, nil
case pubsub.Event[status.StatusMessage]:
if msg.Type == status.EventStatusPublished {
// If this is a critical message, move it to the front of the queue
if msg.Payload.Critical {
// Insert at the front of the queue
m.queue = append([]status.StatusMessage{msg.Payload}, m.queue...)
// Reset active time to show critical message immediately
m.activeUntil = time.Time{}
} else {
// Otherwise, just add it to the queue
m.queue = append(m.queue, msg.Payload)
// If this is the first message and nothing is active, activate it immediately
if len(m.queue) == 1 && m.activeUntil.IsZero() {
now := time.Now()
duration := m.messageTTL
if msg.Payload.Duration > 0 {
duration = msg.Payload.Duration
}
m.activeUntil = now.Add(duration)
}
}
}
case statusCleanupMsg:
now := msg.time
// If the active message has expired, remove it and activate the next one
if !m.activeUntil.IsZero() && m.activeUntil.Before(now) {
// Current message expired, remove it if we have one
if len(m.queue) > 0 {
m.queue = m.queue[1:]
}
m.activeUntil = time.Time{}
}
// If we have messages in queue but none are active, activate the first one
if len(m.queue) > 0 && m.activeUntil.IsZero() {
// Use custom duration if specified, otherwise use default
duration := m.messageTTL
if m.queue[0].Duration > 0 {
duration = m.queue[0].Duration
}
m.activeUntil = now.Add(duration)
}
return m, m.clearMessageCmd()
}
return m, nil
}
func logo() string {
t := theme.CurrentTheme()
base := lipgloss.NewStyle().Background(t.BackgroundElement()).Foreground(t.TextMuted()).Render
emphasis := lipgloss.NewStyle().Bold(true).Background(t.BackgroundElement()).Foreground(t.Text()).Render
open := base("open")
code := emphasis("code ")
version := base(app.Info.Version)
return styles.Padded().
Background(t.BackgroundElement()).
Render(open + code + version)
}
func formatTokensAndCost(tokens float32, contextWindow float32, cost float32) string {
// Format tokens in human-readable format (e.g., 110K, 1.2M)
var formattedTokens string
switch {
case tokens >= 1_000_000:
formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
case tokens >= 1_000:
formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
default:
formattedTokens = fmt.Sprintf("%d", int(tokens))
}
// Remove .0 suffix if present
if strings.HasSuffix(formattedTokens, ".0K") {
formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
}
if strings.HasSuffix(formattedTokens, ".0M") {
formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
}
// Format cost with $ symbol and 2 decimal places
formattedCost := fmt.Sprintf("$%.2f", cost)
percentage := (float64(tokens) / float64(contextWindow)) * 100
return fmt.Sprintf("Tokens: %s (%d%%), Cost: %s", formattedTokens, int(percentage), formattedCost)
}
func (m statusComponent) View() string {
if m.app.Session.Id == "" {
return styles.BaseStyle().
Width(m.width).
Height(2).
Render("")
}
t := theme.CurrentTheme()
logo := logo()
cwd := styles.Padded().
Foreground(t.TextMuted()).
Background(t.BackgroundSubtle()).
Render(app.Info.Path.Cwd)
sessionInfo := ""
if m.app.Session.Id != "" {
tokens := float32(0)
cost := float32(0)
contextWindow := m.app.Model.Limit.Context
for _, message := range m.app.Messages {
if message.Metadata.Assistant != nil {
cost += message.Metadata.Assistant.Cost
usage := message.Metadata.Assistant.Tokens
if usage.Output > 0 {
tokens = (usage.Input + usage.Output + usage.Reasoning)
}
}
}
sessionInfo = styles.Padded().
Background(t.BackgroundElement()).
Foreground(t.TextMuted()).
Render(formatTokensAndCost(tokens, contextWindow, cost))
}
// diagnostics := styles.Padded().Background(t.BackgroundElement()).Render(m.projectDiagnostics())
space := max(
0,
m.width-lipgloss.Width(logo)-lipgloss.Width(cwd)-lipgloss.Width(sessionInfo),
)
spacer := lipgloss.NewStyle().Background(t.BackgroundSubtle()).Width(space).Render("")
status := logo + cwd + spacer + sessionInfo
blank := styles.BaseStyle().Background(t.Background()).Width(m.width).Render("")
return blank + "\n" + status
// Display the first status message if available
// var statusMessage string
// if len(m.queue) > 0 {
// sm := m.queue[0]
// infoStyle := styles.Padded().
// Foreground(t.Background())
//
// switch sm.Level {
// case "info":
// infoStyle = infoStyle.Background(t.Info())
// case "warn":
// infoStyle = infoStyle.Background(t.Warning())
// case "error":
// infoStyle = infoStyle.Background(t.Error())
// case "debug":
// infoStyle = infoStyle.Background(t.TextMuted())
// }
//
// // Truncate message if it's longer than available width
// msg := sm.Message
// availWidth := statusWidth - 10
//
// // If we have enough space, show inline
// if availWidth >= minInlineWidth {
// if len(msg) > availWidth && availWidth > 0 {
// msg = msg[:availWidth] + "..."
// }
// status += infoStyle.Width(statusWidth).Render(msg)
// } else {
// // Otherwise, prepare a full-width message to show above
// if len(msg) > m.width-10 && m.width > 10 {
// msg = msg[:m.width-10] + "..."
// }
// statusMessage = infoStyle.Width(m.width).Render(msg)
//
// // Add empty space in the status bar
// status += styles.Padded().
// Foreground(t.Text()).
// Background(t.BackgroundSubtle()).
// Width(statusWidth).
// Render("")
// }
// } else {
// status += styles.Padded().
// Foreground(t.Text()).
// Background(t.BackgroundSubtle()).
// Width(statusWidth).
// Render("")
// }
// status += diagnostics
// status += modelName
// If we have a separate status message, prepend it
// if statusMessage != "" {
// return statusMessage + "\n" + status
// } else {
// blank := styles.BaseStyle().Background(t.Background()).Width(m.width).Render("")
// return blank + "\n" + status
// }
}
func NewStatusCmp(app *app.App) StatusComponent {
statusComponent := &statusComponent{
app: app,
queue: []status.StatusMessage{},
messageTTL: 4 * time.Second,
activeUntil: time.Time{},
}
return statusComponent
}
@@ -1,20 +1,20 @@
package dialog
import (
"log/slog"
"github.com/charmbracelet/bubbles/v2/key"
"github.com/charmbracelet/bubbles/v2/textarea"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
type CompletionItem struct {
title string
Title string
Value string
}
@@ -36,8 +36,7 @@ func (ci *CompletionItem) Render(selected bool, width int) string {
if selected {
itemStyle = itemStyle.
Foreground(t.Primary()).
Bold(true)
Foreground(t.Primary())
}
title := itemStyle.Render(
@@ -63,6 +62,7 @@ type CompletionProvider interface {
GetId() string
GetEntry() CompletionItemI
GetChildEntries(query string) ([]CompletionItemI, error)
GetEmptyMessage() string
}
type CompletionSelectedMsg struct {
@@ -78,7 +78,8 @@ type CompletionDialogCompleteItemMsg struct {
type CompletionDialogCloseMsg struct{}
type CompletionDialog interface {
layout.ModelWithView
tea.Model
tea.ViewModel
SetWidth(width int)
IsEmpty() bool
SetProvider(provider CompletionProvider)
@@ -111,37 +112,13 @@ func (c *completionDialogComponent) Init() tea.Cmd {
return nil
}
func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd {
value := c.pseudoSearchTextArea.Value()
if value == "" {
return nil
}
// Check if this is a command completion
isCommand := c.completionProvider.GetId() == "commands"
return tea.Batch(
util.CmdHandler(CompletionSelectedMsg{
SearchString: value,
CompletionValue: item.GetValue(),
IsCommand: isCommand,
}),
c.close(),
)
}
func (c *completionDialogComponent) close() tea.Cmd {
c.list.SetItems([]CompletionItemI{})
c.pseudoSearchTextArea.Reset()
c.pseudoSearchTextArea.Blur()
return util.CmdHandler(CompletionDialogCloseMsg{})
}
func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case []CompletionItemI:
c.list.SetItems(msg)
case app.CompletionDialogTriggerdMsg:
c.pseudoSearchTextArea.SetValue(msg.InitialValue)
case tea.KeyMsg:
if c.pseudoSearchTextArea.Focused() {
if !key.Matches(msg, completionDialogKeys.Complete) {
@@ -156,18 +133,19 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
if query != c.query {
items, err := c.completionProvider.GetChildEntries(query)
if err != nil {
status.Error(err.Error())
}
c.list.SetItems(items)
c.query = query
cmd = func() tea.Msg {
items, err := c.completionProvider.GetChildEntries(query)
if err != nil {
slog.Error("Failed to get completion items", "error", err)
}
return items
}
cmds = append(cmds, cmd)
}
u, cmd := c.list.Update(msg)
c.list = u.(list.List[CompletionItemI])
cmds = append(cmds, cmd)
}
@@ -187,14 +165,17 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return c, tea.Batch(cmds...)
} else {
items, err := c.completionProvider.GetChildEntries("")
if err != nil {
status.Error(err.Error())
cmd := func() tea.Msg {
items, err := c.completionProvider.GetChildEntries("")
if err != nil {
slog.Error("Failed to get completion items", "error", err)
}
return items
}
c.list.SetItems(items)
c.pseudoSearchTextArea.SetValue(msg.String())
return c, c.pseudoSearchTextArea.Focus()
cmds = append(cmds, cmd)
cmds = append(cmds, c.pseudoSearchTextArea.Focus())
// c.pseudoSearchTextArea.SetValue(msg.String())
return c, tea.Batch(cmds...)
}
case tea.WindowSizeMsg:
c.width = msg.Width
@@ -227,6 +208,7 @@ func (c *completionDialogComponent) View() string {
BorderBottom(false).
BorderRight(true).
BorderLeft(true).
BorderBackground(t.Background()).
BorderForeground(t.BackgroundSubtle()).
Width(c.width).
Render(c.list.View())
@@ -243,29 +225,56 @@ func (c *completionDialogComponent) IsEmpty() bool {
func (c *completionDialogComponent) SetProvider(provider CompletionProvider) {
if c.completionProvider.GetId() != provider.GetId() {
c.completionProvider = provider
items, err := provider.GetChildEntries("")
if err != nil {
status.Error(err.Error())
}
c.list.SetItems(items)
c.list.SetEmptyMessage(" " + provider.GetEmptyMessage())
}
}
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 NewCompletionDialogComponent(completionProvider CompletionProvider) CompletionDialog {
ti := textarea.New()
items, err := completionProvider.GetChildEntries("")
if err != nil {
status.Error(err.Error())
}
li := list.NewListComponent(
items,
[]CompletionItemI{},
7,
"No matches",
completionProvider.GetEmptyMessage(),
false,
)
go func() {
items, err := completionProvider.GetChildEntries("")
if err != nil {
slog.Error("Failed to get completion items", "error", err)
}
li.SetItems(items)
}()
return &completionDialogComponent{
query: "",
completionProvider: completionProvider,
+24 -36
View File
@@ -3,9 +3,9 @@ package dialog
import (
"strings"
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/theme"
@@ -15,28 +15,9 @@ type helpDialog struct {
width int
height int
modal *modal.Modal
bindings []key.Binding
commands []commands.Command
}
// func (i bindingItem) Render(selected bool, width int) string {
// t := theme.CurrentTheme()
// baseStyle := styles.BaseStyle().
// Width(width - 2).
// Background(t.BackgroundElement())
//
// if selected {
// baseStyle = baseStyle.
// Background(t.Primary()).
// Foreground(t.BackgroundElement()).
// Bold(true)
// } else {
// baseStyle = baseStyle.
// Foreground(t.Text())
// }
//
// return baseStyle.Padding(0, 1).Render(i.binding.Help().Desc)
// }
func (h *helpDialog) Init() tea.Cmd {
return nil
}
@@ -59,22 +40,29 @@ func (h *helpDialog) View() string {
descStyle := lipgloss.NewStyle().
Background(t.BackgroundElement()).
Foreground(t.TextMuted())
contentStyle := lipgloss.NewStyle().
PaddingLeft(1).Background(t.BackgroundElement())
lines := []string{}
for _, b := range h.bindings {
content := keyStyle.Render(b.Help().Key)
content += descStyle.Render(" " + b.Help().Desc)
for i, key := range b.Keys() {
if i == 0 {
keyString := " (" + strings.ToUpper(key) + ")"
// space := max(h.width-lipgloss.Width(content)-lipgloss.Width(keyString), 0)
// spacer := strings.Repeat(" ", space)
// content += descStyle.Render(spacer)
content += descStyle.Render(keyString)
}
for _, b := range h.commands {
// Only interested in slash commands
if b.Trigger == "" {
continue
}
lines = append(lines, content)
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))
}
return strings.Join(lines, "\n")
@@ -92,9 +80,9 @@ type HelpDialog interface {
layout.Modal
}
func NewHelpDialog(bindings ...key.Binding) HelpDialog {
func NewHelpDialog(commands []commands.Command) HelpDialog {
return &helpDialog{
bindings: bindings,
modal: modal.New(),
commands: commands,
modal: modal.New(modal.WithTitle("Help")),
}
}
@@ -13,7 +13,6 @@ import (
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/state"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
@@ -115,7 +114,7 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Sequence(
util.CmdHandler(modal.CloseModalMsg{}),
util.CmdHandler(
state.ModelSelectedMsg{
app.ModelSelectedMsg{
Provider: m.provider,
Model: models[m.selectedIdx],
}),
@@ -181,6 +180,7 @@ func (m *modelDialog) switchProvider(offset int) {
m.hScrollOffset = newOffset
m.provider = m.availableProviders[m.hScrollOffset]
m.modal.SetTitle(fmt.Sprintf("Select %s Model", m.provider.Name))
m.setupModelsForProvider(m.provider.Id)
}
@@ -190,14 +190,6 @@ func (m *modelDialog) View() string {
Background(t.BackgroundElement()).
Foreground(t.Text())
// Capitalize first letter of provider name
title := baseStyle.
Foreground(t.Primary()).
Bold(true).
Width(maxDialogWidth).
Padding(0, 0, 1).
Render(fmt.Sprintf("Select %s Model", m.provider.Name))
// Render visible models
endIdx := min(m.scrollOffset+numVisibleModels, len(m.provider.Models))
modelItems := make([]string, 0, endIdx-m.scrollOffset)
@@ -218,8 +210,9 @@ func (m *modelDialog) View() string {
content := lipgloss.JoinVertical(
lipgloss.Left,
title,
baseStyle.Width(maxDialogWidth).Render(lipgloss.JoinVertical(lipgloss.Left, modelItems...)),
baseStyle.
Width(maxDialogWidth).
Render(lipgloss.JoinVertical(lipgloss.Left, modelItems...)),
scrollIndicator,
)
@@ -239,12 +232,7 @@ func (m *modelDialog) getScrollIndicators(maxWidth int) string {
}
if m.hScrollPossible {
if m.hScrollOffset > 0 {
indicator = "← " + indicator
}
if m.hScrollOffset < len(m.availableProviders)-1 {
indicator += "→"
}
indicator = "← " + indicator + "→"
}
if indicator == "" {
@@ -314,6 +302,6 @@ func NewModelDialog(app *app.App) ModelDialog {
hScrollOffset: 0,
hScrollPossible: len(availableProviders) > 1,
provider: availableProviders[0],
modal: modal.New(),
modal: modal.New(modal.WithTitle(fmt.Sprintf("Select %s Model", availableProviders[0].Name))),
}
}
@@ -6,7 +6,6 @@ import (
"github.com/charmbracelet/bubbles/v2/viewport"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
@@ -30,7 +29,8 @@ type PermissionResponseMsg struct {
// PermissionDialogComponent interface for permission dialog component
type PermissionDialogComponent interface {
layout.ModelWithView
tea.Model
tea.ViewModel
// SetPermissions(permission permission.PermissionRequest) tea.Cmd
}
@@ -1,139 +0,0 @@
package dialog
import (
"strings"
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
const question = "Are you sure you want to quit?"
// QuitDialog interface for the quit confirmation dialog
type QuitDialog interface {
layout.Modal
IsQuitDialog() bool
}
type quitDialog struct {
width int
height int
modal *modal.Modal
selectedNo bool
}
type helpMapping struct {
LeftRight key.Binding
EnterSpace key.Binding
Yes key.Binding
No key.Binding
}
var helpKeys = helpMapping{
LeftRight: key.NewBinding(
key.WithKeys("left", "right", "h", "l", "tab"),
key.WithHelp("←/→", "switch options"),
),
EnterSpace: key.NewBinding(
key.WithKeys("enter", " "),
key.WithHelp("enter/space", "confirm"),
),
Yes: key.NewBinding(
key.WithKeys("y", "Y", "ctrl+c"),
key.WithHelp("y/Y", "yes"),
),
No: key.NewBinding(
key.WithKeys("n", "N"),
key.WithHelp("n/N", "no"),
),
}
func (q *quitDialog) Init() tea.Cmd {
return nil
}
func (q *quitDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
q.width = msg.Width
q.height = msg.Height
case tea.KeyMsg:
switch {
case key.Matches(msg, helpKeys.LeftRight):
q.selectedNo = !q.selectedNo
return q, nil
case key.Matches(msg, helpKeys.EnterSpace):
if !q.selectedNo {
return q, tea.Quit
}
return q, util.CmdHandler(modal.CloseModalMsg{})
case key.Matches(msg, helpKeys.Yes):
return q, tea.Quit
case key.Matches(msg, helpKeys.No):
return q, util.CmdHandler(modal.CloseModalMsg{})
}
}
return q, nil
}
func (q *quitDialog) Render(background string) string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
yesStyle := baseStyle
noStyle := baseStyle
spacerStyle := baseStyle.Background(t.BackgroundElement())
if q.selectedNo {
noStyle = noStyle.Background(t.Primary()).Foreground(t.BackgroundElement())
yesStyle = yesStyle.Background(t.BackgroundElement()).Foreground(t.Primary())
} else {
yesStyle = yesStyle.Background(t.Primary()).Foreground(t.BackgroundElement())
noStyle = noStyle.Background(t.BackgroundElement()).Foreground(t.Primary())
}
yesButton := yesStyle.Padding(0, 1).Render("Yes")
noButton := noStyle.Padding(0, 1).Render("No")
buttons := lipgloss.JoinHorizontal(lipgloss.Left, yesButton, spacerStyle.Render(" "), noButton)
width := lipgloss.Width(question)
remainingWidth := width - lipgloss.Width(buttons)
if remainingWidth > 0 {
buttons = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + buttons
}
content := baseStyle.Render(
lipgloss.JoinVertical(
lipgloss.Center,
question,
"",
buttons,
),
)
return q.modal.Render(content, background)
}
func (q *quitDialog) Close() tea.Cmd {
return nil
}
func (q *quitDialog) IsQuitDialog() bool {
return true
}
// NewQuitDialog creates a new quit confirmation dialog
func NewQuitDialog() QuitDialog {
return &quitDialog{
selectedNo: true,
modal: modal.New(),
}
}
@@ -8,7 +8,6 @@ import (
"github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/state"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
@@ -20,14 +19,12 @@ type SessionDialog interface {
layout.Modal
}
type sessionItem struct {
session client.SessionInfo
}
type sessionItem client.SessionInfo
func (s sessionItem) Render(selected bool, width int) string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle().
Width(width - 2).
Width(width - 4).
Background(t.BackgroundElement())
if selected {
@@ -40,7 +37,7 @@ func (s sessionItem) Render(selected bool, width int) string {
Foreground(t.Text())
}
return baseStyle.Padding(0, 1).Render(s.session.Title)
return baseStyle.Padding(0, 1).Render(s.Title)
}
type sessionDialog struct {
@@ -61,15 +58,14 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
s.width = msg.Width
s.height = msg.Height
s.list.SetMaxWidth(layout.Current.Container.Width - 12)
case tea.KeyMsg:
case tea.KeyPressMsg:
switch msg.String() {
case "enter":
if item, idx := s.list.GetSelectedItem(); idx >= 0 {
selectedSession := item.session
s.selectedSessionID = selectedSession.Id
s.selectedSessionID = item.Id
return s, tea.Sequence(
util.CmdHandler(modal.CloseModalMsg{}),
util.CmdHandler(state.SessionSelectedMsg(&selectedSession)),
util.CmdHandler(app.SessionSelectedMsg(&item)),
)
}
}
@@ -95,7 +91,10 @@ func NewSessionDialog(app *app.App) SessionDialog {
var sessionItems []sessionItem
for _, sess := range sessions {
sessionItems = append(sessionItems, sessionItem{session: sess})
if sess.ParentID != nil {
continue
}
sessionItems = append(sessionItems, sessionItem(sess))
}
list := list.NewListComponent(
@@ -5,14 +5,13 @@ import (
list "github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
// ThemeChangedMsg is sent when the theme is changed
type ThemeChangedMsg struct {
// ThemeSelectedMsg is sent when the theme is changed
type ThemeSelectedMsg struct {
ThemeName string
}
@@ -71,12 +70,12 @@ func (t *themeDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return t, util.CmdHandler(modal.CloseModalMsg{})
}
if err := theme.SetTheme(selectedTheme); err != nil {
status.Error(err.Error())
// status.Error(err.Error())
return t, nil
}
return t, tea.Sequence(
util.CmdHandler(modal.CloseModalMsg{}),
util.CmdHandler(ThemeChangedMsg{ThemeName: selectedTheme}),
util.CmdHandler(ThemeSelectedMsg{ThemeName: selectedTheme}),
)
}
}
+10 -4
View File
@@ -1,10 +1,10 @@
package list
import (
"strings"
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/layout"
)
type ListItem interface {
@@ -12,12 +12,14 @@ type ListItem interface {
}
type List[T ListItem] interface {
layout.ModelWithView
tea.Model
tea.ViewModel
SetMaxWidth(maxWidth int)
GetSelectedItem() (item T, idx int)
SetItems(items []T)
GetItems() []T
SetSelectedIndex(idx int)
SetEmptyMessage(msg string)
IsEmpty() bool
}
@@ -100,6 +102,10 @@ func (c *listComponent[T]) GetItems() []T {
return c.items
}
func (c *listComponent[T]) SetEmptyMessage(msg string) {
c.fallbackMsg = msg
}
func (c *listComponent[T]) IsEmpty() bool {
return len(c.items) == 0
}
@@ -143,7 +149,7 @@ func (c *listComponent[T]) View() string {
listItems = append(listItems, title)
}
return lipgloss.JoinVertical(lipgloss.Left, listItems...)
return strings.Join(listItems, "\n")
}
func NewListComponent[T ListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) List[T] {
@@ -1,6 +1,8 @@
package modal
import (
"strings"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
@@ -66,6 +68,10 @@ func New(opts ...ModalOption) *Modal {
return m
}
func (m *Modal) SetTitle(title string) {
m.title = title
}
// Render renders the modal centered on the screen
func (m *Modal) Render(contentView string, background string) string {
t := theme.CurrentTheme()
@@ -95,15 +101,21 @@ func (m *Modal) Render(contentView string, background string) string {
titleStyle := baseStyle.
Foreground(t.Primary()).
Bold(true).
Width(innerWidth).
Padding(0, 1)
titleView := titleStyle.Render(m.title)
finalContent = lipgloss.JoinVertical(
lipgloss.Left,
titleView,
contentView,
)
// titleView := titleStyle.Render(m.title)
escStyle := baseStyle.Foreground(t.TextMuted()).Bold(false)
escText := escStyle.Render("esc")
// Calculate position for esc text
titleWidth := lipgloss.Width(m.title)
escWidth := lipgloss.Width(escText)
spacesNeeded := max(0, innerWidth-titleWidth-escWidth-3)
spacer := strings.Repeat(" ", spacesNeeded)
titleLine := m.title + spacer + escText
titleLine = titleStyle.Render(titleLine)
finalContent = strings.Join([]string{titleLine, contentView}, "\n") + "\n"
} else {
finalContent = contentView
}
@@ -0,0 +1,140 @@
package status
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
type StatusComponent interface {
tea.Model
tea.ViewModel
}
type statusComponent struct {
app *app.App
width int
}
func (m statusComponent) Init() tea.Cmd {
return nil
}
func (m statusComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
return m, nil
}
return m, nil
}
func (m statusComponent) logo() string {
t := theme.CurrentTheme()
base := lipgloss.NewStyle().Background(t.BackgroundElement()).Foreground(t.TextMuted()).Render
emphasis := lipgloss.NewStyle().Bold(true).Background(t.BackgroundElement()).Foreground(t.Text()).Render
open := base("open")
code := emphasis("code ")
version := base(m.app.Version)
return styles.Padded().
Background(t.BackgroundElement()).
Render(open + code + version)
}
func formatTokensAndCost(tokens float32, contextWindow float32, cost float32) string {
// Format tokens in human-readable format (e.g., 110K, 1.2M)
var formattedTokens string
switch {
case tokens >= 1_000_000:
formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
case tokens >= 1_000:
formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
default:
formattedTokens = fmt.Sprintf("%d", int(tokens))
}
// Remove .0 suffix if present
if strings.HasSuffix(formattedTokens, ".0K") {
formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
}
if strings.HasSuffix(formattedTokens, ".0M") {
formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
}
// Format cost with $ symbol and 2 decimal places
formattedCost := fmt.Sprintf("$%.2f", cost)
percentage := (float64(tokens) / float64(contextWindow)) * 100
return fmt.Sprintf("Tokens: %s (%d%%), Cost: %s", formattedTokens, int(percentage), formattedCost)
}
func (m statusComponent) View() string {
t := theme.CurrentTheme()
if m.app.Session.Id == "" {
return styles.BaseStyle().
Background(t.Background()).
Width(m.width).
Height(2).
Render("")
}
logo := m.logo()
cwd := styles.Padded().
Foreground(t.TextMuted()).
Background(t.BackgroundSubtle()).
Render(m.app.Info.Path.Cwd)
sessionInfo := ""
if m.app.Session.Id != "" {
tokens := float32(0)
cost := float32(0)
contextWindow := m.app.Model.Limit.Context
for _, message := range m.app.Messages {
if message.Metadata.Assistant != nil {
cost += message.Metadata.Assistant.Cost
usage := message.Metadata.Assistant.Tokens
if usage.Output > 0 {
tokens = (usage.Input +
usage.Cache.Write +
usage.Cache.Read +
usage.Output +
usage.Reasoning)
}
}
}
sessionInfo = styles.Padded().
Background(t.BackgroundElement()).
Foreground(t.TextMuted()).
Render(formatTokensAndCost(tokens, contextWindow, cost))
}
// diagnostics := styles.Padded().Background(t.BackgroundElement()).Render(m.projectDiagnostics())
space := max(
0,
m.width-lipgloss.Width(logo)-lipgloss.Width(cwd)-lipgloss.Width(sessionInfo),
)
spacer := lipgloss.NewStyle().Background(t.BackgroundSubtle()).Width(space).Render("")
status := logo + cwd + spacer + sessionInfo
blank := styles.BaseStyle().Background(t.Background()).Width(m.width).Render("")
return blank + "\n" + status
}
func NewStatusCmp(app *app.App) StatusComponent {
statusComponent := &statusComponent{
app: app,
}
return statusComponent
}
+25 -25
View File
@@ -7,27 +7,31 @@ import (
"os"
"github.com/BurntSushi/toml"
"github.com/sst/opencode/pkg/client"
)
type Config struct {
type State struct {
Theme string `toml:"theme"`
Provider string `toml:"provider"`
Model string `toml:"model"`
}
// NewConfig creates a new Config instance with default values.
// This can be useful for initializing a new configuration file.
func NewConfig(theme, provider, model string) *Config {
return &Config{
Theme: theme,
Provider: provider,
Model: model,
func NewState() *State {
return &State{
Theme: "opencode",
}
}
// SaveConfig writes the provided Config struct to the specified TOML file.
func MergeState(state *State, config *client.ConfigInfo) *client.ConfigInfo {
if config.Theme == nil {
config.Theme = &state.Theme
}
return config
}
// SaveState writes the provided Config struct to the specified TOML file.
// It will create the file if it doesn't exist, or overwrite it if it does.
func SaveConfig(filePath string, config *Config) error {
func SaveState(filePath string, state *State) error {
file, err := os.Create(filePath)
if err != nil {
return fmt.Errorf("failed to create/open config file %s: %w", filePath, err)
@@ -35,31 +39,27 @@ func SaveConfig(filePath string, config *Config) error {
defer file.Close()
writer := bufio.NewWriter(file)
encoder := toml.NewEncoder(writer)
if err := encoder.Encode(config); err != nil {
return fmt.Errorf("failed to encode config to TOML file %s: %w", filePath, err)
if err := encoder.Encode(state); err != nil {
return fmt.Errorf("failed to encode state to TOML file %s: %w", filePath, err)
}
if err := writer.Flush(); err != nil {
return fmt.Errorf("failed to flush writer for config file %s: %w", filePath, err)
return fmt.Errorf("failed to flush writer for state file %s: %w", filePath, err)
}
slog.Debug("Configuration saved to file", "file", filePath)
slog.Debug("State saved to file", "file", filePath)
return nil
}
// LoadConfig reads a Config struct from the specified TOML file.
// It returns a pointer to the Config struct and an error if any issues occur.
func LoadConfig(filePath string) (*Config, error) {
var config Config
if _, err := toml.DecodeFile(filePath, &config); err != nil {
// LoadState loads the state from the specified TOML file.
// It returns a pointer to the State struct and an error if any issues occur.
func LoadState(filePath string) (*State, error) {
var state State
if _, err := toml.DecodeFile(filePath, &state); err != nil {
if _, statErr := os.Stat(filePath); os.IsNotExist(statErr) {
return nil, fmt.Errorf("config file not found at %s: %w", filePath, statErr)
return nil, fmt.Errorf("state file not found at %s: %w", filePath, statErr)
}
return nil, fmt.Errorf("failed to decode TOML from file %s: %w", filePath, err)
}
return &config, nil
return &state, nil
}
+33 -28
View File
@@ -6,20 +6,12 @@ import (
"github.com/sst/opencode/internal/theme"
)
type ModelWithView interface {
type Container interface {
tea.Model
tea.ViewModel
}
type Container interface {
ModelWithView
Sizeable
Focus()
Blur()
MaxWidth() int
Alignment() lipgloss.Position
GetPosition() (x, y int)
GetContent() ModelWithView
Focusable
Alignable
}
type container struct {
@@ -28,7 +20,7 @@ type container struct {
x int
y int
content ModelWithView
content tea.ViewModel
paddingTop int
paddingRight int
@@ -48,13 +40,19 @@ type container struct {
}
func (c *container) Init() tea.Cmd {
return c.content.Init()
if model, ok := c.content.(tea.Model); ok {
return model.Init()
}
return nil
}
func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
u, cmd := c.content.Update(msg)
c.content = u.(ModelWithView)
return c, cmd
if model, ok := c.content.(tea.Model); ok {
u, cmd := model.Update(msg)
c.content = u.(tea.ViewModel)
return c, cmd
}
return c, nil
}
func (c *container) View() string {
@@ -156,21 +154,28 @@ func (c *container) Alignment() lipgloss.Position {
}
// Focus sets the container as focused
func (c *container) Focus() {
func (c *container) Focus() tea.Cmd {
c.focused = true
// Pass focus to content if it supports it
if focusable, ok := c.content.(interface{ Focus() }); ok {
focusable.Focus()
if focusable, ok := c.content.(Focusable); ok {
return focusable.Focus()
}
return nil
}
// Blur removes focus from the container
func (c *container) Blur() {
func (c *container) Blur() tea.Cmd {
c.focused = false
// Remove focus from content if it supports it
if blurable, ok := c.content.(interface{ Blur() }); ok {
blurable.Blur()
if blurable, ok := c.content.(Focusable); ok {
return blurable.Blur()
}
return nil
}
func (c *container) IsFocused() bool {
if blurable, ok := c.content.(Focusable); ok {
return blurable.IsFocused()
}
return c.focused
}
// GetPosition returns the x, y coordinates of the container
@@ -178,14 +183,14 @@ func (c *container) GetPosition() (x, y int) {
return c.x, c.y
}
// GetContent returns the content of the container
func (c *container) GetContent() ModelWithView {
return c.content
func (c *container) SetPosition(x, y int) {
c.x = x
c.y = y
}
type ContainerOption func(*container)
func NewContainer(content ModelWithView, options ...ContainerOption) Container {
func NewContainer(content tea.ViewModel, options ...ContainerOption) Container {
c := &container{
content: content,
borderStyle: lipgloss.NormalBorder(),
+71 -85
View File
@@ -3,6 +3,7 @@ package layout
import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/theme"
)
type FlexDirection int
@@ -12,22 +13,22 @@ const (
FlexDirectionVertical
)
type FlexPaneSize struct {
type FlexChildSize struct {
Fixed bool
Size int
}
var FlexPaneSizeGrow = FlexPaneSize{Fixed: false}
var FlexChildSizeGrow = FlexChildSize{Fixed: false}
func FlexPaneSizeFixed(size int) FlexPaneSize {
return FlexPaneSize{Fixed: true, Size: size}
func FlexChildSizeFixed(size int) FlexChildSize {
return FlexChildSize{Fixed: true, Size: size}
}
type FlexLayout interface {
ModelWithView
tea.ViewModel
Sizeable
SetPanes(panes []Container) tea.Cmd
SetPaneSizes(sizes []FlexPaneSize) tea.Cmd
SetChildren(panes []tea.ViewModel) tea.Cmd
SetSizes(sizes []FlexChildSize) tea.Cmd
SetDirection(direction FlexDirection) tea.Cmd
}
@@ -35,91 +36,69 @@ type flexLayout struct {
width int
height int
direction FlexDirection
panes []Container
sizes []FlexPaneSize
children []tea.ViewModel
sizes []FlexChildSize
}
type FlexLayoutOption func(*flexLayout)
func (f *flexLayout) Init() tea.Cmd {
var cmds []tea.Cmd
for _, pane := range f.panes {
if pane != nil {
cmds = append(cmds, pane.Init())
}
}
return tea.Batch(cmds...)
}
func (f *flexLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
return f, f.SetSize(msg.Width, msg.Height)
}
for i, pane := range f.panes {
if pane != nil {
u, cmd := pane.Update(msg)
f.panes[i] = u.(Container)
if cmd != nil {
cmds = append(cmds, cmd)
}
}
}
return f, tea.Batch(cmds...)
}
func (f *flexLayout) View() string {
if len(f.panes) == 0 {
if len(f.children) == 0 {
return ""
}
views := make([]string, 0, len(f.panes))
for i, pane := range f.panes {
if pane == nil {
t := theme.CurrentTheme()
views := make([]string, 0, len(f.children))
for i, child := range f.children {
if child == nil {
continue
}
var paneWidth, paneHeight int
alignment := lipgloss.Center
if alignable, ok := child.(Alignable); ok {
alignment = alignable.Alignment()
}
var childWidth, childHeight int
if f.direction == FlexDirectionHorizontal {
paneWidth, paneHeight = f.calculatePaneSize(i)
childWidth, childHeight = f.calculateChildSize(i)
view := lipgloss.PlaceHorizontal(
paneWidth,
pane.Alignment(),
pane.View(),
childWidth,
alignment,
child.View(),
// TODO: make configurable WithBackgroundStyle
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
)
views = append(views, view)
} else {
paneWidth, paneHeight = f.calculatePaneSize(i)
childWidth, childHeight = f.calculateChildSize(i)
view := lipgloss.Place(
f.width,
paneHeight,
childHeight,
lipgloss.Center,
pane.Alignment(),
pane.View(),
alignment,
child.View(),
// TODO: make configurable WithBackgroundStyle
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
)
views = append(views, view)
}
}
if f.direction == FlexDirectionHorizontal {
return lipgloss.JoinHorizontal(lipgloss.Center, views...)
}
return lipgloss.JoinVertical(lipgloss.Center, views...)
}
func (f *flexLayout) calculatePaneSize(index int) (width, height int) {
if index >= len(f.panes) {
func (f *flexLayout) calculateChildSize(index int) (width, height int) {
if index >= len(f.children) {
return 0, 0
}
totalFixed := 0
flexCount := 0
for i, pane := range f.panes {
if pane == nil {
for i, child := range f.children {
if child == nil {
continue
}
if i < len(f.sizes) && f.sizes[i].Fixed {
@@ -160,14 +139,18 @@ func (f *flexLayout) SetSize(width, height int) tea.Cmd {
var cmds []tea.Cmd
currentX, currentY := 0, 0
for i, pane := range f.panes {
if pane != nil {
paneWidth, paneHeight := f.calculatePaneSize(i)
for i, child := range f.children {
if child != nil {
paneWidth, paneHeight := f.calculateChildSize(i)
alignment := lipgloss.Center
if alignable, ok := child.(Alignable); ok {
alignment = alignable.Alignment()
}
// Calculate actual position based on alignment
actualX, actualY := currentX, currentY
if f.direction == FlexDirectionHorizontal {
// In horizontal layout, vertical alignment affects Y position
// (lipgloss.Center is used for vertical alignment in JoinHorizontal)
@@ -175,11 +158,13 @@ func (f *flexLayout) SetSize(width, height int) tea.Cmd {
} else {
// In vertical layout, horizontal alignment affects X position
contentWidth := paneWidth
if pane.MaxWidth() > 0 && contentWidth > pane.MaxWidth() {
contentWidth = pane.MaxWidth()
if alignable, ok := child.(Alignable); ok {
if alignable.MaxWidth() > 0 && contentWidth > alignable.MaxWidth() {
contentWidth = alignable.MaxWidth()
}
}
switch pane.Alignment() {
switch alignment {
case lipgloss.Center:
actualX = (f.width - contentWidth) / 2
case lipgloss.Right:
@@ -188,16 +173,17 @@ func (f *flexLayout) SetSize(width, height int) tea.Cmd {
actualX = 0
}
}
// Set position if the pane is a *container
if c, ok := pane.(*container); ok {
c.x = actualX
c.y = actualY
// Set position if the pane is Alignable
if c, ok := child.(Alignable); ok {
c.SetPosition(actualX, actualY)
}
cmd := pane.SetSize(paneWidth, paneHeight)
cmds = append(cmds, cmd)
if sizeable, ok := child.(Sizeable); ok {
cmd := sizeable.SetSize(paneWidth, paneHeight)
cmds = append(cmds, cmd)
}
// Update position for next pane
if f.direction == FlexDirectionHorizontal {
currentX += paneWidth
@@ -213,15 +199,15 @@ func (f *flexLayout) GetSize() (int, int) {
return f.width, f.height
}
func (f *flexLayout) SetPanes(panes []Container) tea.Cmd {
f.panes = panes
func (f *flexLayout) SetChildren(children []tea.ViewModel) tea.Cmd {
f.children = children
if f.width > 0 && f.height > 0 {
return f.SetSize(f.width, f.height)
}
return nil
}
func (f *flexLayout) SetPaneSizes(sizes []FlexPaneSize) tea.Cmd {
func (f *flexLayout) SetSizes(sizes []FlexChildSize) tea.Cmd {
f.sizes = sizes
if f.width > 0 && f.height > 0 {
return f.SetSize(f.width, f.height)
@@ -237,11 +223,11 @@ func (f *flexLayout) SetDirection(direction FlexDirection) tea.Cmd {
return nil
}
func NewFlexLayout(options ...FlexLayoutOption) FlexLayout {
func NewFlexLayout(children []tea.ViewModel, options ...FlexLayoutOption) FlexLayout {
layout := &flexLayout{
children: children,
direction: FlexDirectionHorizontal,
panes: []Container{},
sizes: []FlexPaneSize{},
sizes: []FlexChildSize{},
}
for _, option := range options {
option(layout)
@@ -255,13 +241,13 @@ func WithDirection(direction FlexDirection) FlexLayoutOption {
}
}
func WithPanes(panes ...Container) FlexLayoutOption {
func WithChildren(children ...tea.ViewModel) FlexLayoutOption {
return func(f *flexLayout) {
f.panes = panes
f.children = children
}
}
func WithPaneSizes(sizes ...FlexPaneSize) FlexLayoutOption {
func WithSizes(sizes ...FlexChildSize) FlexLayoutOption {
return func(f *flexLayout) {
f.sizes = sizes
}
+8
View File
@@ -5,6 +5,7 @@ import (
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
)
var Current *LayoutInfo
@@ -45,6 +46,13 @@ type Sizeable interface {
GetSize() (int, int)
}
type Alignable interface {
MaxWidth() int
Alignment() lipgloss.Position
SetPosition(x, y int)
GetPosition() (x, y int)
}
func KeyMapToSlice(t any) (bindings []key.Binding) {
typ := reflect.TypeOf(t)
if typ.Kind() != reflect.Struct {
-188
View File
@@ -1,188 +0,0 @@
package page
import (
"context"
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/completions"
"github.com/sst/opencode/internal/components/chat"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/util"
)
var ChatPage PageID = "chat"
type chatPage struct {
app *app.App
editor layout.Container
messages layout.Container
layout layout.FlexLayout
completionDialog dialog.CompletionDialog
completionManager *completions.CompletionManager
showCompletionDialog bool
}
type ChatKeyMap struct {
Cancel key.Binding
ToggleTools key.Binding
ShowCompletionDialog key.Binding
}
var keyMap = ChatKeyMap{
Cancel: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "cancel"),
),
ToggleTools: key.NewBinding(
key.WithKeys("ctrl+h"),
key.WithHelp("ctrl+h", "toggle tools"),
),
ShowCompletionDialog: key.NewBinding(
key.WithKeys("/"),
key.WithHelp("/", "Complete"),
),
}
func (p *chatPage) Init() tea.Cmd {
cmds := []tea.Cmd{
p.layout.Init(),
}
cmds = append(cmds, p.completionDialog.Init())
return tea.Batch(cmds...)
}
func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
cmd := p.layout.SetSize(msg.Width, msg.Height)
cmds = append(cmds, cmd)
case chat.SendMsg:
p.showCompletionDialog = false
cmd := p.sendMessage(msg.Text, msg.Attachments)
if cmd != nil {
return p, cmd
}
case dialog.CompletionDialogCloseMsg:
p.showCompletionDialog = false
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
_, cmd := p.editor.Update(msg)
if cmd != nil {
return p, cmd
}
}
switch {
case key.Matches(msg, keyMap.ShowCompletionDialog):
p.showCompletionDialog = true
// Continue sending keys to layout->chat
case key.Matches(msg, keyMap.Cancel):
if p.app.Session.Id != "" {
// Cancel the current session's generation process
// This allows users to interrupt long-running operations
p.app.Cancel(context.Background(), p.app.Session.Id)
return p, nil
}
case key.Matches(msg, keyMap.ToggleTools):
return p, util.CmdHandler(chat.ToggleToolMessagesMsg{})
}
}
if p.showCompletionDialog {
// Get the current text from the editor to determine which provider to use
editorModel := p.editor.GetContent().(interface{ GetValue() string })
currentInput := editorModel.GetValue()
provider := p.completionManager.GetProvider(currentInput)
p.completionDialog.SetProvider(provider)
context, contextCmd := p.completionDialog.Update(msg)
p.completionDialog = context.(dialog.CompletionDialog)
cmds = append(cmds, contextCmd)
// Doesn't forward event if enter key is pressed and there are completions
if keyMsg, ok := msg.(tea.KeyMsg); ok {
if keyMsg.String() == "enter" { // && !p.completionDialog.IsEmpty() {
return p, tea.Batch(cmds...)
}
}
}
u, cmd := p.layout.Update(msg)
cmds = append(cmds, cmd)
p.layout = u.(layout.FlexLayout)
return p, tea.Batch(cmds...)
}
func (p *chatPage) sendMessage(text string, attachments []app.Attachment) tea.Cmd {
var cmds []tea.Cmd
cmd := p.app.SendChatMessage(context.Background(), text, attachments)
cmds = append(cmds, cmd)
return tea.Batch(cmds...)
}
func (p *chatPage) SetSize(width, height int) tea.Cmd {
return p.layout.SetSize(width, height)
}
func (p *chatPage) GetSize() (int, int) {
return p.layout.GetSize()
}
func (p *chatPage) View() string {
layoutView := p.layout.View()
if p.showCompletionDialog {
editorWidth, _ := p.editor.GetSize()
editorX, editorY := p.editor.GetPosition()
p.completionDialog.SetWidth(editorWidth)
overlay := p.completionDialog.View()
layoutView = layout.PlaceOverlay(
editorX,
editorY-lipgloss.Height(overlay)+2,
overlay,
layoutView,
)
}
return layoutView
}
func NewChatPage(app *app.App) layout.ModelWithView {
completionManager := completions.NewCompletionManager(app)
initialProvider := completionManager.GetProvider("")
completionDialog := dialog.NewCompletionDialogComponent(initialProvider)
messagesContainer := layout.NewContainer(
chat.NewMessagesComponent(app),
)
editorContainer := layout.NewContainer(
chat.NewEditorComponent(app),
layout.WithMaxWidth(layout.Current.Container.Width),
layout.WithAlignCenter(),
)
return &chatPage{
app: app,
editor: editorContainer,
messages: messagesContainer,
completionDialog: completionDialog,
completionManager: completionManager,
layout: layout.NewFlexLayout(
layout.WithPanes(messagesContainer, editorContainer),
layout.WithDirection(layout.FlexDirectionVertical),
layout.WithPaneSizes(
layout.FlexPaneSizeGrow,
layout.FlexPaneSizeFixed(6),
),
),
}
}
-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
}
-113
View File
@@ -1,113 +0,0 @@
package pubsub
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
)
const defaultChannelBufferSize = 100
type Broker[T any] struct {
subs map[chan Event[T]]context.CancelFunc
mu sync.RWMutex
isClosed bool
}
func NewBroker[T any]() *Broker[T] {
return &Broker[T]{
subs: make(map[chan Event[T]]context.CancelFunc),
}
}
func (b *Broker[T]) Shutdown() {
b.mu.Lock()
if b.isClosed {
b.mu.Unlock()
return
}
b.isClosed = true
for ch, cancel := range b.subs {
cancel()
close(ch)
delete(b.subs, ch)
}
b.mu.Unlock()
slog.Debug("PubSub broker shut down", "type", fmt.Sprintf("%T", *new(T)))
}
func (b *Broker[T]) Subscribe(ctx context.Context) <-chan Event[T] {
b.mu.Lock()
defer b.mu.Unlock()
if b.isClosed {
closedCh := make(chan Event[T])
close(closedCh)
return closedCh
}
subCtx, subCancel := context.WithCancel(ctx)
subscriberChannel := make(chan Event[T], defaultChannelBufferSize)
b.subs[subscriberChannel] = subCancel
go func() {
<-subCtx.Done()
b.mu.Lock()
defer b.mu.Unlock()
if _, ok := b.subs[subscriberChannel]; ok {
close(subscriberChannel)
delete(b.subs, subscriberChannel)
}
}()
return subscriberChannel
}
func (b *Broker[T]) Publish(eventType EventType, payload T) {
b.mu.RLock()
defer b.mu.RUnlock()
if b.isClosed {
slog.Warn("Attempted to publish on a closed pubsub broker", "type", eventType, "payload_type", fmt.Sprintf("%T", payload))
return
}
event := Event[T]{Type: eventType, Payload: payload}
for ch := range b.subs {
// Non-blocking send with a fallback to a goroutine to prevent slow subscribers
// from blocking the publisher.
select {
case ch <- event:
// Successfully sent
default:
// Subscriber channel is full or receiver is slow.
// Send in a new goroutine to avoid blocking the publisher.
// This might lead to out-of-order delivery for this specific slow subscriber.
go func(sChan chan Event[T], ev Event[T]) {
// Re-check if broker is closed before attempting send in goroutine
b.mu.RLock()
isBrokerClosed := b.isClosed
b.mu.RUnlock()
if isBrokerClosed {
return
}
select {
case sChan <- ev:
case <-time.After(2 * time.Second): // Timeout for slow subscriber
slog.Warn("PubSub: Dropped event for slow subscriber after timeout", "type", ev.Type)
}
}(ch, event)
}
}
}
func (b *Broker[T]) GetSubscriberCount() int {
b.mu.RLock()
defer b.mu.RUnlock()
return len(b.subs)
}
-24
View File
@@ -1,24 +0,0 @@
package pubsub
import "context"
type EventType string
const (
EventTypeCreated EventType = "created"
EventTypeUpdated EventType = "updated"
EventTypeDeleted EventType = "deleted"
)
type Event[T any] struct {
Type EventType
Payload T
}
type Subscriber[T any] interface {
Subscribe(ctx context.Context) <-chan Event[T]
}
type Publisher[T any] interface {
Publish(eventType EventType, payload T)
}
-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
}
-142
View File
@@ -1,142 +0,0 @@
package status
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
"github.com/sst/opencode/internal/pubsub"
)
type Level string
const (
LevelInfo Level = "info"
LevelWarn Level = "warn"
LevelError Level = "error"
LevelDebug Level = "debug"
)
type StatusMessage struct {
Level Level `json:"level"`
Message string `json:"message"`
Timestamp time.Time `json:"timestamp"`
Critical bool `json:"critical"`
Duration time.Duration `json:"duration"`
}
// StatusOption is a function that configures a status message
type StatusOption func(*StatusMessage)
// WithCritical marks a status message as critical, causing it to be displayed immediately
func WithCritical(critical bool) StatusOption {
return func(msg *StatusMessage) {
msg.Critical = critical
}
}
// WithDuration sets a custom display duration for a status message
func WithDuration(duration time.Duration) StatusOption {
return func(msg *StatusMessage) {
msg.Duration = duration
}
}
const (
EventStatusPublished pubsub.EventType = "status_published"
)
type Service interface {
pubsub.Subscriber[StatusMessage]
Info(message string, opts ...StatusOption)
Warn(message string, opts ...StatusOption)
Error(message string, opts ...StatusOption)
Debug(message string, opts ...StatusOption)
}
type service struct {
broker *pubsub.Broker[StatusMessage]
mu sync.RWMutex
}
var globalStatusService *service
func InitService() error {
if globalStatusService != nil {
return fmt.Errorf("status service already initialized")
}
broker := pubsub.NewBroker[StatusMessage]()
globalStatusService = &service{
broker: broker,
}
return nil
}
func GetService() Service {
if globalStatusService == nil {
panic("status service not initialized. Call status.InitService() at application startup.")
}
return globalStatusService
}
func (s *service) Info(message string, opts ...StatusOption) {
s.publish(LevelInfo, message, opts...)
slog.Info(message)
}
func (s *service) Warn(message string, opts ...StatusOption) {
s.publish(LevelWarn, message, opts...)
slog.Warn(message)
}
func (s *service) Error(message string, opts ...StatusOption) {
s.publish(LevelError, message, opts...)
slog.Error(message)
}
func (s *service) Debug(message string, opts ...StatusOption) {
s.publish(LevelDebug, message, opts...)
slog.Debug(message)
}
func (s *service) publish(level Level, messageText string, opts ...StatusOption) {
statusMsg := StatusMessage{
Level: level,
Message: messageText,
Timestamp: time.Now(),
}
// Apply all options
for _, opt := range opts {
opt(&statusMsg)
}
s.broker.Publish(EventStatusPublished, statusMsg)
}
func (s *service) Subscribe(ctx context.Context) <-chan pubsub.Event[StatusMessage] {
return s.broker.Subscribe(ctx)
}
func Info(message string, opts ...StatusOption) {
GetService().Info(message, opts...)
}
func Warn(message string, opts ...StatusOption) {
GetService().Warn(message, opts...)
}
func Error(message string, opts ...StatusOption) {
GetService().Error(message, opts...)
}
func Debug(message string, opts ...StatusOption) {
GetService().Debug(message, opts...)
}
func Subscribe(ctx context.Context) <-chan pubsub.Event[StatusMessage] {
return GetService().Subscribe(ctx)
}
-12
View File
@@ -1,12 +0,0 @@
package styles
const (
OpenCodeIcon string = "◍"
ErrorIcon string = "ⓔ"
WarningIcon string = "ⓦ"
InfoIcon string = "ⓘ"
HintIcon string = "ⓗ"
SpinnerIcon string = "⟳"
DocumentIcon string = "🖼"
)
+276
View File
@@ -0,0 +1,276 @@
package theme
import (
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/lipgloss/v2/compat"
)
// AyuTheme implements the Theme interface with Ayu Dark colors.
// It provides a modern dark theme inspired by the Ayu color scheme.
type AyuTheme struct {
BaseTheme
}
// NewAyuTheme creates a new instance of the Ayu Dark theme.
func NewAyuTheme() *AyuTheme {
// Ayu Dark color palette
// Base background colors
darkBg := "#0B0E14" // App background
darkBgAlt := "#0D1017" // Editor background
darkLine := "#11151C" // UI line separators
darkPanel := "#0F131A" // UI panel background
// Text colors
darkFg := "#BFBDB6" // Primary text
darkFgMuted := "#565B66" // Muted text
darkGutter := "#6C7380" // Gutter text
// Syntax highlighting colors
darkTag := "#39BAE6" // Tags and attributes
darkFunc := "#FFB454" // Functions
darkEntity := "#59C2FF" // Entities and variables
darkString := "#AAD94C" // Strings
darkRegexp := "#95E6CB" // Regular expressions
darkMarkup := "#F07178" // Markup elements
darkKeyword := "#FF8F40" // Keywords
darkSpecial := "#E6B673" // Special characters
darkComment := "#ACB6BF" // Comments
darkConstant := "#D2A6FF" // Constants
darkOperator := "#F29668" // Operators
// Version control colors
darkAdded := "#7FD962" // Added lines
darkRemoved := "#F26D78" // Removed lines
// Accent colors
darkAccent := "#E6B450" // Primary accent
darkError := "#D95757" // Error color
// Active state colors
darkIndentActive := "#6C7380" // Active indent guides
theme := &AyuTheme{}
// Base colors
theme.PrimaryColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkEntity),
Light: lipgloss.Color(darkEntity),
}
theme.SecondaryColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkConstant),
Light: lipgloss.Color(darkConstant),
}
theme.AccentColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkAccent),
Light: lipgloss.Color(darkAccent),
}
// Status colors
theme.ErrorColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkError),
Light: lipgloss.Color(darkError),
}
theme.WarningColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkSpecial),
Light: lipgloss.Color(darkSpecial),
}
theme.SuccessColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkAdded),
Light: lipgloss.Color(darkAdded),
}
theme.InfoColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkTag),
Light: lipgloss.Color(darkTag),
}
// Text colors
theme.TextColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkFg),
Light: lipgloss.Color(darkFg),
}
theme.TextMutedColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkFgMuted),
Light: lipgloss.Color(darkFgMuted),
}
// Background colors
theme.BackgroundColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkBg),
Light: lipgloss.Color(darkBg),
}
theme.BackgroundSubtleColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkBgAlt),
Light: lipgloss.Color(darkBgAlt),
}
theme.BackgroundElementColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkPanel),
Light: lipgloss.Color(darkPanel),
}
// Border colors
theme.BorderColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkGutter),
Light: lipgloss.Color(darkGutter),
}
theme.BorderActiveColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkIndentActive),
Light: lipgloss.Color(darkIndentActive),
}
theme.BorderSubtleColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkLine),
Light: lipgloss.Color(darkLine),
}
// Diff view colors
theme.DiffAddedColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkAdded),
Light: lipgloss.Color(darkAdded),
}
theme.DiffRemovedColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkRemoved),
Light: lipgloss.Color(darkRemoved),
}
theme.DiffContextColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkFgMuted),
Light: lipgloss.Color(darkFgMuted),
}
theme.DiffHunkHeaderColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkGutter),
Light: lipgloss.Color(darkGutter),
}
theme.DiffHighlightAddedColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkAdded),
Light: lipgloss.Color(darkAdded),
}
theme.DiffHighlightRemovedColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkRemoved),
Light: lipgloss.Color(darkRemoved),
}
theme.DiffAddedBgColor = compat.AdaptiveColor{
Dark: lipgloss.Color("#1a2b1a"),
Light: lipgloss.Color("#1a2b1a"),
}
theme.DiffRemovedBgColor = compat.AdaptiveColor{
Dark: lipgloss.Color("#2b1a1a"),
Light: lipgloss.Color("#2b1a1a"),
}
theme.DiffContextBgColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkBgAlt),
Light: lipgloss.Color(darkBgAlt),
}
theme.DiffLineNumberColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkGutter),
Light: lipgloss.Color(darkGutter),
}
theme.DiffAddedLineNumberBgColor = compat.AdaptiveColor{
Dark: lipgloss.Color("#152b15"),
Light: lipgloss.Color("#152b15"),
}
theme.DiffRemovedLineNumberBgColor = compat.AdaptiveColor{
Dark: lipgloss.Color("#2b1515"),
Light: lipgloss.Color("#2b1515"),
}
// Markdown colors
theme.MarkdownTextColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkFg),
Light: lipgloss.Color(darkFg),
}
theme.MarkdownHeadingColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkFunc),
Light: lipgloss.Color(darkFunc),
}
theme.MarkdownLinkColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkTag),
Light: lipgloss.Color(darkTag),
}
theme.MarkdownLinkTextColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkEntity),
Light: lipgloss.Color(darkEntity),
}
theme.MarkdownCodeColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkString),
Light: lipgloss.Color(darkString),
}
theme.MarkdownBlockQuoteColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkSpecial),
Light: lipgloss.Color(darkSpecial),
}
theme.MarkdownEmphColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkKeyword),
Light: lipgloss.Color(darkKeyword),
}
theme.MarkdownStrongColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkMarkup),
Light: lipgloss.Color(darkMarkup),
}
theme.MarkdownHorizontalRuleColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkGutter),
Light: lipgloss.Color(darkGutter),
}
theme.MarkdownListItemColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkOperator),
Light: lipgloss.Color(darkOperator),
}
theme.MarkdownListEnumerationColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkConstant),
Light: lipgloss.Color(darkConstant),
}
theme.MarkdownImageColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkRegexp),
Light: lipgloss.Color(darkRegexp),
}
theme.MarkdownImageTextColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkEntity),
Light: lipgloss.Color(darkEntity),
}
theme.MarkdownCodeBlockColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkString),
Light: lipgloss.Color(darkString),
}
// Syntax highlighting colors
theme.SyntaxCommentColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkComment),
Light: lipgloss.Color(darkComment),
}
theme.SyntaxKeywordColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkKeyword),
Light: lipgloss.Color(darkKeyword),
}
theme.SyntaxFunctionColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkFunc),
Light: lipgloss.Color(darkFunc),
}
theme.SyntaxVariableColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkEntity),
Light: lipgloss.Color(darkEntity),
}
theme.SyntaxStringColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkString),
Light: lipgloss.Color(darkString),
}
theme.SyntaxNumberColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkConstant),
Light: lipgloss.Color(darkConstant),
}
theme.SyntaxTypeColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkSpecial),
Light: lipgloss.Color(darkSpecial),
}
theme.SyntaxOperatorColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkOperator),
Light: lipgloss.Color(darkOperator),
}
theme.SyntaxPunctuationColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkFg),
Light: lipgloss.Color(darkFg),
}
return theme
}
func init() {
// Register the Ayu theme with the theme manager
RegisterTheme("ayu", NewAyuTheme())
}
+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.BackgroundSubtleColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkStep2),
Light: lipgloss.Color(lightStep2),
}
theme.BackgroundElementColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkStep3),
Light: lipgloss.Color(lightStep3),
}
// Border colors
theme.BorderColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkStep7),
Light: lipgloss.Color(lightStep7),
}
theme.BorderActiveColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkStep8),
Light: lipgloss.Color(lightStep8),
}
theme.BorderSubtleColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkStep6),
Light: lipgloss.Color(lightStep6),
}
// Diff view colors
theme.DiffAddedColor = compat.AdaptiveColor{
Dark: lipgloss.Color("#A7C080"),
Light: lipgloss.Color("#8DA101"),
}
theme.DiffRemovedColor = compat.AdaptiveColor{
Dark: lipgloss.Color("#E67E80"),
Light: lipgloss.Color("#F85552"),
}
theme.DiffContextColor = compat.AdaptiveColor{
Dark: lipgloss.Color("#7A8478"),
Light: lipgloss.Color("#A6B0A0"),
}
theme.DiffHunkHeaderColor = compat.AdaptiveColor{
Dark: lipgloss.Color("#859289"),
Light: lipgloss.Color("#939F91"),
}
theme.DiffHighlightAddedColor = compat.AdaptiveColor{
Dark: lipgloss.Color("#A7C080"),
Light: lipgloss.Color("#8DA101"),
}
theme.DiffHighlightRemovedColor = compat.AdaptiveColor{
Dark: lipgloss.Color("#E67E80"),
Light: lipgloss.Color("#F85552"),
}
theme.DiffAddedBgColor = compat.AdaptiveColor{
Dark: lipgloss.Color("#425047"),
Light: lipgloss.Color("#F0F1D2"),
}
theme.DiffRemovedBgColor = compat.AdaptiveColor{
Dark: lipgloss.Color("#543A48"),
Light: lipgloss.Color("#FBE3DA"),
}
theme.DiffContextBgColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkStep2),
Light: lipgloss.Color(lightStep2),
}
theme.DiffLineNumberColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkStep3),
Light: lipgloss.Color(lightStep3),
}
theme.DiffAddedLineNumberBgColor = compat.AdaptiveColor{
Dark: lipgloss.Color("#3A4A3F"),
Light: lipgloss.Color("#E8F2D1"),
}
theme.DiffRemovedLineNumberBgColor = compat.AdaptiveColor{
Dark: lipgloss.Color("#4A3A40"),
Light: lipgloss.Color("#FBDAD2"),
}
// Markdown colors
theme.MarkdownTextColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkStep12),
Light: lipgloss.Color(lightStep12),
}
theme.MarkdownHeadingColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkSecondary),
Light: lipgloss.Color(lightSecondary),
}
theme.MarkdownLinkColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkPrimary),
Light: lipgloss.Color(lightPrimary),
}
theme.MarkdownLinkTextColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkCyan),
Light: lipgloss.Color(lightCyan),
}
theme.MarkdownCodeColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkGreen),
Light: lipgloss.Color(lightGreen),
}
theme.MarkdownBlockQuoteColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkYellow),
Light: lipgloss.Color(lightYellow),
}
theme.MarkdownEmphColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkYellow),
Light: lipgloss.Color(lightYellow),
}
theme.MarkdownStrongColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkAccent),
Light: lipgloss.Color(lightAccent),
}
theme.MarkdownHorizontalRuleColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkStep11),
Light: lipgloss.Color(lightStep11),
}
theme.MarkdownListItemColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkPrimary),
Light: lipgloss.Color(lightPrimary),
}
theme.MarkdownListEnumerationColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkCyan),
Light: lipgloss.Color(lightCyan),
}
theme.MarkdownImageColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkPrimary),
Light: lipgloss.Color(lightPrimary),
}
theme.MarkdownImageTextColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkCyan),
Light: lipgloss.Color(lightCyan),
}
theme.MarkdownCodeBlockColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkStep12),
Light: lipgloss.Color(lightStep12),
}
// Syntax highlighting colors
theme.SyntaxCommentColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkStep11),
Light: lipgloss.Color(lightStep11),
}
theme.SyntaxKeywordColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkPrimary),
Light: lipgloss.Color(lightPrimary),
}
theme.SyntaxFunctionColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkSecondary),
Light: lipgloss.Color(lightSecondary),
}
theme.SyntaxVariableColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkRed),
Light: lipgloss.Color(lightRed),
}
theme.SyntaxStringColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkGreen),
Light: lipgloss.Color(lightGreen),
}
theme.SyntaxNumberColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkAccent),
Light: lipgloss.Color(lightAccent),
}
theme.SyntaxTypeColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkYellow),
Light: lipgloss.Color(lightYellow),
}
theme.SyntaxOperatorColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkCyan),
Light: lipgloss.Color(lightCyan),
}
theme.SyntaxPunctuationColor = compat.AdaptiveColor{
Dark: lipgloss.Color(darkStep12),
Light: lipgloss.Color(lightStep12),
}
return theme
}
func init() {
// Register the Everforest theme with the theme manager
RegisterTheme("everforest", NewEverforestTheme())
}
+432 -258
View File
@@ -3,202 +3,232 @@ package tui
import (
"context"
"log/slog"
"os"
"os/exec"
"strings"
"github.com/charmbracelet/bubbles/v2/cursor"
"github.com/charmbracelet/bubbles/v2/key"
"github.com/charmbracelet/bubbles/v2/spinner"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/core"
"github.com/sst/opencode/internal/completions"
"github.com/sst/opencode/internal/components/chat"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/components/status"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/page"
"github.com/sst/opencode/internal/state"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
"github.com/sst/opencode/pkg/client"
)
type appModel struct {
width, height int
currentPage page.PageID
previousPage page.PageID
pages map[page.PageID]layout.ModelWithView
loadedPages map[page.PageID]bool
status core.StatusComponent
app *app.App
modal layout.Modal
width, height int
app *app.App
modal layout.Modal
status status.StatusComponent
editor chat.EditorComponent
messages chat.MessagesComponent
editorContainer layout.Container
layout layout.FlexLayout
completions dialog.CompletionDialog
completionManager *completions.CompletionManager
showCompletionDialog bool
leaderBinding *key.Binding
isLeaderSequence bool
}
func (a appModel) Init() tea.Cmd {
t := theme.CurrentTheme()
var cmds []tea.Cmd
cmds = append(cmds, tea.SetBackgroundColor(t.Background()))
cmds = append(cmds, tea.RequestBackgroundColor)
cmd := a.pages[a.currentPage].Init()
a.loadedPages[a.currentPage] = true
cmds = append(cmds, cmd)
cmd = a.status.Init()
cmds = append(cmds, cmd)
cmds = append(cmds, a.app.InitializeProvider())
cmds = append(cmds, a.editor.Init())
cmds = append(cmds, a.messages.Init())
cmds = append(cmds, a.status.Init())
cmds = append(cmds, a.completions.Init())
// Check if we should show the init dialog
cmds = append(cmds, func() tea.Msg {
shouldShow := app.Info.Git && app.Info.Time.Initialized == nil
shouldShow := a.app.Info.Git && a.app.Info.Time.Initialized == nil
return dialog.ShowInitDialogMsg{Show: shouldShow}
})
return tea.Batch(cmds...)
}
func (a appModel) updateAllPages(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
for id := range a.pages {
updated, cmd := a.pages[id].Update(msg)
a.pages[id] = updated.(layout.ModelWithView)
cmds = append(cmds, cmd)
}
s, cmd := a.status.Update(msg)
cmds = append(cmds, cmd)
a.status = s.(core.StatusComponent)
return a, tea.Batch(cmds...)
}
func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
if a.modal != nil {
bypassModal := false
if _, ok := msg.(modal.CloseModalMsg); ok {
a.modal = nil
return a, nil
}
if msg, ok := msg.(tea.KeyMsg); ok {
switch msg := msg.(type) {
case tea.KeyPressMsg:
// 1. Handle active modal
if a.modal != nil {
switch msg.String() {
case "esc":
// Escape always closes current modal
case "esc", "ctrl+c":
a.modal = nil
return a, nil
case "ctrl+c":
if _, ok := a.modal.(dialog.QuitDialog); ok {
return a, tea.Quit
} else {
quitDialog := dialog.NewQuitDialog()
a.modal = quitDialog
return a, nil
}
}
// don't send commands to the modal
for _, cmdDef := range a.app.Commands {
if key.Matches(msg, cmdDef.KeyBinding) {
bypassModal = true
break
}
}
}
// thanks i hate this
switch msg.(type) {
case tea.WindowSizeMsg:
bypassModal = true
case client.EventSessionUpdated:
bypassModal = true
case client.EventMessageUpdated:
bypassModal = true
case cursor.BlinkMsg:
bypassModal = true
case spinner.TickMsg:
bypassModal = true
}
if !bypassModal {
// Pass all other key presses to the modal
updatedModal, cmd := a.modal.Update(msg)
a.modal = updatedModal.(layout.Modal)
return a, cmd
}
}
switch msg := msg.(type) {
case commands.ExecuteCommandMsg:
switch msg.Name {
case "quit":
quitDialog := dialog.NewQuitDialog()
a.modal = quitDialog
case "new":
a.app.Session = &client.SessionInfo{}
a.app.Messages = []client.MessageInfo{}
cmds = append(cmds, util.CmdHandler(state.SessionClearedMsg{}))
case "sessions":
sessionDialog := dialog.NewSessionDialog(a.app)
a.modal = sessionDialog
case "model":
modelDialog := dialog.NewModelDialog(a.app)
a.modal = modelDialog
case "theme":
themeDialog := dialog.NewThemeDialog()
a.modal = themeDialog
case "help":
var helpBindings []key.Binding
for _, cmd := range a.app.Commands {
// Create a new binding for help display
helpBindings = append(helpBindings, key.NewBinding(
key.WithKeys(cmd.KeyBinding.Keys()...),
key.WithHelp("/"+cmd.Name, cmd.Description),
))
// 2. Check for commands that require leader
if a.isLeaderSequence {
matches := a.app.Commands.Matches(msg, a.isLeaderSequence)
// Reset leader state
a.isLeaderSequence = false
if len(matches) > 0 {
return a, util.CmdHandler(commands.ExecuteCommandsMsg(matches))
}
helpDialog := dialog.NewHelpDialog(helpBindings...)
a.modal = helpDialog
}
slog.Info("Execute command", "cmds", cmds)
return a, tea.Batch(cmds...)
// 3. Handle completions trigger
switch msg.String() {
case "/":
if !a.showCompletionDialog {
a.showCompletionDialog = true
initialValue := "/"
currentInput := a.editor.Value()
// if the input doesn't end with a space,
// then we want to include the last word
if !strings.HasSuffix(currentInput, " ") {
words := strings.Split(a.editor.Value(), " ")
if len(words) > 0 {
lastWord := words[len(words)-1]
lastWord = strings.TrimSpace(lastWord)
initialValue = lastWord + "/"
}
}
updated, cmd := a.completions.Update(
app.CompletionDialogTriggerdMsg{
InitialValue: initialValue,
},
)
a.completions = updated.(dialog.CompletionDialog)
cmds = append(cmds, cmd)
updated, cmd = a.completions.Update(msg)
a.completions = updated.(dialog.CompletionDialog)
cmds = append(cmds, cmd)
updated, cmd = a.editor.Update(msg)
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
return a, tea.Sequence(cmds...)
}
}
if a.showCompletionDialog {
switch msg.String() {
case "tab", "enter", "esc":
context, contextCmd := a.completions.Update(msg)
a.completions = context.(dialog.CompletionDialog)
cmds = append(cmds, contextCmd)
return a, tea.Batch(cmds...)
}
updated, cmd := a.editor.Update(msg)
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
currentInput := a.editor.Value()
provider := a.completionManager.GetProvider(currentInput)
a.completions.SetProvider(provider)
context, contextCmd := a.completions.Update(msg)
a.completions = context.(dialog.CompletionDialog)
cmds = append(cmds, contextCmd)
return a, tea.Batch(cmds...)
}
// 4. Maximize editor responsiveness for printable characters
if msg.Text != "" {
updated, cmd := a.editor.Update(msg)
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
return a, tea.Batch(cmds...)
}
// 5. Check for leader key activation
if a.leaderBinding != nil &&
!a.isLeaderSequence &&
key.Matches(msg, *a.leaderBinding) {
a.isLeaderSequence = true
return a, nil
}
// 6. Check again for commands that don't require leader
matches := a.app.Commands.Matches(msg, a.isLeaderSequence)
if len(matches) > 0 {
return a, util.CmdHandler(commands.ExecuteCommandsMsg(matches))
}
// 7. Fallback to editor. This shouldn't happen?
// All printable characters were already sent, and
// any other keypress that didn't match a command
// is likely a noop.
updatedEditor, cmd := a.editor.Update(msg)
a.editor = updatedEditor.(chat.EditorComponent)
return a, cmd
case tea.MouseWheelMsg:
if a.modal != nil {
return a, nil
}
updated, cmd := a.messages.Update(msg)
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case tea.BackgroundColorMsg:
styles.Terminal = &styles.TerminalInfo{
BackgroundIsDark: msg.IsDark(),
}
case cursor.BlinkMsg:
return a.updateAllPages(msg)
case spinner.TickMsg:
return a.updateAllPages(msg)
slog.Debug("Background color", "isDark", msg.IsDark())
case modal.CloseModalMsg:
a.modal = nil
return a, nil
case commands.ExecuteCommandMsg:
updated, cmd := a.executeCommand(commands.Command(msg))
return updated, cmd
case commands.ExecuteCommandsMsg:
for _, command := range msg {
updated, cmd := a.executeCommand(command)
if cmd != nil {
return updated, cmd
}
}
case app.SendMsg:
a.showCompletionDialog = false
cmd := a.app.SendChatMessage(context.Background(), msg.Text, msg.Attachments)
cmds = append(cmds, cmd)
case dialog.CompletionDialogCloseMsg:
a.showCompletionDialog = false
a.completions.SetProvider(a.completionManager.DefaultProvider())
case client.EventSessionUpdated:
if msg.Properties.Info.Id == a.app.Session.Id {
a.app.Session = &msg.Properties.Info
return a.updateAllPages(state.StateUpdatedMsg{State: nil})
}
case client.EventMessageUpdated:
if msg.Properties.Info.Metadata.SessionID == a.app.Session.Id {
exists := false
for i, m := range a.app.Messages {
if m.Id == msg.Properties.Info.Id {
a.app.Messages[i] = msg.Properties.Info
return a.updateAllPages(state.StateUpdatedMsg{State: nil})
exists = true
}
}
a.app.Messages = append(a.app.Messages, msg.Properties.Info)
return a.updateAllPages(state.StateUpdatedMsg{State: nil})
if !exists {
a.app.Messages = append(a.app.Messages, msg.Properties.Info)
}
}
case tea.WindowSizeMsg:
msg.Height -= 2 // Make space for the status bar
a.width, a.height = msg.Width, msg.Height
// TODO: move away from global state
layout.Current = &layout.LayoutInfo{
Viewport: layout.Dimensions{
Width: a.width,
@@ -208,140 +238,79 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
Width: min(a.width, 80),
},
}
s, cmd := a.status.Update(msg)
a.status = s.(core.StatusComponent)
if cmd != nil {
cmds = append(cmds, cmd)
}
updated, cmd := a.pages[a.currentPage].Update(msg)
a.pages[a.currentPage] = updated.(layout.ModelWithView)
if cmd != nil {
cmds = append(cmds, cmd)
}
if a.modal != nil {
s, cmd := a.modal.Update(msg)
a.modal = s.(layout.Modal)
if cmd != nil {
cmds = append(cmds, cmd)
}
}
return a, tea.Batch(cmds...)
// case pubsub.Event[permission.PermissionRequest]:
// a.showPermissions = true
// return a, a.permissions.SetPermissions(msg.Payload)
case dialog.PermissionResponseMsg:
// TODO: Permissions service not implemented in API yet
// var cmd tea.Cmd
// switch msg.Action {
// case dialog.PermissionAllow:
// a.app.Permissions.Grant(context.Background(), msg.Permission)
// case dialog.PermissionAllowForSession:
// a.app.Permissions.GrantPersistant(context.Background(), msg.Permission)
// case dialog.PermissionDeny:
// a.app.Permissions.Deny(context.Background(), msg.Permission)
// }
// a.showPermissions = false
return a, nil
case page.PageChangeMsg:
return a, a.moveToPage(msg.ID)
case state.SessionSelectedMsg:
a.layout.SetSize(a.width, a.height)
case app.SessionSelectedMsg:
var err error
a.app.Session = msg
a.app.Messages, _ = a.app.ListMessages(context.Background(), msg.Id)
return a.updateAllPages(msg)
case state.ModelSelectedMsg:
a.app.Messages, err = a.app.ListMessages(context.Background(), msg.Id)
if err != nil {
slog.Error("Failed to list messages", "error", err)
}
case app.ModelSelectedMsg:
a.app.Provider = &msg.Provider
a.app.Model = &msg.Model
a.app.Config.Provider = msg.Provider.Id
a.app.Config.Model = msg.Model.Id
a.app.SaveConfig()
return a.updateAllPages(msg)
case dialog.ThemeChangedMsg:
a.app.Config.Theme = msg.ThemeName
a.app.SaveConfig()
updated, cmd := a.pages[a.currentPage].Update(msg)
a.pages[a.currentPage] = updated.(layout.ModelWithView)
if cmd != nil {
cmds = append(cmds, cmd)
}
s, cmd := a.status.Update(msg)
cmds = append(cmds, cmd)
a.status = s.(core.StatusComponent)
t := theme.CurrentTheme()
cmds = append(cmds, tea.SetBackgroundColor(t.Background()))
return a, tea.Batch(cmds...)
case tea.KeyMsg:
switch msg.String() {
// give the editor a chance to clear input
case "ctrl+c":
updated, cmd := a.pages[a.currentPage].Update(msg)
a.pages[a.currentPage] = updated.(layout.ModelWithView)
if cmd != nil {
return a, cmd
}
}
// First, check for modal triggers from the command registry
if a.modal == nil {
for _, cmdDef := range a.app.Commands {
if key.Matches(msg, cmdDef.KeyBinding) {
// If a key matches, send an ExecuteCommandMsg to self.
// This unifies keybinding and slash command handling.
return a, util.CmdHandler(commands.ExecuteCommandMsg{Name: cmdDef.Name})
}
}
}
a.app.State.Provider = msg.Provider.Id
a.app.State.Model = msg.Model.Id
a.app.SaveState()
case dialog.ThemeSelectedMsg:
a.app.State.Theme = msg.ThemeName
a.app.SaveState()
}
// update status bar
s, cmd := a.status.Update(msg)
cmds = append(cmds, cmd)
a.status = s.(core.StatusComponent)
a.status = s.(status.StatusComponent)
// update current page
updated, cmd := a.pages[a.currentPage].Update(msg)
a.pages[a.currentPage] = updated.(layout.ModelWithView)
// update editor
u, cmd := a.editor.Update(msg)
a.editor = u.(chat.EditorComponent)
cmds = append(cmds, cmd)
// update messages
u, cmd = a.messages.Update(msg)
a.messages = u.(chat.MessagesComponent)
cmds = append(cmds, cmd)
// update modal
if a.modal != nil {
u, cmd := a.modal.Update(msg)
a.modal = u.(layout.Modal)
cmds = append(cmds, cmd)
}
if a.showCompletionDialog {
u, cmd := a.completions.Update(msg)
a.completions = u.(dialog.CompletionDialog)
cmds = append(cmds, cmd)
}
return a, tea.Batch(cmds...)
}
func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
var cmds []tea.Cmd
if _, ok := a.loadedPages[pageID]; !ok {
cmd := a.pages[pageID].Init()
cmds = append(cmds, cmd)
a.loadedPages[pageID] = true
}
a.previousPage = a.currentPage
a.currentPage = pageID
if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
cmd := sizable.SetSize(a.width, a.height)
cmds = append(cmds, cmd)
}
return tea.Batch(cmds...)
}
func (a appModel) View() string {
components := []string{
a.pages[a.currentPage].View(),
}
components = append(components, a.status.View())
appView := lipgloss.JoinVertical(lipgloss.Top, components...)
layoutView := a.layout.View()
if a.showCompletionDialog {
editorWidth, _ := a.editorContainer.GetSize()
editorX, editorY := a.editorContainer.GetPosition()
a.completions.SetWidth(editorWidth)
overlay := a.completions.View()
layoutView = layout.PlaceOverlay(
editorX,
editorY-lipgloss.Height(overlay)+2,
overlay,
layoutView,
)
}
components := []string{
layoutView,
a.status.View(),
}
appView := lipgloss.JoinVertical(lipgloss.Top, components...)
if a.modal != nil {
appView = a.modal.Render(appView)
}
@@ -349,16 +318,221 @@ func (a appModel) View() string {
return appView
}
func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) {
cmds := []tea.Cmd{
util.CmdHandler(commands.CommandExecutedMsg(command)),
}
switch command.Name {
case commands.AppHelpCommand:
helpDialog := dialog.NewHelpDialog(a.app.Commands.Sorted())
a.modal = helpDialog
case commands.EditorOpenCommand:
if a.app.IsBusy() {
// status.Warn("Agent is working, please wait...")
return a, nil
}
editor := os.Getenv("EDITOR")
if editor == "" {
// TODO: let the user know there's no EDITOR set
return a, nil
}
value := a.editor.Value()
updated, cmd := a.editor.Clear()
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
tmpfile, err := os.CreateTemp("", "msg_*.md")
tmpfile.WriteString(value)
if err != nil {
slog.Error("Failed to create temp file", "error", err)
return a, nil
}
tmpfile.Close()
c := exec.Command(editor, tmpfile.Name()) //nolint:gosec
c.Stdin = os.Stdin
c.Stdout = os.Stdout
c.Stderr = os.Stderr
cmd = tea.ExecProcess(c, func(err error) tea.Msg {
if err != nil {
slog.Error("Failed to open editor", "error", err)
return nil
}
content, err := os.ReadFile(tmpfile.Name())
if err != nil {
slog.Error("Failed to read file", "error", err)
return nil
}
if len(content) == 0 {
slog.Warn("Message is empty")
return nil
}
os.Remove(tmpfile.Name())
// attachments := m.attachments
// m.attachments = nil
return app.SendMsg{
Text: string(content),
Attachments: []app.Attachment{}, // attachments,
}
})
cmds = append(cmds, cmd)
case commands.SessionNewCommand:
if a.app.Session.Id == "" {
return a, nil
}
a.app.Session = &client.SessionInfo{}
a.app.Messages = []client.MessageInfo{}
cmds = append(cmds, util.CmdHandler(app.SessionClearedMsg{}))
case commands.SessionListCommand:
sessionDialog := dialog.NewSessionDialog(a.app)
a.modal = sessionDialog
case commands.SessionShareCommand:
if a.app.Session.Id == "" {
return a, nil
}
a.app.Client.PostSessionShareWithResponse(
context.Background(),
client.PostSessionShareJSONRequestBody{
SessionID: a.app.Session.Id,
},
)
case commands.SessionInterruptCommand:
if a.app.Session.Id == "" {
return a, nil
}
a.app.Cancel(context.Background(), a.app.Session.Id)
return a, nil
case commands.SessionCompactCommand:
if a.app.Session.Id == "" {
return a, nil
}
// TODO: block until compaction is complete
a.app.CompactSession(context.Background())
case commands.ToolDetailsCommand:
cmds = append(cmds, util.CmdHandler(chat.ToggleToolDetailsMsg{}))
case commands.ModelListCommand:
modelDialog := dialog.NewModelDialog(a.app)
a.modal = modelDialog
case commands.ThemeListCommand:
themeDialog := dialog.NewThemeDialog()
a.modal = themeDialog
case commands.ProjectInitCommand:
cmds = append(cmds, a.app.InitializeProject(context.Background()))
case commands.InputClearCommand:
if a.editor.Value() == "" {
return a, nil
}
updated, cmd := a.editor.Clear()
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
case commands.InputPasteCommand:
updated, cmd := a.editor.Paste()
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
case commands.InputSubmitCommand:
updated, cmd := a.editor.Submit()
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
case commands.InputNewlineCommand:
updated, cmd := a.editor.Newline()
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
case commands.HistoryPreviousCommand:
if a.showCompletionDialog {
return a, nil
}
updated, cmd := a.editor.Previous()
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
case commands.HistoryNextCommand:
if a.showCompletionDialog {
return a, nil
}
updated, cmd := a.editor.Next()
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
case commands.MessagesFirstCommand:
updated, cmd := a.messages.First()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case commands.MessagesLastCommand:
updated, cmd := a.messages.Last()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case commands.MessagesPageUpCommand:
if a.showCompletionDialog {
return a, nil
}
updated, cmd := a.messages.PageUp()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case commands.MessagesPageDownCommand:
if a.showCompletionDialog {
return a, nil
}
updated, cmd := a.messages.PageDown()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case commands.MessagesHalfPageUpCommand:
if a.showCompletionDialog {
return a, nil
}
updated, cmd := a.messages.HalfPageUp()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case commands.MessagesHalfPageDownCommand:
if a.showCompletionDialog {
return a, nil
}
updated, cmd := a.messages.HalfPageDown()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case commands.AppExitCommand:
return a, tea.Quit
}
return a, tea.Batch(cmds...)
}
func NewModel(app *app.App) tea.Model {
startPage := page.ChatPage
completionManager := completions.NewCompletionManager(app)
initialProvider := completionManager.DefaultProvider()
messages := chat.NewMessagesComponent(app)
editor := chat.NewEditorComponent(app)
completions := dialog.NewCompletionDialogComponent(initialProvider)
editorContainer := layout.NewContainer(
editor,
layout.WithMaxWidth(layout.Current.Container.Width),
layout.WithAlignCenter(),
)
messagesContainer := layout.NewContainer(messages)
var leaderBinding *key.Binding
if (*app.Configg.Keybinds).Leader != nil {
binding := key.NewBinding(key.WithKeys(*app.Configg.Keybinds.Leader))
leaderBinding = &binding
}
model := &appModel{
currentPage: startPage,
loadedPages: make(map[page.PageID]bool),
status: core.NewStatusCmp(app),
app: app,
pages: map[page.PageID]layout.ModelWithView{
page.ChatPage: page.NewChatPage(app),
},
status: status.NewStatusCmp(app),
app: app,
editor: editor,
messages: messages,
completions: completions,
completionManager: completionManager,
leaderBinding: leaderBinding,
isLeaderSequence: false,
showCompletionDialog: false,
editorContainer: editorContainer,
layout: layout.NewFlexLayout(
[]tea.ViewModel{messagesContainer, editorContainer},
layout.WithDirection(layout.FlexDirectionVertical),
layout.WithSizes(
layout.FlexChildSizeGrow,
layout.FlexChildSizeFixed(6),
),
),
}
return model
+395 -18
View File
@@ -44,6 +44,25 @@
"description": "Get app info"
}
},
"/config_get": {
"post": {
"responses": {
"200": {
"description": "Get config info",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Config.Info"
}
}
}
}
},
"operationId": "postConfig_get",
"parameters": [],
"description": "Get config info"
}
},
"/app_initialize": {
"post": {
"responses": {
@@ -478,6 +497,25 @@
}
}
}
},
"/installation_info": {
"post": {
"responses": {
"200": {
"description": "Get installation info",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InstallationInfo"
}
}
}
}
},
"operationId": "postInstallation_info",
"parameters": [],
"description": "Get installation info"
}
}
},
"components": {
@@ -499,6 +537,9 @@
{
"$ref": "#/components/schemas/Event.message.part.updated"
},
{
"$ref": "#/components/schemas/Event.installation.updated"
},
{
"$ref": "#/components/schemas/Event.session.updated"
},
@@ -514,6 +555,7 @@
"permission.updated": "#/components/schemas/Event.permission.updated",
"message.updated": "#/components/schemas/Event.message.updated",
"message.part.updated": "#/components/schemas/Event.message.part.updated",
"installation.updated": "#/components/schemas/Event.installation.updated",
"session.updated": "#/components/schemas/Event.session.updated",
"session.error": "#/components/schemas/Event.session.error"
}
@@ -782,12 +824,28 @@
},
"reasoning": {
"type": "number"
},
"cache": {
"type": "object",
"properties": {
"read": {
"type": "number"
},
"write": {
"type": "number"
}
},
"required": [
"read",
"write"
]
}
},
"required": [
"input",
"output",
"reasoning"
"reasoning",
"cache"
]
}
},
@@ -1143,6 +1201,30 @@
"properties"
]
},
"Event.installation.updated": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "installation.updated"
},
"properties": {
"type": "object",
"properties": {
"version": {
"type": "string"
}
},
"required": [
"version"
]
}
},
"required": [
"type",
"properties"
]
},
"Event.session.updated": {
"type": "object",
"properties": {
@@ -1181,21 +1263,20 @@
"share": {
"type": "object",
"properties": {
"secret": {
"type": "string"
},
"url": {
"type": "string"
}
},
"required": [
"secret",
"url"
]
},
"title": {
"type": "string"
},
"version": {
"type": "string"
},
"time": {
"type": "object",
"properties": {
@@ -1215,6 +1296,7 @@
"required": [
"id",
"title",
"version",
"time"
]
},
@@ -1276,13 +1358,17 @@
},
"cwd": {
"type": "string"
},
"state": {
"type": "string"
}
},
"required": [
"config",
"data",
"root",
"cwd"
"cwd",
"state"
]
},
"time": {
@@ -1301,21 +1387,243 @@
"time"
]
},
"Error": {
"Config.Info": {
"type": "object",
"properties": {
"data": {
"$schema": {
"type": "string"
},
"theme": {
"type": "string"
},
"keybinds": {
"$ref": "#/components/schemas/Config.Keybinds"
},
"autoshare": {
"type": "boolean",
"description": "Share newly created sessions automatically"
},
"autoupdate": {
"type": "boolean",
"description": "Automatically update to the latest version"
},
"disabled_providers": {
"type": "array",
"items": {
"type": "string"
},
"description": "Disable providers that are loaded automatically"
},
"model": {
"type": "string",
"description": "Model to use in the format of provider/model, eg anthropic/claude-2"
},
"provider": {
"type": "object",
"additionalProperties": {}
"additionalProperties": {
"type": "object",
"properties": {
"api": {
"type": "string"
},
"name": {
"type": "string"
},
"env": {
"type": "array",
"items": {
"type": "string"
}
},
"id": {
"type": "string"
},
"npm": {
"type": "string"
},
"models": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"attachment": {
"type": "boolean"
},
"reasoning": {
"type": "boolean"
},
"temperature": {
"type": "boolean"
},
"cost": {
"type": "object",
"properties": {
"input": {
"type": "number"
},
"output": {
"type": "number"
},
"cache_read": {
"type": "number"
},
"cache_write": {
"type": "number"
}
},
"required": [
"input",
"output"
]
},
"limit": {
"type": "object",
"properties": {
"context": {
"type": "number"
},
"output": {
"type": "number"
}
},
"required": [
"context",
"output"
]
},
"id": {
"type": "string"
}
}
}
},
"options": {
"type": "object",
"additionalProperties": {}
}
},
"required": [
"models"
]
}
},
"mcp": {
"type": "object",
"additionalProperties": {
"oneOf": [
{
"$ref": "#/components/schemas/Config.McpLocal"
},
{
"$ref": "#/components/schemas/Config.McpRemote"
}
],
"discriminator": {
"propertyName": "type",
"mapping": {
"local": "#/components/schemas/Config.McpLocal",
"remote": "#/components/schemas/Config.McpRemote"
}
}
}
}
},
"required": [
"data"
]
}
},
"Config.Keybinds": {
"type": "object",
"properties": {
"leader": {
"type": "string"
},
"help": {
"type": "string"
},
"editor_open": {
"type": "string"
},
"session_new": {
"type": "string"
},
"session_list": {
"type": "string"
},
"session_share": {
"type": "string"
},
"session_interrupt": {
"type": "string"
},
"session_compact": {
"type": "string"
},
"tool_details": {
"type": "string"
},
"model_list": {
"type": "string"
},
"theme_list": {
"type": "string"
},
"project_init": {
"type": "string"
},
"input_clear": {
"type": "string"
},
"input_paste": {
"type": "string"
},
"input_submit": {
"type": "string"
},
"input_newline": {
"type": "string"
},
"history_previous": {
"type": "string"
},
"history_next": {
"type": "string"
},
"messages_page_up": {
"type": "string"
},
"messages_page_down": {
"type": "string"
},
"messages_half_page_up": {
"type": "string"
},
"messages_half_page_down": {
"type": "string"
},
"messages_previous": {
"type": "string"
},
"messages_next": {
"type": "string"
},
"messages_first": {
"type": "string"
},
"messages_last": {
"type": "string"
},
"app_exit": {
"type": "string"
}
}
},
"Provider.Info": {
"type": "object",
"properties": {
"api": {
"type": "string"
},
"name": {
"type": "string"
},
@@ -1328,6 +1636,9 @@
"id": {
"type": "string"
},
"npm": {
"type": "string"
},
"models": {
"type": "object",
"additionalProperties": {
@@ -1366,18 +1677,16 @@
"output": {
"type": "number"
},
"inputCached": {
"cache_read": {
"type": "number"
},
"outputCached": {
"cache_write": {
"type": "number"
}
},
"required": [
"input",
"output",
"inputCached",
"outputCached"
"output"
]
},
"limit": {
@@ -1408,6 +1717,74 @@
"limit",
"id"
]
},
"Config.McpLocal": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "local"
},
"command": {
"type": "array",
"items": {
"type": "string"
}
},
"environment": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"required": [
"type",
"command"
]
},
"Config.McpRemote": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "remote"
},
"url": {
"type": "string"
}
},
"required": [
"type",
"url"
]
},
"Error": {
"type": "object",
"properties": {
"data": {
"type": "object",
"additionalProperties": {}
}
},
"required": [
"data"
]
},
"InstallationInfo": {
"type": "object",
"properties": {
"version": {
"type": "string"
},
"latest": {
"type": "string"
}
},
"required": [
"version",
"latest"
]
}
}
}
+445 -7
View File
@@ -31,6 +31,7 @@ type AppInfo struct {
Cwd string `json:"cwd"`
Data string `json:"data"`
Root string `json:"root"`
State string `json:"state"`
} `json:"path"`
Time struct {
Initialized *float32 `json:"initialized,omitempty"`
@@ -38,6 +39,100 @@ type AppInfo struct {
User string `json:"user"`
}
// ConfigInfo defines model for Config.Info.
type ConfigInfo struct {
Schema *string `json:"$schema,omitempty"`
// Autoshare Share newly created sessions automatically
Autoshare *bool `json:"autoshare,omitempty"`
// Autoupdate Automatically update to the latest version
Autoupdate *bool `json:"autoupdate,omitempty"`
// DisabledProviders Disable providers that are loaded automatically
DisabledProviders *[]string `json:"disabled_providers,omitempty"`
Keybinds *ConfigKeybinds `json:"keybinds,omitempty"`
Mcp *map[string]ConfigInfo_Mcp_AdditionalProperties `json:"mcp,omitempty"`
// Model Model to use in the format of provider/model, eg anthropic/claude-2
Model *string `json:"model,omitempty"`
Provider *map[string]struct {
Api *string `json:"api,omitempty"`
Env *[]string `json:"env,omitempty"`
Id *string `json:"id,omitempty"`
Models map[string]struct {
Attachment *bool `json:"attachment,omitempty"`
Cost *struct {
CacheRead *float32 `json:"cache_read,omitempty"`
CacheWrite *float32 `json:"cache_write,omitempty"`
Input float32 `json:"input"`
Output float32 `json:"output"`
} `json:"cost,omitempty"`
Id *string `json:"id,omitempty"`
Limit *struct {
Context float32 `json:"context"`
Output float32 `json:"output"`
} `json:"limit,omitempty"`
Name *string `json:"name,omitempty"`
Reasoning *bool `json:"reasoning,omitempty"`
Temperature *bool `json:"temperature,omitempty"`
} `json:"models"`
Name *string `json:"name,omitempty"`
Npm *string `json:"npm,omitempty"`
Options *map[string]interface{} `json:"options,omitempty"`
} `json:"provider,omitempty"`
Theme *string `json:"theme,omitempty"`
}
// ConfigInfo_Mcp_AdditionalProperties defines model for Config.Info.mcp.AdditionalProperties.
type ConfigInfo_Mcp_AdditionalProperties struct {
union json.RawMessage
}
// ConfigKeybinds defines model for Config.Keybinds.
type ConfigKeybinds struct {
AppExit *string `json:"app_exit,omitempty"`
EditorOpen *string `json:"editor_open,omitempty"`
Help *string `json:"help,omitempty"`
HistoryNext *string `json:"history_next,omitempty"`
HistoryPrevious *string `json:"history_previous,omitempty"`
InputClear *string `json:"input_clear,omitempty"`
InputNewline *string `json:"input_newline,omitempty"`
InputPaste *string `json:"input_paste,omitempty"`
InputSubmit *string `json:"input_submit,omitempty"`
Leader *string `json:"leader,omitempty"`
MessagesFirst *string `json:"messages_first,omitempty"`
MessagesHalfPageDown *string `json:"messages_half_page_down,omitempty"`
MessagesHalfPageUp *string `json:"messages_half_page_up,omitempty"`
MessagesLast *string `json:"messages_last,omitempty"`
MessagesNext *string `json:"messages_next,omitempty"`
MessagesPageDown *string `json:"messages_page_down,omitempty"`
MessagesPageUp *string `json:"messages_page_up,omitempty"`
MessagesPrevious *string `json:"messages_previous,omitempty"`
ModelList *string `json:"model_list,omitempty"`
ProjectInit *string `json:"project_init,omitempty"`
SessionCompact *string `json:"session_compact,omitempty"`
SessionInterrupt *string `json:"session_interrupt,omitempty"`
SessionList *string `json:"session_list,omitempty"`
SessionNew *string `json:"session_new,omitempty"`
SessionShare *string `json:"session_share,omitempty"`
ThemeList *string `json:"theme_list,omitempty"`
ToolDetails *string `json:"tool_details,omitempty"`
}
// ConfigMcpLocal defines model for Config.McpLocal.
type ConfigMcpLocal struct {
Command []string `json:"command"`
Environment *map[string]string `json:"environment,omitempty"`
Type string `json:"type"`
}
// ConfigMcpRemote defines model for Config.McpRemote.
type ConfigMcpRemote struct {
Type string `json:"type"`
Url string `json:"url"`
}
// Error defines model for Error.
type Error struct {
Data map[string]interface{} `json:"data"`
@@ -48,6 +143,14 @@ type Event struct {
union json.RawMessage
}
// EventInstallationUpdated defines model for Event.installation.updated.
type EventInstallationUpdated struct {
Properties struct {
Version string `json:"version"`
} `json:"properties"`
Type string `json:"type"`
}
// EventLspClientDiagnostics defines model for Event.lsp.client.diagnostics.
type EventLspClientDiagnostics struct {
Properties struct {
@@ -111,6 +214,12 @@ type EventStorageWrite struct {
Type string `json:"type"`
}
// InstallationInfo defines model for InstallationInfo.
type InstallationInfo struct {
Latest string `json:"latest"`
Version string `json:"version"`
}
// MessageInfo defines model for Message.Info.
type MessageInfo struct {
Id string `json:"id"`
@@ -126,6 +235,10 @@ type MessageInfo struct {
Summary *bool `json:"summary,omitempty"`
System []string `json:"system"`
Tokens struct {
Cache struct {
Read float32 `json:"read"`
Write float32 `json:"write"`
} `json:"cache"`
Input float32 `json:"input"`
Output float32 `json:"output"`
Reasoning float32 `json:"reasoning"`
@@ -244,10 +357,10 @@ type MessageToolInvocationToolResult struct {
type ModelInfo struct {
Attachment bool `json:"attachment"`
Cost struct {
Input float32 `json:"input"`
InputCached float32 `json:"inputCached"`
Output float32 `json:"output"`
OutputCached float32 `json:"outputCached"`
CacheRead *float32 `json:"cache_read,omitempty"`
CacheWrite *float32 `json:"cache_write,omitempty"`
Input float32 `json:"input"`
Output float32 `json:"output"`
} `json:"cost"`
Id string `json:"id"`
Limit struct {
@@ -261,10 +374,12 @@ type ModelInfo struct {
// ProviderInfo defines model for Provider.Info.
type ProviderInfo struct {
Api *string `json:"api,omitempty"`
Env []string `json:"env"`
Id string `json:"id"`
Models map[string]ModelInfo `json:"models"`
Name string `json:"name"`
Npm *string `json:"npm,omitempty"`
}
// ProviderAuthError defines model for ProviderAuthError.
@@ -300,14 +415,14 @@ type SessionInfo struct {
Id string `json:"id"`
ParentID *string `json:"parentID,omitempty"`
Share *struct {
Secret string `json:"secret"`
Url string `json:"url"`
Url string `json:"url"`
} `json:"share,omitempty"`
Time struct {
Created float32 `json:"created"`
Updated float32 `json:"updated"`
} `json:"time"`
Title string `json:"title"`
Title string `json:"title"`
Version string `json:"version"`
}
// PostFileSearchJSONBody defines parameters for PostFileSearch.
@@ -452,6 +567,95 @@ func (a MessageInfo_Metadata_Tool_AdditionalProperties) MarshalJSON() ([]byte, e
return json.Marshal(object)
}
// AsConfigMcpLocal returns the union data inside the ConfigInfo_Mcp_AdditionalProperties as a ConfigMcpLocal
func (t ConfigInfo_Mcp_AdditionalProperties) AsConfigMcpLocal() (ConfigMcpLocal, error) {
var body ConfigMcpLocal
err := json.Unmarshal(t.union, &body)
return body, err
}
// FromConfigMcpLocal overwrites any union data inside the ConfigInfo_Mcp_AdditionalProperties as the provided ConfigMcpLocal
func (t *ConfigInfo_Mcp_AdditionalProperties) FromConfigMcpLocal(v ConfigMcpLocal) error {
v.Type = "local"
b, err := json.Marshal(v)
t.union = b
return err
}
// MergeConfigMcpLocal performs a merge with any union data inside the ConfigInfo_Mcp_AdditionalProperties, using the provided ConfigMcpLocal
func (t *ConfigInfo_Mcp_AdditionalProperties) MergeConfigMcpLocal(v ConfigMcpLocal) error {
v.Type = "local"
b, err := json.Marshal(v)
if err != nil {
return err
}
merged, err := runtime.JSONMerge(t.union, b)
t.union = merged
return err
}
// AsConfigMcpRemote returns the union data inside the ConfigInfo_Mcp_AdditionalProperties as a ConfigMcpRemote
func (t ConfigInfo_Mcp_AdditionalProperties) AsConfigMcpRemote() (ConfigMcpRemote, error) {
var body ConfigMcpRemote
err := json.Unmarshal(t.union, &body)
return body, err
}
// FromConfigMcpRemote overwrites any union data inside the ConfigInfo_Mcp_AdditionalProperties as the provided ConfigMcpRemote
func (t *ConfigInfo_Mcp_AdditionalProperties) FromConfigMcpRemote(v ConfigMcpRemote) error {
v.Type = "remote"
b, err := json.Marshal(v)
t.union = b
return err
}
// MergeConfigMcpRemote performs a merge with any union data inside the ConfigInfo_Mcp_AdditionalProperties, using the provided ConfigMcpRemote
func (t *ConfigInfo_Mcp_AdditionalProperties) MergeConfigMcpRemote(v ConfigMcpRemote) error {
v.Type = "remote"
b, err := json.Marshal(v)
if err != nil {
return err
}
merged, err := runtime.JSONMerge(t.union, b)
t.union = merged
return err
}
func (t ConfigInfo_Mcp_AdditionalProperties) Discriminator() (string, error) {
var discriminator struct {
Discriminator string `json:"type"`
}
err := json.Unmarshal(t.union, &discriminator)
return discriminator.Discriminator, err
}
func (t ConfigInfo_Mcp_AdditionalProperties) ValueByDiscriminator() (interface{}, error) {
discriminator, err := t.Discriminator()
if err != nil {
return nil, err
}
switch discriminator {
case "local":
return t.AsConfigMcpLocal()
case "remote":
return t.AsConfigMcpRemote()
default:
return nil, errors.New("unknown discriminator value: " + discriminator)
}
}
func (t ConfigInfo_Mcp_AdditionalProperties) MarshalJSON() ([]byte, error) {
b, err := t.union.MarshalJSON()
return b, err
}
func (t *ConfigInfo_Mcp_AdditionalProperties) UnmarshalJSON(b []byte) error {
err := t.union.UnmarshalJSON(b)
return err
}
// AsEventStorageWrite returns the union data inside the Event as a EventStorageWrite
func (t Event) AsEventStorageWrite() (EventStorageWrite, error) {
var body EventStorageWrite
@@ -592,6 +796,34 @@ func (t *Event) MergeEventMessagePartUpdated(v EventMessagePartUpdated) error {
return err
}
// AsEventInstallationUpdated returns the union data inside the Event as a EventInstallationUpdated
func (t Event) AsEventInstallationUpdated() (EventInstallationUpdated, error) {
var body EventInstallationUpdated
err := json.Unmarshal(t.union, &body)
return body, err
}
// FromEventInstallationUpdated overwrites any union data inside the Event as the provided EventInstallationUpdated
func (t *Event) FromEventInstallationUpdated(v EventInstallationUpdated) error {
v.Type = "installation.updated"
b, err := json.Marshal(v)
t.union = b
return err
}
// MergeEventInstallationUpdated performs a merge with any union data inside the Event, using the provided EventInstallationUpdated
func (t *Event) MergeEventInstallationUpdated(v EventInstallationUpdated) error {
v.Type = "installation.updated"
b, err := json.Marshal(v)
if err != nil {
return err
}
merged, err := runtime.JSONMerge(t.union, b)
t.union = merged
return err
}
// AsEventSessionUpdated returns the union data inside the Event as a EventSessionUpdated
func (t Event) AsEventSessionUpdated() (EventSessionUpdated, error) {
var body EventSessionUpdated
@@ -662,6 +894,8 @@ func (t Event) ValueByDiscriminator() (interface{}, error) {
return nil, err
}
switch discriminator {
case "installation.updated":
return t.AsEventInstallationUpdated()
case "lsp.client.diagnostics":
return t.AsEventLspClientDiagnostics()
case "message.part.updated":
@@ -1276,6 +1510,9 @@ type ClientInterface interface {
// PostAppInitialize request
PostAppInitialize(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error)
// PostConfigGet request
PostConfigGet(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error)
// GetEvent request
GetEvent(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error)
@@ -1284,6 +1521,9 @@ type ClientInterface interface {
PostFileSearch(ctx context.Context, body PostFileSearchJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
// PostInstallationInfo request
PostInstallationInfo(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error)
// PostPathGet request
PostPathGet(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error)
@@ -1351,6 +1591,18 @@ func (c *Client) PostAppInitialize(ctx context.Context, reqEditors ...RequestEdi
return c.Client.Do(req)
}
func (c *Client) PostConfigGet(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewPostConfigGetRequest(c.Server)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
return c.Client.Do(req)
}
func (c *Client) GetEvent(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewGetEventRequest(c.Server)
if err != nil {
@@ -1387,6 +1639,18 @@ func (c *Client) PostFileSearch(ctx context.Context, body PostFileSearchJSONRequ
return c.Client.Do(req)
}
func (c *Client) PostInstallationInfo(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewPostInstallationInfoRequest(c.Server)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
return c.Client.Do(req)
}
func (c *Client) PostPathGet(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewPostPathGetRequest(c.Server)
if err != nil {
@@ -1633,6 +1897,33 @@ func NewPostAppInitializeRequest(server string) (*http.Request, error) {
return req, nil
}
// NewPostConfigGetRequest generates requests for PostConfigGet
func NewPostConfigGetRequest(server string) (*http.Request, error) {
var err error
serverURL, err := url.Parse(server)
if err != nil {
return nil, err
}
operationPath := fmt.Sprintf("/config_get")
if operationPath[0] == '/' {
operationPath = "." + operationPath
}
queryURL, err := serverURL.Parse(operationPath)
if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", queryURL.String(), nil)
if err != nil {
return nil, err
}
return req, nil
}
// NewGetEventRequest generates requests for GetEvent
func NewGetEventRequest(server string) (*http.Request, error) {
var err error
@@ -1700,6 +1991,33 @@ func NewPostFileSearchRequestWithBody(server string, contentType string, body io
return req, nil
}
// NewPostInstallationInfoRequest generates requests for PostInstallationInfo
func NewPostInstallationInfoRequest(server string) (*http.Request, error) {
var err error
serverURL, err := url.Parse(server)
if err != nil {
return nil, err
}
operationPath := fmt.Sprintf("/installation_info")
if operationPath[0] == '/' {
operationPath = "." + operationPath
}
queryURL, err := serverURL.Parse(operationPath)
if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", queryURL.String(), nil)
if err != nil {
return nil, err
}
return req, nil
}
// NewPostPathGetRequest generates requests for PostPathGet
func NewPostPathGetRequest(server string) (*http.Request, error) {
var err error
@@ -2097,6 +2415,9 @@ type ClientWithResponsesInterface interface {
// PostAppInitializeWithResponse request
PostAppInitializeWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*PostAppInitializeResponse, error)
// PostConfigGetWithResponse request
PostConfigGetWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*PostConfigGetResponse, error)
// GetEventWithResponse request
GetEventWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetEventResponse, error)
@@ -2105,6 +2426,9 @@ type ClientWithResponsesInterface interface {
PostFileSearchWithResponse(ctx context.Context, body PostFileSearchJSONRequestBody, reqEditors ...RequestEditorFn) (*PostFileSearchResponse, error)
// PostInstallationInfoWithResponse request
PostInstallationInfoWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*PostInstallationInfoResponse, error)
// PostPathGetWithResponse request
PostPathGetWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*PostPathGetResponse, error)
@@ -2192,6 +2516,28 @@ func (r PostAppInitializeResponse) StatusCode() int {
return 0
}
type PostConfigGetResponse struct {
Body []byte
HTTPResponse *http.Response
JSON200 *ConfigInfo
}
// Status returns HTTPResponse.Status
func (r PostConfigGetResponse) Status() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Status
}
return http.StatusText(0)
}
// StatusCode returns HTTPResponse.StatusCode
func (r PostConfigGetResponse) StatusCode() int {
if r.HTTPResponse != nil {
return r.HTTPResponse.StatusCode
}
return 0
}
type GetEventResponse struct {
Body []byte
HTTPResponse *http.Response
@@ -2236,6 +2582,28 @@ func (r PostFileSearchResponse) StatusCode() int {
return 0
}
type PostInstallationInfoResponse struct {
Body []byte
HTTPResponse *http.Response
JSON200 *InstallationInfo
}
// Status returns HTTPResponse.Status
func (r PostInstallationInfoResponse) Status() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Status
}
return http.StatusText(0)
}
// StatusCode returns HTTPResponse.StatusCode
func (r PostInstallationInfoResponse) StatusCode() int {
if r.HTTPResponse != nil {
return r.HTTPResponse.StatusCode
}
return 0
}
type PostPathGetResponse struct {
Body []byte
HTTPResponse *http.Response
@@ -2483,6 +2851,15 @@ func (c *ClientWithResponses) PostAppInitializeWithResponse(ctx context.Context,
return ParsePostAppInitializeResponse(rsp)
}
// PostConfigGetWithResponse request returning *PostConfigGetResponse
func (c *ClientWithResponses) PostConfigGetWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*PostConfigGetResponse, error) {
rsp, err := c.PostConfigGet(ctx, reqEditors...)
if err != nil {
return nil, err
}
return ParsePostConfigGetResponse(rsp)
}
// GetEventWithResponse request returning *GetEventResponse
func (c *ClientWithResponses) GetEventWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetEventResponse, error) {
rsp, err := c.GetEvent(ctx, reqEditors...)
@@ -2509,6 +2886,15 @@ func (c *ClientWithResponses) PostFileSearchWithResponse(ctx context.Context, bo
return ParsePostFileSearchResponse(rsp)
}
// PostInstallationInfoWithResponse request returning *PostInstallationInfoResponse
func (c *ClientWithResponses) PostInstallationInfoWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*PostInstallationInfoResponse, error) {
rsp, err := c.PostInstallationInfo(ctx, reqEditors...)
if err != nil {
return nil, err
}
return ParsePostInstallationInfoResponse(rsp)
}
// PostPathGetWithResponse request returning *PostPathGetResponse
func (c *ClientWithResponses) PostPathGetWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*PostPathGetResponse, error) {
rsp, err := c.PostPathGet(ctx, reqEditors...)
@@ -2699,6 +3085,32 @@ func ParsePostAppInitializeResponse(rsp *http.Response) (*PostAppInitializeRespo
return response, nil
}
// ParsePostConfigGetResponse parses an HTTP response from a PostConfigGetWithResponse call
func ParsePostConfigGetResponse(rsp *http.Response) (*PostConfigGetResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
defer func() { _ = rsp.Body.Close() }()
if err != nil {
return nil, err
}
response := &PostConfigGetResponse{
Body: bodyBytes,
HTTPResponse: rsp,
}
switch {
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
var dest ConfigInfo
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON200 = &dest
}
return response, nil
}
// ParseGetEventResponse parses an HTTP response from a GetEventWithResponse call
func ParseGetEventResponse(rsp *http.Response) (*GetEventResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
@@ -2751,6 +3163,32 @@ func ParsePostFileSearchResponse(rsp *http.Response) (*PostFileSearchResponse, e
return response, nil
}
// ParsePostInstallationInfoResponse parses an HTTP response from a PostInstallationInfoWithResponse call
func ParsePostInstallationInfoResponse(rsp *http.Response) (*PostInstallationInfoResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
defer func() { _ = rsp.Body.Close() }()
if err != nil {
return nil, err
}
response := &PostInstallationInfoResponse{
Body: bodyBytes,
HTTPResponse: rsp,
}
switch {
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
var dest InstallationInfo
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON200 = &dest
}
return response, nil
}
// ParsePostPathGetResponse parses an HTTP response from a PostPathGetWithResponse call
func ParsePostPathGetResponse(rsp *http.Response) (*PostPathGetResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)

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