Compare commits

...

132 Commits

Author SHA1 Message Date
Dax Raad de15e67834 fix lsp diagnostic accurancy 2025-06-30 22:48:32 -04:00
Dax Raad fea56d8de6 fix loading api key from env for openai compatible providers 2025-06-30 19:07:51 -04:00
Max Rabin 3d71be2b45 Add pyright lsp for Python (#551)
Co-authored-by: Max Rabin <max.rabin@mobileye.com>
2025-06-30 18:17:47 -04:00
adamdottv 58baca2a5b chore: typescript error 2025-06-30 15:46:18 -05:00
adamdottv ef73926db6 chore: include model release date 2025-06-30 15:46:18 -05:00
Dax Raad 9ad1687f04 optimistically boot lsp servers 2025-06-30 16:45:26 -04:00
Jeremy Mack c573270e66 chore: remove duplicate EditTool in TOOLS array (#556) 2025-06-30 15:32:15 -04:00
Dax Raad 9ebad68274 fix bash tool extra line 2025-06-30 15:31:30 -04:00
Dax Raad 03664ba588 fix formatting of bash tools 2025-06-30 15:28:59 -04:00
adamdottv 5a107b275c fix(tui): layout issues 2025-06-30 14:04:56 -05:00
Dax Raad dd5736fe5f add back in file hierarchy in system prompt but limit to 200 items 2025-06-30 14:46:46 -04:00
adamdottv 9f3ba03965 chore: rework layout primitives 2025-06-30 12:29:29 -05:00
Timo Clasen d090c08ef0 feat: update user and agent messages width and alignment (#515)
Co-authored-by: adamdottv <2363879+adamdottv@users.noreply.github.com>
2025-06-30 11:57:56 -05:00
Dmytro Yankovskyi 68e82e4d94 fix(#467): more granular bedrock modelID based on aws region (#482) 2025-06-30 11:12:30 -04:00
Dax Raad a4aa0e6f8d docs: readme 2025-06-30 10:56:38 -04:00
GitHub Action 8c1ae2717c Update download stats 2025-06-30 2025-06-30 12:26:30 +00:00
Dax Raad 72d48759d7 add ruby formatter and lsp 2025-06-29 22:00:08 -04:00
Timo Clasen 986144b377 docs: how to disable mcp server (#543)
Co-authored-by: GitHub Action <action@github.com>
2025-06-29 21:33:30 -04:00
Dax Raad 1fdb326aa7 ignore: refactoring 2025-06-29 21:30:23 -04:00
Dax Raad 463257e7e4 add zig, python, clang, and kotlin formatters
Co-authored-by: Suhas-Koheda <Suhas-Koheda@users.noreply.github.com>
Co-authored-by: Polo123456789 <Polo123456789@users.noreply.github.com>
Co-authored-by: theodore-s-beers <theodore-s-beers@users.noreply.github.com>
Co-authored-by: TylerHillery <TylerHillery@users.noreply.github.com>
2025-06-29 21:27:35 -04:00
Dax Raad 0f41e60bd6 restructure formatters 2025-06-29 21:22:21 -04:00
Polo123456789 7df81f7b3e Formatters as plugins (#487) 2025-06-29 21:13:32 -04:00
Adam Spiers dd22cb2bb0 chore: add .editorconfig (#536)
Co-authored-by: Adam Spiers <opencode@adamspiers.org>
2025-06-29 21:12:58 -04:00
Dax Raad 248325925f fix issue with costs resetting once chat is completed 2025-06-29 19:43:03 -04:00
Dax Raad ca48a4f0fb better amazon bedrock caching with anthropic models 2025-06-29 19:27:07 -04:00
Dax 98ee5a3d87 Update STATS.md 2025-06-29 13:04:44 -04:00
GitHub Action 67480e5a1c Update download stats 2025-06-29 2025-06-29 12:23:40 +00:00
GitHub Action 2581a9b54c Update download stats 2025-06-29 2025-06-29 02:00:18 +00:00
Dax Raad 14a293e124 ci: stats 2025-06-28 21:59:14 -04:00
Dax Raad 780419ecae ci: daily stats script 2025-06-28 21:57:46 -04:00
Timo Clasen f0962e2d9c Add Option to Disable MCP Servers (#513) 2025-06-28 21:05:31 -04:00
Dax Raad 3a9584a419 fix context display 2025-06-28 21:01:53 -04:00
adamdottv 196f42cbff fix(tui): share command and error messages 2025-06-28 17:51:28 -05:00
Dax Raad 322385f6b1 patch for scroll dumping characters into input buffer 2025-06-28 11:56:47 -04:00
Dax Raad b7446cd7b9 ci: fix 2025-06-28 09:16:29 -04:00
Gal Schlezinger f618e569ab optimize edit-tool rendering (#463)
Co-authored-by: opencode <noreply@opencode.ai>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2025-06-28 06:01:10 -05:00
Jay V 7b394b91e2 docs: share handle slower code blocks 2025-06-27 20:21:28 -04:00
Jay V 6a7983a4ea docs: adding more share images 2025-06-27 20:03:17 -04:00
Jay V 737146fca1 docs: tweak logo 2025-06-27 19:18:54 -04:00
Jay V 688f3fd12f Merge branch 'jeremyosih-feat/scroll-to-bottom-button' into dev 2025-06-27 19:16:46 -04:00
Jay V 145df08444 docs: share page format 2025-06-27 19:16:33 -04:00
Dax Raad 8b400515ea smooth out initial onboarding flow 2025-06-27 19:10:42 -04:00
Jay V 289797f56d docs: share cleanup title 2025-06-27 19:10:42 -04:00
adamdottv be0811ecc3 chore: rework openapi spec and use stainless sdk 2025-06-27 19:10:42 -04:00
Dax Raad 0676bcd4fd temporary patch for input lag on initial run 2025-06-27 19:10:42 -04:00
Polo123456789 d076def561 feat: Add golang file formatting (#474) 2025-06-27 19:10:42 -04:00
Wendell Misiedjan e0807d7317 fix: bunproc stdout / stderr parsing, error handling for bun ResolveMessage (#468) 2025-06-27 19:10:42 -04:00
Jay V fa2723f2d0 docs: update logo screenshot 2025-06-27 19:10:42 -04:00
Jay V 87d62514db docs: share page write tool bug 2025-06-27 19:10:42 -04:00
Dax Raad 2f8cf9146b ci: ignore 2025-06-27 19:10:42 -04:00
Dax Raad 8e0ec6b037 ci: aur 2025-06-27 19:10:42 -04:00
Dax Raad 6dc434cb83 ignore: cleanup 2025-06-27 19:10:42 -04:00
Dax Raad d972c27f03 lazy load formatters 2025-06-27 19:10:42 -04:00
Ryan Winchester 9e2bb63688 feat: add elixir file formatting (#458) 2025-06-27 19:10:42 -04:00
adamdottv 49053b66a9 fix(web): remove system prompts from share page 2025-06-27 19:10:42 -04:00
TheGoddessInari 47497aef07 scripts/hooks: Change shebang to universal /bin/sh (#453) 2025-06-27 19:10:41 -04:00
adamdottv 8455029de1 fix(tui): min width on user messages 2025-06-27 19:10:41 -04:00
Dax Raad 9f07f89384 fix formatting output going into tui 2025-06-27 19:10:41 -04:00
adamdottv d840d43e8f ignore: more metadata in app info 2025-06-27 19:10:41 -04:00
adamdottv 9ead2f3dfb fix: don't use prettier for langs it doesn't format 2025-06-27 19:10:41 -04:00
Dax Raad f3742ddbb8 ignore: run prettier 2025-06-27 19:10:41 -04:00
Dax Raad b61a841aa8 add auto formatting and experimental hooks feature 2025-06-27 19:10:41 -04:00
Jay V ebcf11e574 docs: lander tweak 2025-06-27 19:10:41 -04:00
Jay V 065f0aaddf docs: tweak lander 2025-06-27 19:10:41 -04:00
Dax Raad c0773dc7c5 smooth out initial onboarding flow 2025-06-27 16:09:59 -04:00
Jay V 1c3c74bd36 docs: share cleanup title 2025-06-27 15:31:21 -04:00
adamdottv 79bbf90b72 chore: rework openapi spec and use stainless sdk 2025-06-27 14:26:25 -05:00
Dax Raad 226a4a7f36 temporary patch for input lag on initial run 2025-06-27 14:36:03 -04:00
Polo123456789 df3b424830 feat: Add golang file formatting (#474) 2025-06-27 14:11:09 -04:00
Wendell Misiedjan 3cfd9d80bc fix: bunproc stdout / stderr parsing, error handling for bun ResolveMessage (#468) 2025-06-27 14:09:35 -04:00
Jay V e0553b8d2c docs: update logo screenshot 2025-06-27 14:04:09 -04:00
Jay V 391c837b37 docs: share page write tool bug 2025-06-27 13:25:15 -04:00
Dax Raad 5773d9d1a3 ci: ignore 2025-06-27 12:37:57 -04:00
Dax Raad ce611963c3 ci: aur 2025-06-27 12:29:13 -04:00
Dax Raad f865cacfb8 ignore: cleanup 2025-06-27 11:35:57 -04:00
Dax Raad 2ec0611f42 lazy load formatters 2025-06-27 11:33:37 -04:00
Ryan Winchester 334161a30e feat: add elixir file formatting (#458) 2025-06-27 10:15:11 -04:00
adamdottv dbb6e55226 fix(web): remove system prompts from share page 2025-06-27 06:48:44 -05:00
TheGoddessInari d0f9260559 scripts/hooks: Change shebang to universal /bin/sh (#453) 2025-06-27 07:40:22 -04:00
adamdottv d2176064e1 fix(tui): min width on user messages 2025-06-27 06:31:13 -05:00
Dax Raad ed8d277e49 fix formatting output going into tui 2025-06-27 07:29:41 -04:00
adamdottv 59b3268c64 ignore: more metadata in app info 2025-06-27 06:19:27 -05:00
adamdottv d043f67761 fix: don't use prettier for langs it doesn't format 2025-06-27 05:47:14 -05:00
Dax Raad 51bf193889 ignore: run prettier 2025-06-26 22:30:44 -04:00
Dax Raad f8b78f08b4 add auto formatting and experimental hooks feature 2025-06-26 22:17:08 -04:00
Jay V a4f32d602b docs: lander tweak 2025-06-26 19:47:58 -04:00
Jay V dc3dd21cf3 docs: tweak lander 2025-06-26 19:02:44 -04:00
Jeremy Osih b4c2fcccf5 Merge branch 'sst:dev' into feat/scroll-to-bottom-button 2025-06-27 00:41:20 +02:00
Jeremy Osih e950ad5306 feat(web): add scroll to last message button
Add intelligent floating scroll button for long conversations that:
- Only appears when scrolling down (direction-aware)
- Auto-hides after 3 seconds of inactivity
- Stays visible on hover to prevent accidental disappearance
- Uses consistent design patterns with repo styling
- Includes proper accessibility features

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

Co-Authored-By: Jeremy Osih <osih.jeremy@gmail.com>
Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-27 00:38:14 +02:00
Dax Raad 8ca713b737 disable task tool temporarily 2025-06-26 18:27:49 -04:00
Jay V 5b54554fd5 docs: edit theme doc 2025-06-26 17:56:31 -04:00
Dax Raad 4bc651f958 fix: improve JSON formatting and add piped output support for run command
🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-26 17:32:00 -04:00
Jay V 3b6976a9c8 Merge branch 'rekram1-node-chore/update-config-docs' into dev 2025-06-26 17:24:03 -04:00
Jay V 863d5c1e8e docs: editing rules 2025-06-26 17:23:52 -04:00
adamdottv 97e19e9677 fix(tui): editor styles were off 2025-06-26 17:22:21 -04:00
adamdottv b27851461f feat(tui): more themes 2025-06-26 17:22:21 -04:00
adamdottv 209687377a feat(tui): more themes 2025-06-26 17:22:21 -04:00
adamdottv 90face1c09 fix(tui): editor width issues 2025-06-26 17:22:21 -04:00
adamdottv 936e2ce48b feat(tui): show lsp diagnostics for edit and write tools 2025-06-26 17:22:21 -04:00
adamdottv 16ee8ee379 fix(tui): chat editor aesthetics 2025-06-26 17:22:21 -04:00
adamdottv ac39308dad fix(tui): visual issue with modal selected items in system theme 2025-06-26 17:22:21 -04:00
adamdottv 346b49219d chore: tui agents.md 2025-06-26 17:22:21 -04:00
Jay V d84c1f20c7 docs: social share 2025-06-26 17:22:17 -04:00
adamdottv dfb8777555 fix(tui): editor spinner colors 2025-06-26 17:21:53 -04:00
Jay V 008af18156 docs: share page responsive diff 2025-06-26 17:21:53 -04:00
adamdottv ab23167f80 docs: system theme 2025-06-26 17:21:53 -04:00
adamdottv b17ec46463 fix(tui): make opencode theme default 2025-06-26 17:21:53 -04:00
Adam 2e26b58d16 feat: default system theme (#419)
Co-authored-by: adamdottv <2363879+adamdottv@users.noreply.github.com>
2025-06-26 17:21:53 -04:00
Mike Wallio 31b56e5a05 Fix undefined is not an object (evaluating 'G.title') (#395) 2025-06-26 17:21:53 -04:00
Juhani Pelli 47c401cf25 fix: guard against large output limit causing infinite summarize loop (#399) 2025-06-26 17:21:53 -04:00
Dax Raad fab8dc9e6f more edit tool fixes 2025-06-26 17:21:53 -04:00
Dax Raad f39a2b1f16 integrate gemini-cli strategies for edit tool 2025-06-26 17:21:53 -04:00
Dax Raad 66830ced4e make edit tool more robust 2025-06-26 17:21:53 -04:00
Dax Raad 9d3fad754d ignore: typo 2025-06-26 17:21:53 -04:00
Dax Raad dcd3131f58 add output length errors 2025-06-26 17:21:53 -04:00
Dax Raad 3d02e07161 fix codex not working 2025-06-26 17:21:53 -04:00
Dax Raad 4dbc6a43a6 redirect uncaught errors to log file 2025-06-26 17:21:53 -04:00
adamdottv 5394b5188b fix(tui): editor styles were off 2025-06-26 15:12:26 -05:00
adamdottv 8e680b3957 feat(tui): more themes 2025-06-26 15:03:30 -05:00
adamdottv 1b8cd796d6 feat(tui): more themes 2025-06-26 14:54:32 -05:00
adamdottv 35fba793d0 fix(tui): editor width issues 2025-06-26 12:57:11 -05:00
adamdottv 5358d43b74 feat(tui): show lsp diagnostics for edit and write tools 2025-06-26 12:47:17 -05:00
adamdottv f777347bac fix(tui): chat editor aesthetics 2025-06-26 12:44:44 -05:00
adamdottv 17c8b914df fix(tui): visual issue with modal selected items in system theme 2025-06-26 12:33:06 -05:00
adamdottv 43b467dd12 chore: tui agents.md 2025-06-26 12:28:29 -05:00
Jay V 0e0770921e docs: social share 2025-06-26 13:21:42 -04:00
adamdottv 8edbb74352 fix(tui): editor spinner colors 2025-06-26 12:21:20 -05:00
Jay V e6bfa95758 docs: share page responsive diff 2025-06-26 13:06:41 -04:00
adamdottv e4120b6287 docs: system theme 2025-06-26 11:33:02 -05:00
adamdottv ccbc9e00f2 fix(tui): make opencode theme default 2025-06-26 11:32:25 -05:00
Adam 7d13baadc8 feat: default system theme (#419)
Co-authored-by: adamdottv <2363879+adamdottv@users.noreply.github.com>
2025-06-26 10:16:07 -05:00
rekram1-node 9acc83697f chore: document AGENTS.md 2025-06-26 08:28:06 -05:00
148 changed files with 7694 additions and 9104 deletions
+9
View File
@@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
insert_final_newline = true
end_of_line = lf
indent_style = space
indent_size = 2
max_line_length = 80
+32
View File
@@ -0,0 +1,32 @@
name: stats
on:
schedule:
- cron: "0 12 * * *" # Run daily at 12:00 UTC
workflow_dispatch: # Allow manual trigger
jobs:
stats:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Run stats script
run: bun scripts/stats.ts
- name: Commit stats
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add STATS.md
git diff --staged --quiet || git commit -m "Update download stats $(date -I)"
git push
+8 -16
View File
@@ -1,9 +1,9 @@
<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">
<source srcset="packages/web/src/assets/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
<source srcset="packages/web/src/assets/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
<img src="packages/web/src/assets/logo-ornate-light.svg" alt="opencode logo">
</picture>
</a>
</p>
@@ -14,7 +14,7 @@
<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>
[![opencode Terminal UI](packages/web/src/assets/themes/opencode.png)](https://opencode.ai)
[![opencode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
---
@@ -40,6 +40,9 @@ For more info on how to configure opencode [**head over to our docs**](https://o
For any new features we'd appreciate it if you could open an issue first to discuss what you'd like to implement. We're pretty responsive there and it'll save you from working on something that we don't end up using. No need to do this for simpler fixes.
> **Note**: Please talk to us via github issues before spending time working on
> a new feature
To run opencode locally you need.
- Bun
@@ -54,14 +57,7 @@ $ bun run packages/opencode/src/index.ts
#### Development Notes
**API Client Generation**: After making changes to the TypeScript API endpoints in `packages/opencode/src/server/server.ts`, you need to regenerate the Go client and OpenAPI specification:
```bash
$ cd packages/tui
$ go generate ./pkg/client/
```
This updates the generated Go client code that the TUI uses to communicate with the backend server.
**API Client**: After making changes to the TypeScript API endpoints in `packages/opencode/src/server/server.ts`, you will need the opencode team to generate a new stainless sdk for the clients.
### FAQ
@@ -74,10 +70,6 @@ It's very similar to Claude Code in terms of capability. Here are the key differ
- 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).
+6
View File
@@ -0,0 +1,6 @@
# Download Stats
| Date | GitHub Downloads | npm Downloads | Total |
| ---------- | ---------------- | --------------- | --------------- |
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
+1 -1
View File
@@ -19,7 +19,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "0.0.0",
"version": "0.0.5",
"bin": {
"opencode": "./bin/opencode",
},
+17 -1
View File
@@ -1,3 +1,19 @@
{
"$schema": "https://opencode.ai/config.json"
"$schema": "https://opencode.ai/config.json",
"experimental": {
"hook": {
"file_edited": {
".json": [
{
"command": ["bun", "run", "prettier", "$FILE"]
}
]
},
"session_completed": [
{
"command": ["touch", "./node_modules/foo"]
}
]
}
}
}
+8 -8
View File
@@ -6,20 +6,20 @@
import "sst"
declare module "sst" {
export interface Resource {
"Web": {
"type": "sst.cloudflare.Astro"
"url": string
Web: {
type: "sst.cloudflare.Astro"
url: string
}
}
}
// cloudflare
import * as cloudflare from "@cloudflare/workers-types";
// cloudflare
import * as cloudflare from "@cloudflare/workers-types"
declare module "sst" {
export interface Resource {
"Api": cloudflare.Service
"Bucket": cloudflare.R2Bucket
Api: cloudflare.Service
Bucket: cloudflare.R2Bucket
}
}
import "sst"
export {}
export {}
+1 -2
View File
@@ -7,7 +7,6 @@
- **Typecheck**: `bun run typecheck` (npm run typecheck)
- **Test**: `bun test` (runs all tests)
- **Single test**: `bun test test/tool/tool.test.ts` (specific test file)
- **API Client Generation**: `cd packages/tui && go generate ./pkg/client/` (after changes to server endpoints)
## Code Style
@@ -38,4 +37,4 @@
- **Validation**: All inputs validated with Zod schemas
- **Logging**: Use `Log.create({ service: "name" })` pattern
- **Storage**: Use `Storage` namespace for persistence
- **API Client**: Go TUI communicates with TypeScript server via generated client. When adding/modifying server endpoints in `packages/opencode/src/server/server.ts`, run `cd packages/tui && go generate ./pkg/client/` to update the Go client code and OpenAPI spec.
- **API Client**: Go TUI communicates with TypeScript server via stainless SDK. When adding/modifying server endpoints in `packages/opencode/src/server/server.ts`, ask the user to generate a new client SDK to proceed with client-side changes.
+80 -20
View File
@@ -183,6 +183,9 @@
"temperature": {
"type": "boolean"
},
"tool_call": {
"type": "boolean"
},
"cost": {
"type": "object",
"properties": {
@@ -199,10 +202,7 @@
"type": "number"
}
},
"required": [
"input",
"output"
],
"required": ["input", "output"],
"additionalProperties": false
},
"limit": {
@@ -215,14 +215,15 @@
"type": "number"
}
},
"required": [
"context",
"output"
],
"required": ["context", "output"],
"additionalProperties": false
},
"id": {
"type": "string"
},
"options": {
"type": "object",
"additionalProperties": {}
}
},
"additionalProperties": false
@@ -233,9 +234,7 @@
"additionalProperties": {}
}
},
"required": [
"models"
],
"required": ["models"],
"additionalProperties": false
},
"description": "Custom provider configurations and model overrides"
@@ -265,12 +264,13 @@
"type": "string"
},
"description": "Environment variables to set when running the MCP server"
},
"enabled": {
"type": "boolean",
"description": "Enable or disable the MCP server on startup"
}
},
"required": [
"type",
"command"
],
"required": ["type", "command"],
"additionalProperties": false
},
{
@@ -284,19 +284,79 @@
"url": {
"type": "string",
"description": "URL of the remote MCP server"
},
"enabled": {
"type": "boolean",
"description": "Enable or disable the MCP server on startup"
}
},
"required": [
"type",
"url"
],
"required": ["type", "url"],
"additionalProperties": false
}
]
},
"description": "MCP (Model Context Protocol) server configurations"
},
"experimental": {
"type": "object",
"properties": {
"hook": {
"type": "object",
"properties": {
"file_edited": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "object",
"properties": {
"command": {
"type": "array",
"items": {
"type": "string"
}
},
"environment": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"required": ["command"],
"additionalProperties": false
}
}
},
"session_completed": {
"type": "array",
"items": {
"type": "object",
"properties": {
"command": {
"type": "array",
"items": {
"type": "string"
}
},
"environment": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"required": ["command"],
"additionalProperties": false
}
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
}
+2 -1
View File
@@ -1,6 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "0.0.5", "name": "opencode",
"version": "0.0.5",
"name": "opencode",
"type": "module",
"private": true,
"scripts": {
+12 -9
View File
@@ -142,7 +142,7 @@ if (!snapshot) {
"# Maintainer: dax",
"# Maintainer: adam",
"",
"pkgname='opencode-bin'",
"pkgname='${pkg}'",
`pkgver=${version.split("-")[0]}`,
"options=('!debug' '!strip')",
"pkgrel=1",
@@ -166,14 +166,17 @@ if (!snapshot) {
"",
].join("\n")
await $`rm -rf ./dist/aur-opencode-bin`
await $`git clone ssh://aur@aur.archlinux.org/opencode-bin.git ./dist/aur-opencode-bin`
await Bun.file("./dist/aur-opencode-bin/PKGBUILD").write(pkgbuild)
await $`cd ./dist/aur-opencode-bin && makepkg --printsrcinfo > .SRCINFO`
await $`cd ./dist/aur-opencode-bin && git add PKGBUILD .SRCINFO`
await $`cd ./dist/aur-opencode-bin && git commit -m "Update to v${version}"`
if (!dry) await $`cd ./dist/aur-opencode-bin && git push`
for (const pkg of ["opencode", "opencode-bin"]) {
await $`rm -rf ./dist/aur-${pkg}`
await $`git clone ssh://aur@aur.archlinux.org/${pkg}.git ./dist/aur-${pkg}`
await Bun.file(`./dist/aur-${pkg}/PKGBUILD`).write(
pkgbuild.replace("${pkg}", pkg),
)
await $`cd ./dist/aur-${pkg} && makepkg --printsrcinfo > .SRCINFO`
await $`cd ./dist/aur-${pkg} && git add PKGBUILD .SRCINFO`
await $`cd ./dist/aur-${pkg} && git commit -m "Update to v${version}"`
if (!dry) await $`cd ./dist/aur-${pkg} && git push`
}
// Homebrew formula
const homebrewFormula = [
+31 -23
View File
@@ -13,6 +13,7 @@ export namespace App {
export const Info = z
.object({
user: z.string(),
hostname: z.string(),
git: z.boolean(),
path: z.object({
config: z.string(),
@@ -26,15 +27,25 @@ export namespace App {
}),
})
.openapi({
ref: "App.Info",
ref: "App",
})
export type Info = z.infer<typeof Info>
const ctx = Context.create<Awaited<ReturnType<typeof create>>>("app")
const ctx = Context.create<{
info: Info
services: Map<any, { state: any; shutdown?: (input: any) => Promise<void> }>
}>("app")
const APP_JSON = "app.json"
async function create(input: { cwd: string }) {
export type Input = {
cwd: string
}
export async function provide<T>(
input: Input,
cb: (app: App.Info) => Promise<T>,
) {
log.info("creating", {
cwd: input.cwd,
})
@@ -62,8 +73,11 @@ export namespace App {
}
>()
const root = git ?? input.cwd
const info: Info = {
user: os.userInfo().username,
hostname: os.hostname(),
time: {
initialized: state.initialized,
},
@@ -72,16 +86,27 @@ export namespace App {
config: Global.Path.config,
state: Global.Path.state,
data,
root: git ?? input.cwd,
root,
cwd: input.cwd,
},
}
const result = {
const app = {
services,
info,
}
return result
return ctx.provide(app, async () => {
try {
const result = await cb(app.info)
return result
} finally {
for (const [key, entry] of app.services.entries()) {
if (!entry.shutdown) continue
log.info("shutdown", { name: key })
await entry.shutdown?.(await entry.state)
}
}
})
}
export function state<State>(
@@ -107,22 +132,6 @@ export namespace App {
return ctx.use().info
}
export async function provide<T>(
input: { cwd: string },
cb: (app: Info) => Promise<T>,
) {
const app = await create(input)
return ctx.provide(app, async () => {
const result = await cb(app.info)
for (const [key, entry] of app.services.entries()) {
if (!entry.shutdown) continue
log.info("shutdown", { name: key })
await entry.shutdown?.(await entry.state)
}
return result
})
}
export async function initialize() {
const { info } = ctx.use()
info.time.initialized = Date.now()
@@ -142,4 +151,3 @@ export namespace App {
.replace(/[^A-Za-z0-9_]/g, "-")
}
}
+5 -6
View File
@@ -3,6 +3,7 @@ import { Global } from "../global"
import { Log } from "../util/log"
import path from "path"
import { NamedError } from "../util/error"
import { readableStreamToText } from "bun"
export namespace BunProc {
const log = Log.create({ service: "bun" })
@@ -25,11 +26,9 @@ export namespace BunProc {
BUN_BE_BUN: "1",
},
})
const code = await result.exited
// @ts-ignore
const stdout = await result.stdout.text()
// @ts-ignore
const stderr = await result.stderr.text()
const code = await result.exited;
const stdout = result.stdout ? typeof result.stdout === "number" ? result.stdout : await readableStreamToText(result.stdout) : undefined
const stderr = result.stderr ? typeof result.stderr === "number" ? result.stderr : await readableStreamToText(result.stderr) : undefined
log.info("done", {
code,
stdout,
@@ -65,7 +64,7 @@ export namespace BunProc {
await BunProc.run(["install", "--registry=https://registry.npmjs.org"], {
cwd: Global.Path.cache,
}).catch((e) => {
new InstallFailedError(
throw new InstallFailedError(
{ pkg, version },
{
cause: e,
+4 -2
View File
@@ -49,7 +49,7 @@ export namespace Bus {
)
}
export function publish<Definition extends EventDefinition>(
export async function publish<Definition extends EventDefinition>(
def: Definition,
properties: z.output<Definition["properties"]>,
) {
@@ -60,12 +60,14 @@ export namespace Bus {
log.info("publishing", {
type: def.type,
})
const pending = []
for (const key of [def.type, "*"]) {
const match = state().subscriptions.get(key)
for (const sub of match ?? []) {
sub(payload)
pending.push(sub(payload))
}
}
return Promise.all(pending)
}
export function subscribe<Definition extends EventDefinition>(
+19
View File
@@ -0,0 +1,19 @@
import { App } from "../app/app"
import { ConfigHooks } from "../config/hooks"
import { Format } from "../format"
import { LSP } from "../lsp"
import { Share } from "../share/share"
export async function bootstrap<T>(
input: App.Input,
cb: (app: App.Info) => Promise<T>,
) {
return App.provide(input, async (app) => {
Share.init()
Format.init()
ConfigHooks.init()
LSP.init()
return cb(app)
})
}
+8 -8
View File
@@ -31,7 +31,7 @@ export const AuthListCommand = cmd({
UI.empty()
const authPath = path.join(Global.Path.data, "auth.json")
const homedir = os.homedir()
const displayPath = authPath.startsWith(homedir)
const displayPath = authPath.startsWith(homedir)
? authPath.replace(homedir, "~")
: authPath
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
@@ -46,14 +46,14 @@ export const AuthListCommand = cmd({
prompts.outro(`${results.length} credentials`)
// Environment variables section
const activeEnvVars: Array<{ provider: string, envVar: string }> = []
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
for (const [providerID, provider] of Object.entries(database)) {
for (const envVar of provider.env) {
if (process.env[envVar]) {
activeEnvVars.push({
provider: provider.name || providerID,
envVar
activeEnvVars.push({
provider: provider.name || providerID,
envVar,
})
}
}
@@ -62,11 +62,11 @@ export const AuthListCommand = cmd({
if (activeEnvVars.length > 0) {
UI.empty()
prompts.intro("Environment")
for (const { provider, envVar } of activeEnvVars) {
prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
}
prompts.outro(`${activeEnvVars.length} environment variables`)
}
},
+146
View File
@@ -0,0 +1,146 @@
import { App } from "../../app/app"
import { Ripgrep } from "../../file/ripgrep"
import { File } from "../../file"
import { LSP } from "../../lsp"
import { Log } from "../../util/log"
import { bootstrap } from "../bootstrap"
import { cmd } from "./cmd"
import path from "path"
export const DebugCommand = cmd({
command: "debug",
builder: (yargs) =>
yargs
.command(DiagnosticsCommand)
.command(RipgrepCommand)
.command(SymbolsCommand)
.command(FileReadCommand)
.demandCommand(),
async handler() {},
})
const DiagnosticsCommand = cmd({
command: "diagnostics <file>",
builder: (yargs) =>
yargs.positional("file", { type: "string", demandOption: true }),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
await LSP.touchFile(args.file, true)
await LSP.touchFile(args.file, true)
console.log(await LSP.diagnostics())
})
},
})
const SymbolsCommand = cmd({
command: "symbols <query>",
builder: (yargs) =>
yargs.positional("query", { type: "string", demandOption: true }),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
await LSP.touchFile("./src/index.ts", true)
using _ = Log.Default.time("symbols")
const results = await LSP.workspaceSymbol(args.query)
console.log(JSON.stringify(results, null, 2))
})
},
})
const RipgrepCommand = cmd({
command: "rg",
builder: (yargs) =>
yargs
.command(TreeCommand)
.command(FilesCommand)
.command(SearchCommand)
.demandCommand(),
async handler() {},
})
const TreeCommand = cmd({
command: "tree",
builder: (yargs) =>
yargs.option("limit", {
type: "number",
}),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
const app = App.info()
console.log(await Ripgrep.tree({ cwd: app.path.cwd, limit: args.limit }))
})
},
})
const FilesCommand = cmd({
command: "files",
builder: (yargs) =>
yargs
.option("query", {
type: "string",
description: "Filter files by query",
})
.option("glob", {
type: "string",
description: "Glob pattern to match files",
})
.option("limit", {
type: "number",
description: "Limit number of results",
}),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
const app = App.info()
const files = await Ripgrep.files({
cwd: app.path.cwd,
query: args.query,
glob: args.glob,
limit: args.limit,
})
console.log(files.join("\n"))
})
},
})
const SearchCommand = cmd({
command: "search <pattern>",
builder: (yargs) =>
yargs
.positional("pattern", {
type: "string",
demandOption: true,
description: "Search pattern",
})
.option("glob", {
type: "array",
description: "File glob patterns",
})
.option("limit", {
type: "number",
description: "Limit number of results",
}),
async handler(args) {
const results = await Ripgrep.search({
cwd: process.cwd(),
pattern: args.pattern,
glob: args.glob as string[] | undefined,
limit: args.limit,
})
console.log(JSON.stringify(results, null, 2))
},
})
const FileReadCommand = cmd({
command: "file-read <path>",
builder: (yargs) =>
yargs.positional("path", {
type: "string",
demandOption: true,
description: "File path to read",
}),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
const content = await File.read(path.resolve(args.path))
console.log(content)
})
},
})
+2 -2
View File
@@ -8,7 +8,7 @@ export const ModelsCommand = cmd({
handler: async () => {
await App.provide({ cwd: process.cwd() }, async () => {
const providers = await Provider.list()
for (const [providerID, provider] of Object.entries(providers)) {
for (const modelID of Object.keys(provider.info.models)) {
console.log(`${providerID}/${modelID}`)
@@ -16,4 +16,4 @@ export const ModelsCommand = cmd({
}
})
},
})
})
+94 -96
View File
@@ -1,14 +1,13 @@
import type { Argv } from "yargs"
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 { UI } from "../ui"
import { cmd } from "./cmd"
import { Flag } from "../../flag/flag"
import { Config } from "../../config/config"
import { bootstrap } from "../bootstrap"
const TOOL: Record<string, [string, string]> = {
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
@@ -56,110 +55,109 @@ export const RunCommand = cmd({
},
handler: async (args) => {
const message = args.message.join(" ")
await App.provide(
{
cwd: process.cwd(),
},
async () => {
await Share.init()
const session = await (async () => {
if (args.continue) {
const first = await Session.list().next()
if (first.done) return
return first.value
}
if (args.session) return Session.get(args.session)
return Session.create()
})()
if (!session) {
UI.error("Session not found")
return
await bootstrap({ cwd: process.cwd() }, async () => {
const session = await (async () => {
if (args.continue) {
const first = await Session.list().next()
if (first.done) return
return first.value
}
UI.empty()
UI.println(UI.logo())
UI.empty()
UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message)
UI.empty()
if (args.session) return Session.get(args.session)
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 +
return Session.create()
})()
if (!session) {
UI.error("Session not found")
return
}
const isPiped = !process.stdout.isTTY
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://opencode.ai/s/" +
session.id.slice(-8),
)
}
UI.empty()
const { providerID, modelID } = args.model
? Provider.parseModel(args.model)
: await Provider.defaultModel()
UI.println(
UI.Style.TEXT_NORMAL_BOLD + "@ ",
UI.Style.TEXT_NORMAL + `${providerID}/${modelID}`,
)
UI.empty()
}
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,
)
const { providerID, modelID } = args.model
? Provider.parseModel(args.model)
: await Provider.defaultModel()
UI.println(
UI.Style.TEXT_NORMAL_BOLD + "@ ",
UI.Style.TEXT_NORMAL + `${providerID}/${modelID}`,
)
UI.empty()
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 (evt) => {
if (evt.properties.sessionID !== session.id) return
const part = evt.properties.part
const message = await Session.getMessage(
evt.properties.sessionID,
evt.properties.messageID,
)
if (
part.type === "tool-invocation" &&
part.toolInvocation.state === "result"
) {
const metadata = message.metadata.tool[part.toolInvocation.toolCallId]
const [tool, color] = TOOL[part.toolInvocation.toolName] ?? [
part.toolInvocation.toolName,
UI.Style.TEXT_INFO_BOLD,
]
printEvent(color, tool, metadata?.title || "Unknown")
}
Bus.subscribe(Message.Event.PartUpdated, async (evt) => {
if (evt.properties.sessionID !== session.id) return
const part = evt.properties.part
const message = await Session.getMessage(
evt.properties.sessionID,
evt.properties.messageID,
)
if (
part.type === "tool-invocation" &&
part.toolInvocation.state === "result"
) {
const metadata =
message.metadata.tool[part.toolInvocation.toolCallId]
const [tool, color] = TOOL[part.toolInvocation.toolName] ?? [
part.toolInvocation.toolName,
UI.Style.TEXT_INFO_BOLD,
]
printEvent(color, tool, metadata?.title || 'Unknown')
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)
}
})
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)
}
})
await Session.chat({
sessionID: session.id,
providerID,
modelID,
parts: [
{
type: "text",
text: message,
},
],
})
UI.empty()
},
)
const result = await Session.chat({
sessionID: session.id,
providerID,
modelID,
parts: [
{
type: "text",
text: message,
},
],
})
if (isPiped) {
const match = result.parts.findLast((x) => x.type === "text")
if (match) process.stdout.write(match.text)
}
UI.empty()
})
},
})
-18
View File
@@ -1,18 +0,0 @@
import { App } from "../../app/app"
import { LSP } from "../../lsp"
import { cmd } from "./cmd"
export const ScrapCommand = cmd({
command: "scrap <file>",
builder: (yargs) =>
yargs.positional("file", { type: "string", demandOption: true }),
async handler(args) {
await App.provide(
{ cwd: process.cwd() },
async () => {
await LSP.touchFile(args.file, true)
console.log(await LSP.diagnostics())
},
)
},
})
+114
View File
@@ -0,0 +1,114 @@
import { Global } from "../../global"
import { Provider } from "../../provider/provider"
import { Server } from "../../server/server"
import { bootstrap } from "../bootstrap"
import { UI } from "../ui"
import { cmd } from "./cmd"
import path from "path"
import fs from "fs/promises"
import { Installation } from "../../installation"
import { Config } from "../../config/config"
import { Bus } from "../../bus"
export const TuiCommand = cmd({
command: "$0 [project]",
describe: "start opencode tui",
builder: (yargs) =>
yargs.positional("project", {
type: "string",
describe: "path to start opencode in",
}),
handler: async (args) => {
while (true) {
const cwd = args.project ? path.resolve(args.project) : process.cwd()
try {
process.chdir(cwd)
} catch (e) {
UI.error("Failed to change directory to " + cwd)
return
}
const result = await bootstrap({ cwd }, async (app) => {
const providers = await Provider.list()
if (Object.keys(providers).length === 0) {
return "needs_provider"
}
const server = Server.listen({
port: 0,
hostname: "127.0.0.1",
})
let cmd = ["go", "run", "./main.go"]
let cwd = Bun.fileURLToPath(
new URL("../../../../tui/cmd/opencode", import.meta.url),
)
if (Bun.embeddedFiles.length > 0) {
const blob = Bun.embeddedFiles[0] as File
let binaryName = blob.name
if (process.platform === "win32" && !binaryName.endsWith(".exe")) {
binaryName += ".exe"
}
const binary = path.join(Global.Path.cache, "tui", binaryName)
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()
},
})
;(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().catch(() => {})
if (!latest) return
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(() => {})
})()
await proc.exited
server.stop()
return "done"
})
if (result === "done") break
if (result === "needs_provider") {
UI.empty()
UI.println(UI.logo(" "))
const result = await Bun.spawn({
cmd: [process.execPath, "auth", "login"],
cwd: process.cwd(),
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
}).exited
if (result !== 0) return
UI.empty()
}
}
},
})
+39 -4
View File
@@ -22,6 +22,7 @@ export namespace Config {
}
}
log.info("loaded", result)
return result
})
@@ -36,20 +37,28 @@ export namespace Config {
.record(z.string(), z.string())
.optional()
.describe("Environment variables to set when running the MCP server"),
enabled: z
.boolean()
.optional()
.describe("Enable or disable the MCP server on startup"),
})
.strict()
.openapi({
ref: "Config.McpLocal",
ref: "McpLocalConfig",
})
export const McpRemote = z
.object({
type: z.literal("remote").describe("Type of MCP server connection"),
url: z.string().describe("URL of the remote MCP server"),
enabled: z
.boolean()
.optional()
.describe("Enable or disable the MCP server on startup"),
})
.strict()
.openapi({
ref: "Config.McpRemote",
ref: "McpRemoteConfig",
})
export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
@@ -123,7 +132,7 @@ export namespace Config {
})
.strict()
.openapi({
ref: "Config.Keybinds",
ref: "KeybindsConfig",
})
export const Info = z
.object({
@@ -167,10 +176,36 @@ export namespace Config {
.record(z.string(), Mcp)
.optional()
.describe("MCP (Model Context Protocol) server configurations"),
experimental: z
.object({
hook: z
.object({
file_edited: z
.record(
z.string(),
z
.object({
command: z.string().array(),
environment: z.record(z.string(), z.string()).optional(),
})
.array(),
)
.optional(),
session_completed: z
.object({
command: z.string().array(),
environment: z.record(z.string(), z.string()).optional(),
})
.array()
.optional(),
})
.optional(),
})
.optional(),
})
.strict()
.openapi({
ref: "Config.Info",
ref: "Config",
})
export type Info = z.output<typeof Info>
+54
View File
@@ -0,0 +1,54 @@
import { App } from "../app/app"
import { Bus } from "../bus"
import { File } from "../file"
import { Session } from "../session"
import { Log } from "../util/log"
import { Config } from "./config"
import path from "path"
export namespace ConfigHooks {
const log = Log.create({ service: "config.hooks" })
export function init() {
log.info("init")
const app = App.info()
Bus.subscribe(File.Event.Edited, async (payload) => {
const cfg = await Config.get()
const ext = path.extname(payload.properties.file)
for (const item of cfg.experimental?.hook?.file_edited?.[ext] ?? []) {
log.info("file_edited", {
file: payload.properties.file,
command: item.command,
})
Bun.spawn({
cmd: item.command.map((x) =>
x.replace("$FILE", payload.properties.file),
),
env: item.environment,
cwd: app.path.cwd,
stdout: "ignore",
stderr: "ignore",
})
}
})
Bus.subscribe(Session.Event.Idle, async () => {
const cfg = await Config.get()
if (cfg.experimental?.hook?.session_completed) {
for (const item of cfg.experimental.hook.session_completed) {
log.info("session_completed", {
command: item.command,
})
Bun.spawn({
cmd: item.command,
cwd: App.info().path.cwd,
env: item.environment,
stdout: "ignore",
stderr: "ignore",
})
}
}
})
}
}
-132
View File
@@ -1,132 +0,0 @@
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 = {
darwin: { platform: "apple-darwin", extension: "tar.gz" },
linux: { platform: "unknown-linux-musl", extension: "tar.gz" },
win32: { platform: "pc-windows-msvc", extension: "zip" },
} as const
export const ExtractionFailedError = NamedError.create(
"RipgrepExtractionFailedError",
z.object({
filepath: z.string(),
stderr: z.string(),
}),
)
export const UnsupportedPlatformError = NamedError.create(
"RipgrepUnsupportedPlatformError",
z.object({
platform: z.string(),
}),
)
export const DownloadFailedError = NamedError.create(
"RipgrepDownloadFailedError",
z.object({
url: z.string(),
status: z.number(),
}),
)
const state = lazy(async () => {
let filepath = Bun.which("rg")
if (filepath) return { filepath }
filepath = path.join(
Global.Path.bin,
"rg" + (process.platform === "win32" ? ".exe" : ""),
)
const file = Bun.file(filepath)
if (!(await file.exists())) {
const archMap = { x64: "x86_64", arm64: "aarch64" } as const
const arch = archMap[process.arch as keyof typeof archMap] ?? process.arch
const config = PLATFORM[process.platform as keyof typeof PLATFORM]
if (!config)
throw new UnsupportedPlatformError({ platform: process.platform })
const version = "14.1.1"
const filename = `ripgrep-${version}-${arch}-${config.platform}.${config.extension}`
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${version}/${filename}`
const response = await fetch(url)
if (!response.ok)
throw new DownloadFailedError({ url, status: response.status })
const buffer = await response.arrayBuffer()
const archivePath = path.join(Global.Path.bin, filename)
await Bun.write(archivePath, buffer)
if (config.extension === "tar.gz") {
const args = ["tar", "-xzf", archivePath, "--strip-components=1"]
if (process.platform === "darwin") args.push("--include=*/rg")
if (process.platform === "linux") args.push("--wildcards", "*/rg")
const proc = Bun.spawn(args, {
cwd: Global.Path.bin,
stderr: "pipe",
stdout: "pipe",
})
await proc.exited
if (proc.exitCode !== 0)
throw new ExtractionFailedError({
filepath,
stderr: await Bun.readableStreamToText(proc.stderr),
})
}
if (config.extension === "zip") {
const proc = Bun.spawn(
["unzip", "-j", archivePath, "*/rg.exe", "-d", Global.Path.bin],
{
cwd: Global.Path.bin,
stderr: "pipe",
stdout: "ignore",
},
)
await proc.exited
if (proc.exitCode !== 0)
throw new ExtractionFailedError({
filepath: archivePath,
stderr: await Bun.readableStreamToText(proc.stderr),
})
}
await fs.unlink(archivePath)
if (process.platform !== "win32") await fs.chmod(filepath, 0o755)
}
return {
filepath,
}
})
export async function filepath() {
const { filepath } = await state()
return filepath
}
export async function files(input: {
cwd: string
query?: string
glob?: string
limit?: number
}) {
const commands = [
`${await filepath()} --files --hidden --glob='!.git/*' ${input.glob ? `--glob='${input.glob}'` : ``}`,
]
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).nothrow().text()
return result.split("\n").filter(Boolean)
}
}
@@ -5,7 +5,6 @@ import { z } from "zod"
import { NamedError } from "../util/error"
import { lazy } from "../util/lazy"
import { Log } from "../util/log"
import { $ } from "bun"
export namespace Fzf {
const log = Log.create({ service: "fzf" })
@@ -115,24 +114,4 @@ export namespace Fzf {
const { filepath } = await state()
return filepath
}
export async function search(input: {
cwd: string
query: string
limit?: number
}) {
const results = await $`${await filepath()} --filter=${input.query}`
.quiet()
.throws(false)
.cwd(input.cwd)
.text()
const split = results
.trim()
.split("\n")
.filter((line) => line.length > 0)
log.info("results", {
count: split.length,
})
return split
}
}
+38
View File
@@ -0,0 +1,38 @@
import { z } from "zod"
import { Bus } from "../bus"
import { $ } from "bun"
import { createPatch } from "diff"
import path from "path"
export namespace File {
export const Event = {
Edited: Bus.event(
"file.edited",
z.object({
file: z.string(),
}),
),
}
export async function read(file: string) {
const content = await Bun.file(file).text()
const gitDiff = await $`git diff HEAD -- ${file}`
.cwd(path.dirname(file))
.quiet()
.nothrow()
.text()
if (gitDiff.trim()) {
const relativePath = path.relative(process.cwd(), file)
const originalContent = await $`git show HEAD:./${relativePath}`
.cwd(process.cwd())
.quiet()
.nothrow()
.text()
if (originalContent.trim()) {
const patch = createPatch(file, originalContent, content)
return patch
}
}
return content.trim()
}
}
+350
View File
@@ -0,0 +1,350 @@
// Ripgrep utility functions
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 Stats = z.object({
elapsed: z.object({
secs: z.number(),
nanos: z.number(),
human: z.string(),
}),
searches: z.number(),
searches_with_match: z.number(),
bytes_searched: z.number(),
bytes_printed: z.number(),
matched_lines: z.number(),
matches: z.number(),
})
const Begin = z.object({
type: z.literal("begin"),
data: z.object({
path: z.object({
text: z.string(),
}),
}),
})
const Match = z.object({
type: z.literal("match"),
data: z.object({
path: z.object({
text: z.string(),
}),
lines: z.object({
text: z.string(),
}),
line_number: z.number(),
absolute_offset: z.number(),
submatches: z.array(
z.object({
match: z.object({
text: z.string(),
}),
start: z.number(),
end: z.number(),
}),
),
}),
})
const End = z.object({
type: z.literal("end"),
data: z.object({
path: z.object({
text: z.string(),
}),
binary_offset: z.number().nullable(),
stats: Stats,
}),
})
const Summary = z.object({
type: z.literal("summary"),
data: z.object({
elapsed_total: z.object({
human: z.string(),
nanos: z.number(),
secs: z.number(),
}),
stats: Stats,
}),
})
const Result = z.union([Begin, Match, End, Summary])
export type Result = z.infer<typeof Result>
export type Match = z.infer<typeof Match>
export type Begin = z.infer<typeof Begin>
export type End = z.infer<typeof End>
export type Summary = z.infer<typeof Summary>
const PLATFORM = {
darwin: { platform: "apple-darwin", extension: "tar.gz" },
linux: { platform: "unknown-linux-musl", extension: "tar.gz" },
win32: { platform: "pc-windows-msvc", extension: "zip" },
} as const
export const ExtractionFailedError = NamedError.create(
"RipgrepExtractionFailedError",
z.object({
filepath: z.string(),
stderr: z.string(),
}),
)
export const UnsupportedPlatformError = NamedError.create(
"RipgrepUnsupportedPlatformError",
z.object({
platform: z.string(),
}),
)
export const DownloadFailedError = NamedError.create(
"RipgrepDownloadFailedError",
z.object({
url: z.string(),
status: z.number(),
}),
)
const state = lazy(async () => {
let filepath = Bun.which("rg")
if (filepath) return { filepath }
filepath = path.join(
Global.Path.bin,
"rg" + (process.platform === "win32" ? ".exe" : ""),
)
const file = Bun.file(filepath)
if (!(await file.exists())) {
const archMap = { x64: "x86_64", arm64: "aarch64" } as const
const arch = archMap[process.arch as keyof typeof archMap] ?? process.arch
const config = PLATFORM[process.platform as keyof typeof PLATFORM]
if (!config)
throw new UnsupportedPlatformError({ platform: process.platform })
const version = "14.1.1"
const filename = `ripgrep-${version}-${arch}-${config.platform}.${config.extension}`
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${version}/${filename}`
const response = await fetch(url)
if (!response.ok)
throw new DownloadFailedError({ url, status: response.status })
const buffer = await response.arrayBuffer()
const archivePath = path.join(Global.Path.bin, filename)
await Bun.write(archivePath, buffer)
if (config.extension === "tar.gz") {
const args = ["tar", "-xzf", archivePath, "--strip-components=1"]
if (process.platform === "darwin") args.push("--include=*/rg")
if (process.platform === "linux") args.push("--wildcards", "*/rg")
const proc = Bun.spawn(args, {
cwd: Global.Path.bin,
stderr: "pipe",
stdout: "pipe",
})
await proc.exited
if (proc.exitCode !== 0)
throw new ExtractionFailedError({
filepath,
stderr: await Bun.readableStreamToText(proc.stderr),
})
}
if (config.extension === "zip") {
const proc = Bun.spawn(
["unzip", "-j", archivePath, "*/rg.exe", "-d", Global.Path.bin],
{
cwd: Global.Path.bin,
stderr: "pipe",
stdout: "ignore",
},
)
await proc.exited
if (proc.exitCode !== 0)
throw new ExtractionFailedError({
filepath: archivePath,
stderr: await Bun.readableStreamToText(proc.stderr),
})
}
await fs.unlink(archivePath)
if (process.platform !== "win32") await fs.chmod(filepath, 0o755)
}
return {
filepath,
}
})
export async function filepath() {
const { filepath } = await state()
return filepath
}
export async function files(input: {
cwd: string
query?: string
glob?: string
limit?: number
}) {
const commands = [
`${await filepath()} --files --hidden --glob='!.git/*' ${input.glob ? `--glob='${input.glob}'` : ``}`,
]
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).nothrow().text()
return result.split("\n").filter(Boolean)
}
export async function tree(input: { cwd: string; limit?: number }) {
const files = await Ripgrep.files({ cwd: input.cwd })
interface Node {
path: string[]
children: Node[]
}
function getPath(node: Node, parts: string[], create: boolean) {
if (parts.length === 0) return node
let current = node
for (const part of parts) {
let existing = current.children.find((x) => x.path.at(-1) === part)
if (!existing) {
if (!create) return
existing = {
path: current.path.concat(part),
children: [],
}
current.children.push(existing)
}
current = existing
}
return current
}
const root: Node = {
path: [],
children: [],
}
for (const file of files) {
const parts = file.split(path.sep)
getPath(root, parts, true)
}
function sort(node: Node) {
node.children.sort((a, b) => {
if (!a.children.length && b.children.length) return 1
if (!b.children.length && a.children.length) return -1
return a.path.at(-1)!.localeCompare(b.path.at(-1)!)
})
for (const child of node.children) {
sort(child)
}
}
sort(root)
let current = [root]
const result: Node = {
path: [],
children: [],
}
let processed = 0
const limit = input.limit ?? 50
while (current.length > 0) {
const next = []
for (const node of current) {
if (node.children.length) next.push(...node.children)
}
const max = Math.max(...current.map((x) => x.children.length))
for (let i = 0; i < max && processed < limit; i++) {
for (const node of current) {
const child = node.children[i]
if (!child) continue
getPath(result, child.path, true)
processed++
if (processed >= limit) break
}
}
if (processed >= limit) {
for (const node of [...current, ...next]) {
const compare = getPath(result, node.path, false)
if (!compare) continue
if (compare?.children.length !== node.children.length) {
const diff = node.children.length - compare.children.length
compare.children.push({
path: compare.path.concat(`[${diff} truncated]`),
children: [],
})
}
}
break
}
current = next
}
const lines: string[] = []
function render(node: Node, depth: number) {
const indent = "\t".repeat(depth)
lines.push(indent + node.path.at(-1) + (node.children.length ? "/" : ""))
for (const child of node.children) {
render(child, depth + 1)
}
}
result.children.map((x) => render(x, 0))
return lines.join("\n")
}
export async function search(input: {
cwd: string
pattern: string
glob?: string[]
limit?: number
}) {
const args = [
`${await filepath()}`,
"--json",
"--hidden",
"--glob='!.git/*'",
]
if (input.glob) {
for (const g of input.glob) {
args.push(`--glob=${g}`)
}
}
if (input.limit) {
args.push(`--max-count=${input.limit}`)
}
args.push(input.pattern)
const command = args.join(" ")
const result = await $`${{ raw: command }}`.cwd(input.cwd).quiet().nothrow()
if (result.exitCode !== 0) {
return []
}
const lines = result.text().trim().split("\n").filter(Boolean)
// Parse JSON lines from ripgrep output
return lines
.map((line) => JSON.parse(line))
.map((parsed) => Result.parse(parsed))
.filter((r) => r.type === "match")
.map((r) => r.data)
}
}
@@ -1,6 +1,6 @@
import { App } from "../../app/app"
import { App } from "../app/app"
export namespace FileTimes {
export namespace FileTime {
export const state = App.state("tool.filetimes", () => {
const read: {
[sessionID: string]: {
+160
View File
@@ -0,0 +1,160 @@
import { App } from "../app/app"
import { BunProc } from "../bun"
export interface Info {
name: string
command: string[]
environment?: Record<string, string>
extensions: string[]
enabled(): Promise<boolean>
}
export const gofmt: Info = {
name: "gofmt",
command: ["gofmt", "-w", "$FILE"],
extensions: [".go"],
async enabled() {
return Bun.which("gofmt") !== null
},
}
export const mix: Info = {
name: "mix",
command: ["mix", "format", "$FILE"],
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
async enabled() {
return Bun.which("mix") !== null
},
}
export const prettier: Info = {
name: "prettier",
command: [BunProc.which(), "run", "prettier", "--write", "$FILE"],
environment: {
BUN_BE_BUN: "1",
},
extensions: [
".js",
".jsx",
".mjs",
".cjs",
".ts",
".tsx",
".mts",
".cts",
".html",
".htm",
".css",
".scss",
".sass",
".less",
".vue",
".svelte",
".json",
".jsonc",
".yaml",
".yml",
".toml",
".xml",
".md",
".mdx",
".graphql",
".gql",
],
async enabled() {
// this is more complicated because we only want to use prettier if it's
// being used with the current project
try {
const proc = Bun.spawn({
cmd: [BunProc.which(), "run", "prettier", "--version"],
cwd: App.info().path.cwd,
env: {
BUN_BE_BUN: "1",
},
stdout: "ignore",
stderr: "ignore",
})
const exit = await proc.exited
return exit === 0
} catch {
return false
}
},
}
export const zig: Info = {
name: "zig",
command: ["zig", "fmt", "$FILE"],
extensions: [".zig", ".zon"],
async enabled() {
return Bun.which("zig") !== null
},
}
export const clang: Info = {
name: "clang-format",
command: ["clang-format", "-i", "$FILE"],
extensions: [
".c",
".cc",
".cpp",
".cxx",
".c++",
".h",
".hh",
".hpp",
".hxx",
".h++",
".ino",
".C",
".H",
],
async enabled() {
return Bun.which("clang-format") !== null
},
}
export const ktlint: Info = {
name: "ktlint",
command: ["ktlint", "-F", "$FILE"],
extensions: [".kt", ".kts"],
async enabled() {
return Bun.which("ktlint") !== null
},
}
export const ruff: Info = {
name: "ruff",
command: ["ruff", "format", "$FILE"],
extensions: [".py", ".pyi"],
async enabled() {
return Bun.which("ruff") !== null
},
}
export const rubocop: Info = {
name: "rubocop",
command: ["rubocop", "--autocorrect", "$FILE"],
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async enabled() {
return Bun.which("rubocop") !== null
},
}
export const standardrb: Info = {
name: "standardrb",
command: ["standardrb", "--fix", "$FILE"],
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async enabled() {
return Bun.which("standardrb") !== null
},
}
export const htmlbeautifier: Info = {
name: "htmlbeautifier",
command: ["htmlbeautifier", "$FILE"],
extensions: [".erb", ".html.erb"],
async enabled() {
return Bun.which("htmlbeautifier") !== null
},
}
+65
View File
@@ -0,0 +1,65 @@
import { App } from "../app/app"
import { Bus } from "../bus"
import { File } from "../file"
import { Log } from "../util/log"
import path from "path"
import * as Formatter from "./formatter"
export namespace Format {
const log = Log.create({ service: "format" })
const state = App.state("format", () => {
const enabled: Record<string, boolean> = {}
return {
enabled,
}
})
async function isEnabled(item: Formatter.Info) {
const s = state()
let status = s.enabled[item.name]
if (status === undefined) {
status = await item.enabled()
s.enabled[item.name] = status
}
return status
}
async function getFormatter(ext: string) {
const result = []
for (const item of Object.values(Formatter)) {
if (!item.extensions.includes(ext)) continue
if (!isEnabled(item)) continue
result.push(item)
}
return result
}
export function init() {
log.info("init")
Bus.subscribe(File.Event.Edited, async (payload) => {
const file = payload.properties.file
log.info("formatting", { file })
const ext = path.extname(file)
for (const item of await getFormatter(ext)) {
log.info("running", { command: item.command })
const proc = Bun.spawn({
cmd: item.command.map((x) => x.replace("$FILE", file)),
cwd: App.info().path.cwd,
env: item.environment,
stdout: "ignore",
stderr: "ignore",
})
const exit = await proc.exited
if (exit !== 0)
log.error("failed", {
command: item.command,
...item.environment,
})
}
})
}
}
+20 -111
View File
@@ -1,28 +1,19 @@
import "zod-openapi/extend"
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 url from "node:url"
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 { ScrapCommand } from "./cli/cmd/scrap"
import { Log } from "./util/log"
import { AuthCommand, AuthLoginCommand } from "./cli/cmd/auth"
import { AuthCommand } from "./cli/cmd/auth"
import { UpgradeCommand } from "./cli/cmd/upgrade"
import { ModelsCommand } from "./cli/cmd/models"
import { Provider } from "./provider/provider"
import { UI } from "./cli/ui"
import { Installation } from "./installation"
import { Bus } from "./bus"
import { Config } from "./config/config"
import { NamedError } from "./util/error"
import { FormatError } from "./cli/error"
import { ServeCommand } from "./cli/cmd/serve"
import { TuiCommand } from "./cli/cmd/tui"
import { DebugCommand } from "./cli/cmd/debug"
const cancel = new AbortController()
@@ -55,106 +46,10 @@ const cli = yargs(hideBin(process.argv))
})
})
.usage("\n" + UI.logo())
.command({
command: "$0 [project]",
describe: "start opencode tui",
builder: (yargs) =>
yargs.positional("project", {
type: "string",
describe: "path to start opencode in",
}),
handler: async (args) => {
while (true) {
const cwd = args.project ? path.resolve(args.project) : process.cwd()
try {
process.chdir(cwd)
} catch (e) {
UI.error("Failed to change directory to " + cwd)
return
}
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({
port: 0,
hostname: "127.0.0.1",
})
let cmd = ["go", "run", "./main.go"]
let cwd = url.fileURLToPath(
new URL("../../tui/cmd/opencode", import.meta.url),
)
if (Bun.embeddedFiles.length > 0) {
const blob = Bun.embeddedFiles[0] as File
let binaryName = blob.name
if (process.platform === "win32" && !binaryName.endsWith(".exe")) {
binaryName += ".exe"
}
const binary = path.join(Global.Path.cache, "tui", binaryName)
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)],
signal: cancel.signal,
cwd,
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
env: {
...process.env,
OPENCODE_SERVER: server.url.toString(),
OPENCODE_APP_INFO: JSON.stringify(app),
},
onExit: () => {
server.stop()
},
})
;(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().catch(() => {})
if (!latest) return
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(() => {})
})()
await proc.exited
server.stop()
return "done"
})
if (result === "done") break
if (result === "needs_provider") {
UI.empty()
UI.println(UI.logo(" "))
UI.empty()
await AuthLoginCommand.handler(args)
}
}
},
})
.command(TuiCommand)
.command(RunCommand)
.command(GenerateCommand)
.command(ScrapCommand)
.command(DebugCommand)
.command(AuthCommand)
.command(UpgradeCommand)
.command(ServeCommand)
@@ -172,13 +67,14 @@ const cli = yargs(hideBin(process.argv))
try {
await cli.parse()
} catch (e) {
const data: Record<string, any> = {}
let data: Record<string, any> = {}
if (e instanceof NamedError) {
const obj = e.toObject()
Object.assign(data, {
...obj.data,
})
}
if (e instanceof Error) {
Object.assign(data, {
name: e.name,
@@ -186,6 +82,18 @@ try {
cause: e.cause?.toString(),
})
}
if (e instanceof ResolveMessage) {
Object.assign(data, {
name: e.name,
message: e.message,
code: e.code,
specifier: e.specifier,
referrer: e.referrer,
position: e.position,
importKind: e.importKind,
})
}
Log.Default.error("fatal", data)
const formatted = FormatError(e)
if (formatted) UI.error(formatted)
@@ -193,6 +101,7 @@ try {
UI.error(
"Unexpected error, check log file at " + Log.file() + " for more details",
)
process.exitCode = 1
}
cancel.abort()
+28 -40
View File
@@ -12,6 +12,7 @@ import { Bus } from "../bus"
import z from "zod"
import type { LSPServer } from "./server"
import { NamedError } from "../util/error"
import { withTimeout } from "../util/timeout"
export namespace LSPClient {
const log = Log.create({ service: "lsp.client" })
@@ -52,7 +53,9 @@ export namespace LSPClient {
log.info("textDocument/publishDiagnostics", {
path,
})
const exists = diagnostics.has(path)
diagnostics.set(path, params.diagnostics)
if (!exists && serverID === "typescript") return
Bus.publish(Event.Diagnostics, { path, serverID })
})
connection.onRequest("workspace/configuration", async () => {
@@ -61,7 +64,7 @@ export namespace LSPClient {
connection.listen()
log.info("sending initialize", { id: serverID })
await Promise.race([
await withTimeout(
connection.sendRequest("initialize", {
processId: server.process.pid,
workspaceFolders: [
@@ -88,12 +91,10 @@ export namespace LSPClient {
},
},
}),
new Promise((_, reject) => {
setTimeout(() => {
reject(new InitializeError({ serverID }))
}, 5_000)
}),
])
5_000,
).catch(() => {
throw new InitializeError({ serverID })
})
await connection.sendNotification("initialized", {})
log.info("initialized")
@@ -116,36 +117,28 @@ export namespace LSPClient {
const file = Bun.file(input.path)
const text = await file.text()
const version = files[input.path]
if (version === undefined) {
log.info("textDocument/didOpen", input)
if (version !== undefined) {
diagnostics.delete(input.path)
const extension = path.extname(input.path)
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
await connection.sendNotification("textDocument/didOpen", {
await connection.sendNotification("textDocument/didClose", {
textDocument: {
uri: `file://` + input.path,
languageId,
version: 0,
text,
},
})
files[input.path] = 0
return
}
log.info("textDocument/didChange", input)
log.info("textDocument/didOpen", input)
diagnostics.delete(input.path)
await connection.sendNotification("textDocument/didChange", {
const extension = path.extname(input.path)
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
await connection.sendNotification("textDocument/didOpen", {
textDocument: {
uri: `file://` + input.path,
version: ++files[input.path],
languageId,
version: 0,
text,
},
contentChanges: [
{
text,
},
],
})
files[input.path] = 0
return
},
},
get diagnostics() {
@@ -157,35 +150,30 @@ export namespace LSPClient {
: path.resolve(app.path.cwd, input.path)
log.info("waiting for diagnostics", input)
let unsub: () => void
let timeout: NodeJS.Timeout
return await Promise.race([
new Promise<void>(async (resolve) => {
return await withTimeout(
new Promise<void>((resolve) => {
unsub = Bus.subscribe(Event.Diagnostics, (event) => {
if (
event.properties.path === input.path &&
event.properties.serverID === result.serverID
) {
log.info("got diagnostics", input)
clearTimeout(timeout)
unsub?.()
resolve()
}
})
}),
new Promise<void>((resolve) => {
timeout = setTimeout(() => {
log.info("timed out refreshing diagnostics", input)
unsub?.()
resolve()
}, 5000)
}),
])
5000,
).finally(() => {
unsub?.()
})
},
async shutdown() {
log.info("shutting down")
log.info("shutting down", { serverID })
connection.end()
connection.dispose()
server.process.kill("SIGKILL")
server.process.kill("SIGTERM")
log.info("shutdown", { serverID })
},
}
+43 -30
View File
@@ -3,19 +3,36 @@ import { Log } from "../util/log"
import { LSPClient } from "./client"
import path from "path"
import { LSPServer } from "./server"
import { Ripgrep } from "../file/ripgrep"
export namespace LSP {
const log = Log.create({ service: "lsp" })
const state = App.state(
"lsp",
async () => {
async (app) => {
log.info("initializing")
const clients = new Map<string, LSPClient.Info>()
const skip = new Set<string>()
for (const server of Object.values(LSPServer)) {
for (const extension of server.extensions) {
const [file] = await Ripgrep.files({
cwd: app.path.cwd,
glob: "*" + extension,
})
if (!file) continue
const handle = await server.spawn(App.info())
if (!handle) break
const client = await LSPClient.create(server.id, handle).catch(
() => {},
)
if (!client) break
clients.set(server.id, client)
break
}
}
log.info("initialized")
return {
clients,
skip,
}
},
async (state) => {
@@ -25,35 +42,23 @@ export namespace LSP {
},
)
export async function init() {
return state()
}
export async function touchFile(input: string, waitForDiagnostics?: boolean) {
const extension = path.parse(input).ext
const s = await state()
const matches = LSPServer.All.filter((x) =>
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) {
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) {
await run(async (client) => {
const wait = client.waitForDiagnostics({ path: input })
await client.notify.open({ path: input })
return wait
})
}
const matches = Object.values(LSPServer)
.filter((x) => x.extensions.includes(extension))
.map((x) => x.id)
await run(async (client) => {
if (!matches.includes(client.serverID)) return
const wait = waitForDiagnostics
? client.waitForDiagnostics({ path: input })
: Promise.resolve()
await client.notify.open({ path: input })
return wait
})
}
export async function diagnostics() {
@@ -86,6 +91,14 @@ export namespace LSP {
})
}
export async function workspaceSymbol(query: string) {
return run((client) =>
client.connection.sendRequest("workspace/symbol", {
query,
}),
)
}
async function run<T>(
input: (client: LSPClient.Info) => Promise<T>,
): Promise<T[]> {
+8
View File
@@ -63,6 +63,14 @@ export const LANGUAGE_EXTENSIONS: Record<string, string> = {
".cshtml": "razor",
".razor": "razor",
".rb": "ruby",
".rake": "ruby",
".gemspec": "ruby",
".ru": "ruby",
".erb": "erb",
".html.erb": "erb",
".js.erb": "erb",
".css.erb": "erb",
".json.erb": "erb",
".rs": "rust",
".scss": "scss",
".sass": "sass",
+118 -68
View File
@@ -19,78 +19,128 @@ export namespace LSPServer {
spawn(app: App.Info): Promise<Handle | undefined>
}
export const All: Info[] = [
{
id: "typescript",
extensions: [
".ts",
".tsx",
".js",
".jsx",
".mjs",
".cjs",
".mts",
".cts",
],
async spawn(app) {
const tsserver = await Bun.resolve(
"typescript/lib/tsserver.js",
app.path.cwd,
).catch(() => {})
if (!tsserver) return
const proc = spawn(
BunProc.which(),
["x", "typescript-language-server", "--stdio"],
{
env: {
...process.env,
BUN_BE_BUN: "1",
},
export const Typescript: Info = {
id: "typescript",
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
async spawn(app) {
const tsserver = await Bun.resolve(
"typescript/lib/tsserver.js",
app.path.cwd,
).catch(() => {})
if (!tsserver) return
const proc = spawn(
BunProc.which(),
["x", "typescript-language-server", "--stdio"],
{
env: {
...process.env,
BUN_BE_BUN: "1",
},
)
return {
process: proc,
initialization: {
tsserver: {
path: tsserver,
},
},
)
return {
process: proc,
initialization: {
tsserver: {
path: tsserver,
},
}
},
},
}
},
{
id: "golang",
extensions: [".go"],
async spawn() {
let bin = Bun.which("gopls", {
PATH: process.env["PATH"] + ":" + Global.Path.bin,
}
export const Gopls: Info = {
id: "golang",
extensions: [".go"],
async spawn() {
let bin = Bun.which("gopls", {
PATH: process.env["PATH"] + ":" + Global.Path.bin,
})
if (!bin) {
log.info("installing gopls")
const proc = Bun.spawn({
cmd: ["go", "install", "golang.org/x/tools/gopls@latest"],
env: { ...process.env, GOBIN: Global.Path.bin },
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
})
if (!bin) {
log.info("installing gopls")
const proc = Bun.spawn({
cmd: ["go", "install", "golang.org/x/tools/gopls@latest"],
env: { ...process.env, GOBIN: Global.Path.bin },
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
})
const exit = await proc.exited
if (exit !== 0) {
log.error("Failed to install gopls")
return
}
bin = path.join(
Global.Path.bin,
"gopls" + (process.platform === "win32" ? ".exe" : ""),
)
log.info(`installed gopls`, {
bin,
})
const exit = await proc.exited
if (exit !== 0) {
log.error("Failed to install gopls")
return
}
return {
process: spawn(bin!),
}
},
bin = path.join(
Global.Path.bin,
"gopls" + (process.platform === "win32" ? ".exe" : ""),
)
log.info(`installed gopls`, {
bin,
})
}
return {
process: spawn(bin!),
}
},
]
}
export const RubyLsp: Info = {
id: "ruby-lsp",
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async spawn() {
let bin = Bun.which("ruby-lsp", {
PATH: process.env["PATH"] + ":" + Global.Path.bin,
})
if (!bin) {
const ruby = Bun.which("ruby")
const gem = Bun.which("gem")
if (!ruby || !gem) {
log.info("Ruby not found, please install Ruby first")
return
}
log.info("installing ruby-lsp")
const proc = Bun.spawn({
cmd: ["gem", "install", "ruby-lsp", "--bindir", Global.Path.bin],
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
})
const exit = await proc.exited
if (exit !== 0) {
log.error("Failed to install ruby-lsp")
return
}
bin = path.join(
Global.Path.bin,
"ruby-lsp" + (process.platform === "win32" ? ".exe" : ""),
)
log.info(`installed ruby-lsp`, {
bin,
})
}
return {
process: spawn(bin!, ["--stdio"]),
}
},
}
export const Pyright: Info = {
id: "pyright",
extensions: [".py", ".pyi"],
async spawn() {
const proc = spawn(
BunProc.which(),
["x", "pyright-langserver", "--stdio"],
{
env: {
...process.env,
BUN_BE_BUN: "1",
},
},
)
return {
process: proc,
}
},
}
}
+4
View File
@@ -26,6 +26,10 @@ export namespace MCP {
[name: string]: Awaited<ReturnType<typeof experimental_createMCPClient>>
} = {}
for (const [key, mcp] of Object.entries(cfg.mcp ?? {})) {
if (mcp.enabled === false) {
log.info("mcp server disabled", { key })
continue
}
log.info("found", { key, type: mcp.type })
if (mcp.type === "remote") {
const client = await experimental_createMCPClient({
+4 -3
View File
@@ -10,7 +10,9 @@ export namespace ModelsDev {
export const Model = z
.object({
id: z.string(),
name: z.string(),
release_date: z.string(),
attachment: z.boolean(),
reasoning: z.boolean(),
temperature: z.boolean(),
@@ -25,11 +27,10 @@ export namespace ModelsDev {
context: z.number(),
output: z.number(),
}),
id: z.string(),
options: z.record(z.any()),
})
.openapi({
ref: "Model.Info",
ref: "Model",
})
export type Model = z.infer<typeof Model>
@@ -43,7 +44,7 @@ export namespace ModelsDev {
models: z.record(Model),
})
.openapi({
ref: "Provider.Info",
ref: "Provider",
})
export type Provider = z.infer<typeof Provider>
+62 -13
View File
@@ -11,8 +11,6 @@ import { WebFetchTool } from "../tool/webfetch"
import { GlobTool } from "../tool/glob"
import { GrepTool } from "../tool/grep"
import { ListTool } from "../tool/ls"
import { LspDiagnosticTool } from "../tool/lsp-diagnostics"
import { LspHoverTool } from "../tool/lsp-hover"
import { PatchTool } from "../tool/patch"
import { ReadTool } from "../tool/read"
import type { Tool } from "../tool/tool"
@@ -23,7 +21,7 @@ import { AuthCopilot } from "../auth/copilot"
import { ModelsDev } from "./models"
import { NamedError } from "../util/error"
import { Auth } from "../auth"
import { TaskTool } from "../tool/task"
// import { TaskTool } from "../tool/task"
export namespace Provider {
const log = Log.create({ service: "provider" })
@@ -141,10 +139,54 @@ export namespace Provider {
credentialProvider: fromNodeProviderChain(),
},
async getModel(sdk: any, modelID: string) {
if (modelID.includes("claude")) {
const prefix = region.split("-")[0]
modelID = `${prefix}.${modelID}`
let regionPrefix = region.split("-")[0]
switch (regionPrefix) {
case "us": {
const modelRequiresPrefix = ["claude", "deepseek"].some((m) =>
modelID.includes(m),
)
if (modelRequiresPrefix) {
modelID = `${regionPrefix}.${modelID}`
}
break
}
case "eu": {
const regionRequiresPrefix = [
"eu-west-1",
"eu-west-3",
"eu-north-1",
"eu-central-1",
"eu-south-1",
"eu-south-2",
].some((r) => region.includes(r))
const modelRequiresPrefix = [
"claude",
"nova-lite",
"nova-micro",
"llama3",
"pixtral",
].some((m) => modelID.includes(m))
if (regionRequiresPrefix && modelRequiresPrefix) {
modelID = `${regionPrefix}.${modelID}`
}
break
}
case "ap": {
const modelRequiresPrefix = [
"claude",
"nova-lite",
"nova-micro",
"nova-pro",
].some((m) => modelID.includes(m))
if (modelRequiresPrefix) {
regionPrefix = "apac"
modelID = `${regionPrefix}.${modelID}`
}
break
}
}
return sdk.languageModel(modelID)
},
}
@@ -186,6 +228,7 @@ export namespace Provider {
source,
info,
options,
getModel,
}
return
}
@@ -203,6 +246,7 @@ export namespace Provider {
npm: provider.npm ?? existing?.npm,
name: provider.name ?? existing?.name ?? providerID,
env: provider.env ?? existing?.env ?? [],
api: provider.api ?? existing?.api,
models: existing?.models ?? {},
}
@@ -211,6 +255,7 @@ export namespace Provider {
const parsedModel: ModelsDev.Model = {
id: modelID,
name: model.name ?? existing?.name ?? modelID,
release_date: model.release_date ?? existing?.release_date,
attachment: model.attachment ?? existing?.attachment ?? false,
reasoning: model.reasoning ?? existing?.reasoning ?? false,
temperature: model.temperature ?? existing?.temperature ?? false,
@@ -244,9 +289,14 @@ export namespace Provider {
// 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")
}
const apiKey = provider.env.map((item) => process.env[item]).at(0)
if (!apiKey) continue
mergeProvider(
providerID,
// only include apiKey if there's only one potential option
provider.env.length === 1 ? { apiKey } : {},
"env",
)
}
// load apikeys
@@ -403,16 +453,15 @@ export namespace Provider {
GlobTool,
GrepTool,
ListTool,
LspDiagnosticTool,
LspHoverTool,
// LspDiagnosticTool,
// LspHoverTool,
PatchTool,
ReadTool,
EditTool,
// MultiEditTool,
WriteTool,
TodoWriteTool,
TaskTool,
TodoReadTool,
// TaskTool,
]
const TOOL_MAPPING: Record<string, Tool.Info[]> = {
@@ -20,6 +20,19 @@ export namespace ProviderTransform {
}
}
}
if (providerID === "amazon-bedrock" || modelID.includes("anthropic")) {
const system = msgs.filter((msg) => msg.role === "system").slice(0, 2)
const final = msgs.filter((msg) => msg.role !== "system").slice(-2)
for (const msg of unique([...system, ...final])) {
msg.providerMetadata = {
...msg.providerMetadata,
bedrock: {
cachePoint: { type: "ephemeral" },
},
}
}
}
return msgs
}
}
+198 -235
View File
@@ -9,12 +9,10 @@ import { z } from "zod"
import { Message } from "../session/message"
import { Provider } from "../provider/provider"
import { App } from "../app/app"
import { Global } from "../global"
import { mapValues } from "remeda"
import { NamedError } from "../util/error"
import { ModelsDev } from "../provider/models"
import { Ripgrep } from "../external/ripgrep"
import { Installation } from "../installation"
import { Ripgrep } from "../file/ripgrep"
import { Config } from "../config/config"
const ERRORS = {
@@ -70,12 +68,12 @@ export namespace Server {
})
})
.get(
"/openapi",
"/doc",
openAPISpecs(app, {
documentation: {
info: {
title: "opencode",
version: "1.0.0",
version: "0.0.2",
description: "opencode api",
},
openapi: "3.0.0",
@@ -122,8 +120,8 @@ export namespace Server {
})
},
)
.post(
"/app_info",
.get(
"/app",
describeRoute({
description: "Get app info",
responses: {
@@ -142,26 +140,7 @@ export namespace Server {
},
)
.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",
"/app/init",
describeRoute({
description: "Initialize the app",
responses: {
@@ -180,172 +159,27 @@ export namespace Server {
return c.json(true)
},
)
.post(
"/session_initialize",
.get(
"/config",
describeRoute({
description: "Analyze the app and create an AGENTS.md file",
description: "Get config info",
responses: {
200: {
description: "200",
description: "Get config info",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
zValidator(
"json",
z.object({
sessionID: z.string(),
providerID: z.string(),
modelID: z.string(),
}),
),
async (c) => {
const body = c.req.valid("json")
await Session.initialize(body)
return c.json(true)
},
)
.post(
"/path_get",
describeRoute({
description: "Get paths",
responses: {
200: {
description: "200",
content: {
"application/json": {
schema: resolver(
z.object({
root: z.string(),
data: z.string(),
cwd: z.string(),
config: z.string(),
}),
),
schema: resolver(Config.Info),
},
},
},
},
}),
async (c) => {
const app = App.info()
return c.json({
root: app.path.root,
data: app.path.data,
cwd: app.path.cwd,
config: Global.Path.data,
})
return c.json(await Config.get())
},
)
.post(
"/session_create",
describeRoute({
description: "Create a new session",
responses: {
...ERRORS,
200: {
description: "Successfully created session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
},
}),
async (c) => {
const session = await Session.create()
return c.json(session)
},
)
.post(
"/session_share",
describeRoute({
description: "Share the session",
responses: {
200: {
description: "Successfully shared session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
},
}),
zValidator(
"json",
z.object({
sessionID: z.string(),
}),
),
async (c) => {
const body = c.req.valid("json")
await Session.share(body.sessionID)
const session = await Session.get(body.sessionID)
return c.json(session)
},
)
.post(
"/session_unshare",
describeRoute({
description: "Unshare the session",
responses: {
200: {
description: "Successfully unshared session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
},
}),
zValidator(
"json",
z.object({
sessionID: z.string(),
}),
),
async (c) => {
const body = c.req.valid("json")
await Session.unshare(body.sessionID)
const session = await Session.get(body.sessionID)
return c.json(session)
},
)
.post(
"/session_messages",
describeRoute({
description: "Get messages for a session",
responses: {
200: {
description: "Successfully created session",
content: {
"application/json": {
schema: resolver(Message.Info.array()),
},
},
},
},
}),
zValidator(
"json",
z.object({
sessionID: z.string(),
}),
),
async (c) => {
const messages = await Session.messages(c.req.valid("json").sessionID)
return c.json(messages)
},
)
.post(
"/session_list",
.get(
"/session",
describeRoute({
description: "List all sessions",
responses: {
@@ -365,33 +199,28 @@ export namespace Server {
},
)
.post(
"/session_abort",
"/session",
describeRoute({
description: "Abort a session",
description: "Create a new session",
responses: {
...ERRORS,
200: {
description: "Aborted session",
description: "Successfully created session",
content: {
"application/json": {
schema: resolver(z.boolean()),
schema: resolver(Session.Info),
},
},
},
},
}),
zValidator(
"json",
z.object({
sessionID: z.string(),
}),
),
async (c) => {
const body = c.req.valid("json")
return c.json(Session.abort(body.sessionID))
const session = await Session.create()
return c.json(session)
},
)
.post(
"/session_delete",
.delete(
"/session/:id",
describeRoute({
description: "Delete a session and all its data",
responses: {
@@ -406,24 +235,23 @@ export namespace Server {
},
}),
zValidator(
"json",
"param",
z.object({
sessionID: z.string(),
id: z.string(),
}),
),
async (c) => {
const body = c.req.valid("json")
await Session.remove(body.sessionID)
await Session.remove(c.req.valid("param").id)
return c.json(true)
},
)
.post(
"/session_summarize",
"/session/:id/init",
describeRoute({
description: "Summarize the session",
description: "Analyze the app and create an AGENTS.md file",
responses: {
200: {
description: "Summarize the session",
description: "200",
content: {
"application/json": {
schema: resolver(z.boolean()),
@@ -432,27 +260,175 @@ export namespace Server {
},
},
}),
zValidator(
"param",
z.object({
id: z.string().openapi({ description: "Session ID" }),
}),
),
zValidator(
"json",
z.object({
sessionID: z.string(),
providerID: z.string(),
modelID: z.string(),
}),
),
async (c) => {
const sessionID = c.req.valid("param").id
const body = c.req.valid("json")
await Session.summarize(body)
await Session.initialize({ ...body, sessionID })
return c.json(true)
},
)
.post(
"/session_chat",
"/session/:id/abort",
describeRoute({
description: "Chat with a model",
description: "Abort a session",
responses: {
200: {
description: "Chat with a model",
description: "Aborted session",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
zValidator(
"param",
z.object({
id: z.string(),
}),
),
async (c) => {
return c.json(Session.abort(c.req.valid("param").id))
},
)
.post(
"/session/:id/share",
describeRoute({
description: "Share a session",
responses: {
200: {
description: "Successfully shared session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
},
}),
zValidator(
"param",
z.object({
id: z.string(),
}),
),
async (c) => {
const id = c.req.valid("param").id
await Session.share(id)
const session = await Session.get(id)
return c.json(session)
},
)
.delete(
"/session/:id/share",
describeRoute({
description: "Unshare the session",
responses: {
200: {
description: "Successfully unshared session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
},
}),
zValidator(
"param",
z.object({
id: z.string(),
}),
),
async (c) => {
const id = c.req.valid("param").id
await Session.unshare(id)
const session = await Session.get(id)
return c.json(session)
},
)
.post(
"/session/:id/summarize",
describeRoute({
description: "Summarize the session",
responses: {
200: {
description: "Summarized session",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
zValidator(
"param",
z.object({
id: z.string().openapi({ description: "Session ID" }),
}),
),
zValidator(
"json",
z.object({
providerID: z.string(),
modelID: z.string(),
}),
),
async (c) => {
const id = c.req.valid("param").id
const body = c.req.valid("json")
await Session.summarize({ ...body, sessionID: id })
return c.json(true)
},
)
.get(
"/session/:id/message",
describeRoute({
description: "List messages for a session",
responses: {
200: {
description: "List of messages",
content: {
"application/json": {
schema: resolver(Message.Info.array()),
},
},
},
},
}),
zValidator(
"param",
z.object({
id: z.string().openapi({ description: "Session ID" }),
}),
),
async (c) => {
const messages = await Session.messages(c.req.valid("param").id)
return c.json(messages)
},
)
.post(
"/session/:id/message",
describeRoute({
description: "Create and send a new message to a session",
responses: {
200: {
description: "Created message",
content: {
"application/json": {
schema: resolver(Message.Info),
@@ -461,23 +437,29 @@ export namespace Server {
},
},
}),
zValidator(
"param",
z.object({
id: z.string().openapi({ description: "Session ID" }),
}),
),
zValidator(
"json",
z.object({
sessionID: z.string(),
providerID: z.string(),
modelID: z.string(),
parts: Message.Part.array(),
parts: Message.MessagePart.array(),
}),
),
async (c) => {
const sessionID = c.req.valid("param").id
const body = c.req.valid("json")
const msg = await Session.chat(body)
const msg = await Session.chat({ ...body, sessionID })
return c.json(msg)
},
)
.post(
"/provider_list",
.get(
"/config/providers",
describeRoute({
description: "List all providers",
responses: {
@@ -509,8 +491,8 @@ export namespace Server {
})
},
)
.post(
"/file_search",
.get(
"/file",
describeRoute({
description: "Search for files",
responses: {
@@ -525,41 +507,22 @@ export namespace Server {
},
}),
zValidator(
"json",
"query",
z.object({
query: z.string(),
}),
),
async (c) => {
const body = c.req.valid("json")
const query = c.req.valid("query").query
const app = App.info()
const result = await Ripgrep.files({
cwd: app.path.cwd,
query: body.query,
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
}
+27 -18
View File
@@ -55,14 +55,18 @@ export namespace Session {
}),
})
.openapi({
ref: "session.info",
ref: "Session",
})
export type Info = z.output<typeof Info>
export const ShareInfo = z.object({
secret: z.string(),
url: z.string(),
})
export const ShareInfo = z
.object({
secret: z.string(),
url: z.string(),
})
.openapi({
ref: "SessionShare",
})
export type ShareInfo = z.output<typeof ShareInfo>
export const Event = {
@@ -78,6 +82,12 @@ export namespace Session {
info: Info,
}),
),
Idle: Bus.event(
"session.idle",
z.object({
sessionID: z.string(),
}),
),
Error: Bus.event(
"session.error",
z.object({
@@ -267,7 +277,7 @@ export namespace Session {
sessionID: string
providerID: string
modelID: string
parts: Message.Part[]
parts: Message.MessagePart[]
system?: string[]
tools?: Tool.Info[]
}) {
@@ -492,15 +502,6 @@ export namespace Session {
}
text = undefined
},
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) {
@@ -537,6 +538,7 @@ export namespace Session {
// return step
// },
toolCallStreaming: true,
maxTokens: Math.max(0, model.info.limit.output) || undefined,
abortSignal: abort.signal,
maxSteps: 1000,
providerOptions: model.info.options,
@@ -670,7 +672,7 @@ export namespace Session {
value.usage,
value.providerMetadata,
)
assistant.cost = usage.cost
assistant.cost += usage.cost
await updateMessage(next)
if (value.finishReason === "length")
throw new Message.OutputLengthError({})
@@ -819,7 +821,7 @@ export namespace Session {
async onFinish(input) {
const assistant = next.metadata!.assistant!
const usage = getUsage(model.info, input.usage, input.providerMetadata)
assistant.cost = usage.cost
assistant.cost += usage.cost
assistant.tokens = usage.tokens
next.metadata!.time.completed = Date.now()
await updateMessage(next)
@@ -853,6 +855,9 @@ export namespace Session {
[Symbol.dispose]() {
log.info("unlocking", { sessionID })
state().pending.delete(sessionID)
Bus.publish(Event.Idle, {
sessionID,
})
},
}
}
@@ -868,8 +873,12 @@ export namespace Session {
reasoning: 0,
cache: {
write: (metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
// @ts-expect-error
metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ??
0) as number,
read: (metadata?.["anthropic"]?.["cacheReadInputTokens"] ??
// @ts-expect-error
metadata?.["bedrock"]?.["usage"]?.["cacheReadInputTokens"] ??
0) as number,
},
}
@@ -941,7 +950,7 @@ function toUIMessage(msg: Message.Info): UIMessage {
throw new Error("not implemented")
}
function toParts(parts: Message.Part[]): UIMessage["parts"] {
function toParts(parts: Message.MessagePart[]): UIMessage["parts"] {
const result: UIMessage["parts"] = []
for (const part of parts) {
switch (part.type) {
+21 -17
View File
@@ -18,7 +18,7 @@ export namespace Message {
args: z.custom<Required<unknown>>(),
})
.openapi({
ref: "Message.ToolInvocation.ToolCall",
ref: "ToolCall",
})
export type ToolCall = z.infer<typeof ToolCall>
@@ -31,7 +31,7 @@ export namespace Message {
args: z.custom<Required<unknown>>(),
})
.openapi({
ref: "Message.ToolInvocation.ToolPartialCall",
ref: "ToolPartialCall",
})
export type ToolPartialCall = z.infer<typeof ToolPartialCall>
@@ -45,14 +45,14 @@ export namespace Message {
result: z.string(),
})
.openapi({
ref: "Message.ToolInvocation.ToolResult",
ref: "ToolResult",
})
export type ToolResult = z.infer<typeof ToolResult>
export const ToolInvocation = z
.discriminatedUnion("state", [ToolCall, ToolPartialCall, ToolResult])
.openapi({
ref: "Message.ToolInvocation",
ref: "ToolInvocation",
})
export type ToolInvocation = z.infer<typeof ToolInvocation>
@@ -62,7 +62,7 @@ export namespace Message {
text: z.string(),
})
.openapi({
ref: "Message.Part.Text",
ref: "TextPart",
})
export type TextPart = z.infer<typeof TextPart>
@@ -73,7 +73,7 @@ export namespace Message {
providerMetadata: z.record(z.any()).optional(),
})
.openapi({
ref: "Message.Part.Reasoning",
ref: "ReasoningPart",
})
export type ReasoningPart = z.infer<typeof ReasoningPart>
@@ -83,7 +83,7 @@ export namespace Message {
toolInvocation: ToolInvocation,
})
.openapi({
ref: "Message.Part.ToolInvocation",
ref: "ToolInvocationPart",
})
export type ToolInvocationPart = z.infer<typeof ToolInvocationPart>
@@ -96,7 +96,7 @@ export namespace Message {
providerMetadata: z.record(z.any()).optional(),
})
.openapi({
ref: "Message.Part.SourceUrl",
ref: "SourceUrlPart",
})
export type SourceUrlPart = z.infer<typeof SourceUrlPart>
@@ -108,7 +108,7 @@ export namespace Message {
url: z.string(),
})
.openapi({
ref: "Message.Part.File",
ref: "FilePart",
})
export type FilePart = z.infer<typeof FilePart>
@@ -117,11 +117,11 @@ export namespace Message {
type: z.literal("step-start"),
})
.openapi({
ref: "Message.Part.StepStart",
ref: "StepStartPart",
})
export type StepStartPart = z.infer<typeof StepStartPart>
export const Part = z
export const MessagePart = z
.discriminatedUnion("type", [
TextPart,
ReasoningPart,
@@ -131,15 +131,15 @@ export namespace Message {
StepStartPart,
])
.openapi({
ref: "Message.Part",
ref: "MessagePart",
})
export type Part = z.infer<typeof Part>
export type MessagePart = z.infer<typeof MessagePart>
export const Info = z
.object({
id: z.string(),
role: z.enum(["user", "assistant"]),
parts: z.array(Part),
parts: z.array(MessagePart),
metadata: z
.object({
time: z.object({
@@ -189,10 +189,10 @@ export namespace Message {
})
.optional(),
})
.openapi({ ref: "Message.Metadata" }),
.openapi({ ref: "MessageMetadata" }),
})
.openapi({
ref: "Message.Info",
ref: "Message",
})
export type Info = z.infer<typeof Info>
@@ -205,7 +205,11 @@ export namespace Message {
),
PartUpdated: Bus.event(
"message.part.updated",
z.object({ part: Part, sessionID: z.string(), messageID: z.string() }),
z.object({
part: MessagePart,
sessionID: z.string(),
messageID: z.string(),
}),
),
}
}
+11 -53
View File
@@ -1,5 +1,5 @@
import { App } from "../app/app"
import { Ripgrep } from "../external/ripgrep"
import { Ripgrep } from "../file/ripgrep"
import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
import path from "path"
@@ -27,55 +27,6 @@ export namespace SystemPrompt {
export async function environment() {
const app = App.info()
;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:`,
@@ -85,9 +36,16 @@ export namespace SystemPrompt {
` Platform: ${process.platform}`,
` Today's date: ${new Date().toDateString()}`,
`</env>`,
// `<project>`,
// ` ${app.git ? await tree() : ""}`,
// `</project>`,
`<project>`,
` ${
app.git
? await Ripgrep.tree({
cwd: app.path.cwd,
limit: 200,
})
: ""
}`,
`</project>`,
].join("\n"),
]
}
+4 -9
View File
@@ -1,4 +1,3 @@
import { App } from "../app/app"
import { Bus } from "../bus"
import { Installation } from "../installation"
import { Session } from "../session"
@@ -11,12 +10,6 @@ export namespace Share {
let queue: Promise<void> = Promise.resolve()
const pending = new Map<string, any>()
const state = App.state("share", async () => {
Bus.subscribe(Storage.Event.Write, async (payload) => {
await sync(payload.properties.key, payload.properties.content)
})
})
export async function sync(key: string, content: any) {
const [root, ...splits] = key.split("/")
if (root !== "session") return
@@ -52,8 +45,10 @@ export namespace Share {
})
}
export async function init() {
await state()
export function init() {
Bus.subscribe(Storage.Event.Write, async (payload) => {
await sync(payload.properties.key, payload.properties.content)
})
}
export const URL =
+2
View File
@@ -1,6 +1,7 @@
import { z } from "zod"
import { Tool } from "./tool"
import DESCRIPTION from "./bash.txt"
import { App } from "../app/app"
const MAX_OUTPUT_LENGTH = 30000
const BANNED_COMMANDS = [
@@ -49,6 +50,7 @@ export const BashTool = Tool.define({
const process = Bun.spawn({
cmd: ["bash", "-c", params.command],
cwd: App.info().path.cwd,
maxBuffer: MAX_OUTPUT_LENGTH,
signal: ctx.abort,
timeout: timeout,
+13 -4
View File
@@ -5,12 +5,14 @@
import { z } from "zod"
import * as path from "path"
import { Tool } from "./tool"
import { FileTimes } from "./util/file-times"
import { LSP } from "../lsp"
import { createTwoFilesPatch } from "diff"
import { Permission } from "../permission"
import DESCRIPTION from "./edit.txt"
import { App } from "../app/app"
import { File } from "../file"
import { Bus } from "../bus"
import { FileTime } from "../file/time"
export const EditTool = Tool.define({
id: "edit",
@@ -59,6 +61,9 @@ export const EditTool = Tool.define({
if (params.oldString === "") {
contentNew = params.newString
await Bun.write(filepath, params.newString)
await Bus.publish(File.Event.Edited, {
file: filepath,
})
return
}
@@ -67,7 +72,7 @@ export const EditTool = Tool.define({
if (!stats) throw new Error(`File ${filepath} not found`)
if (stats.isDirectory())
throw new Error(`Path is a directory, not a file: ${filepath}`)
await FileTimes.assert(ctx.sessionID, filepath)
await FileTime.assert(ctx.sessionID, filepath)
contentOld = await file.text()
contentNew = replace(
@@ -77,13 +82,17 @@ export const EditTool = Tool.define({
params.replaceAll,
)
await file.write(contentNew)
await Bus.publish(File.Event.Edited, {
file: filepath,
})
contentNew = await file.text()
})()
const diff = trimDiff(
createTwoFilesPatch(filepath, filepath, contentOld, contentNew),
)
FileTimes.read(ctx.sessionID, filepath)
FileTime.read(ctx.sessionID, filepath)
let output = ""
await LSP.touchFile(filepath, true)
@@ -473,7 +482,7 @@ export function replace(
if (oldString === newString) {
throw new Error("oldString and newString must be different")
}
for (const replacer of [
SimpleReplacer,
LineTrimmedReplacer,
+1 -1
View File
@@ -3,7 +3,7 @@ import path from "path"
import { Tool } from "./tool"
import { App } from "../app/app"
import DESCRIPTION from "./glob.txt"
import { Ripgrep } from "../external/ripgrep"
import { Ripgrep } from "../file/ripgrep"
export const GlobTool = Tool.define({
id: "glob",
+1 -1
View File
@@ -1,7 +1,7 @@
import { z } from "zod"
import { Tool } from "./tool"
import { App } from "../app/app"
import { Ripgrep } from "../external/ripgrep"
import { Ripgrep } from "../file/ripgrep"
import DESCRIPTION from "./grep.txt"
+3 -3
View File
@@ -2,7 +2,7 @@ import { z } from "zod"
import * as path from "path"
import * as fs from "fs/promises"
import { Tool } from "./tool"
import { FileTimes } from "./util/file-times"
import { FileTime } from "../file/time"
import DESCRIPTION from "./patch.txt"
const PatchParams = z.object({
@@ -244,7 +244,7 @@ export const PatchTool = Tool.define({
absPath = path.resolve(process.cwd(), absPath)
}
await FileTimes.assert(ctx.sessionID, absPath)
await FileTime.assert(ctx.sessionID, absPath)
try {
const stats = await fs.stat(absPath)
@@ -351,7 +351,7 @@ export const PatchTool = Tool.define({
totalAdditions += additions
totalRemovals += removals
FileTimes.read(ctx.sessionID, absPath)
FileTime.read(ctx.sessionID, absPath)
}
const result = `Patch applied successfully. ${changedFiles.length} files changed, ${totalAdditions} additions, ${totalRemovals} removals`
+3 -3
View File
@@ -3,7 +3,7 @@ import * as fs from "fs"
import * as path from "path"
import { Tool } from "./tool"
import { LSP } from "../lsp"
import { FileTimes } from "./util/file-times"
import { FileTime } from "../file/time"
import DESCRIPTION from "./read.txt"
import { App } from "../app/app"
@@ -89,8 +89,8 @@ export const ReadTool = Tool.define({
output += "\n</file>"
// just warms the lsp client
await LSP.touchFile(filePath, true)
FileTimes.read(ctx.sessionID, filePath)
await LSP.touchFile(filePath, false)
FileTime.read(ctx.sessionID, filePath)
return {
output,
+8 -3
View File
@@ -1,11 +1,13 @@
import { z } from "zod"
import * as path from "path"
import { Tool } from "./tool"
import { FileTimes } from "./util/file-times"
import { LSP } from "../lsp"
import { Permission } from "../permission"
import DESCRIPTION from "./write.txt"
import { App } from "../app/app"
import { Bus } from "../bus"
import { File } from "../file"
import { FileTime } from "../file/time"
export const WriteTool = Tool.define({
id: "write",
@@ -26,7 +28,7 @@ export const WriteTool = Tool.define({
const file = Bun.file(filepath)
const exists = await file.exists()
if (exists) await FileTimes.assert(ctx.sessionID, filepath)
if (exists) await FileTime.assert(ctx.sessionID, filepath)
await Permission.ask({
id: "write",
@@ -42,7 +44,10 @@ export const WriteTool = Tool.define({
})
await Bun.write(filepath, params.content)
FileTimes.read(ctx.sessionID, filepath)
await Bus.publish(File.Event.Edited, {
file: filepath,
})
FileTime.read(ctx.sessionID, filepath)
let output = ""
await LSP.touchFile(filepath, true)
-1
View File
@@ -8,4 +8,3 @@ export function lazy<T>(fn: () => T) {
return value as T
}
}
+4 -1
View File
@@ -19,7 +19,10 @@ export namespace Log {
await fs.mkdir(dir, { recursive: true })
cleanup(dir)
if (options.print) return
logpath = path.join(dir, new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log")
logpath = path.join(
dir,
new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log",
)
const logfile = Bun.file(logpath)
await fs.truncate(logpath).catch(() => {})
const writer = logfile.writer()
+14
View File
@@ -0,0 +1,14 @@
export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
let timeout: NodeJS.Timeout
return Promise.race([
promise.then((result) => {
clearTimeout(timeout)
return result
}),
new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
reject(new Error(`Operation timed out after ${ms}ms`))
}, ms)
}),
])
}
+1 -1
View File
@@ -6,4 +6,4 @@
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
export {}
+4 -4
View File
@@ -316,13 +316,13 @@ const testCases: TestCase[] = [
// WhitespaceNormalizedReplacer - test regex special characters that could cause errors
{
content: 'const pattern = "test[123]";',
find: 'test[123]',
replace: 'test[456]',
find: "test[123]",
replace: "test[456]",
},
{
content: 'const regex = "^start.*end$";',
find: '^start.*end$',
replace: '^begin.*finish$',
find: "^start.*end$",
replace: "^begin.*finish$",
},
// EscapeNormalizedReplacer - test single backslash vs double backslash
+25
View File
@@ -0,0 +1,25 @@
# TUI Agent Guidelines
## Build/Test Commands
- **Build**: `go build ./cmd/opencode` (builds main binary)
- **Test**: `go test ./...` (runs all tests)
- **Single test**: `go test ./internal/theme -run TestLoadThemesFromJSON` (specific test)
- **Release build**: Uses `.goreleaser.yml` configuration
## Code Style
- **Language**: Go 1.24+ with standard formatting (`gofmt`)
- **Imports**: Group standard, third-party, local packages with blank lines
- **Naming**: Go conventions - PascalCase exports, camelCase private, ALL_CAPS constants
- **Error handling**: Return errors explicitly, use `fmt.Errorf` for wrapping
- **Structs**: Define clear interfaces, embed when appropriate
- **Testing**: Use table-driven tests, `t.TempDir()` for file operations
## Architecture
- **TUI Framework**: Bubble Tea v2 with Lipgloss v2 for styling
- **Client**: Generated OpenAPI client communicates with TypeScript server
- **Components**: Reusable UI components in `internal/components/`
- **Themes**: JSON-based theming system with override hierarchy
- **State**: Centralized app state with message passing
+17 -17
View File
@@ -9,9 +9,10 @@ import (
"strings"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode-sdk-go/option"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/tui"
"github.com/sst/opencode/pkg/client"
)
var Version = "dev"
@@ -25,7 +26,7 @@ func main() {
url := os.Getenv("OPENCODE_SERVER")
appInfoStr := os.Getenv("OPENCODE_APP_INFO")
var appInfo client.AppInfo
var appInfo opencode.App
err := json.Unmarshal([]byte(appInfoStr), &appInfo)
if err != nil {
slog.Error("Failed to unmarshal app info", "error", err)
@@ -49,7 +50,12 @@ func main() {
logger := slog.New(slog.NewTextHandler(file, &slog.HandlerOptions{Level: slog.LevelDebug}))
slog.SetDefault(logger)
httpClient, err := client.NewClientWithResponses(url)
slog.Debug("TUI launched", "app", appInfo)
httpClient := opencode.NewClient(
option.WithBaseURL(url),
)
if err != nil {
slog.Error("Failed to create client", "error", err)
os.Exit(1)
@@ -71,21 +77,15 @@ func main() {
tea.WithMouseCellMotion(),
)
eventClient, err := client.NewClient(url)
if err != nil {
slog.Error("Failed to create event client", "error", err)
os.Exit(1)
}
evts, err := eventClient.Event(ctx)
if err != nil {
slog.Error("Failed to subscribe to events", "error", err)
os.Exit(1)
}
go func() {
for item := range evts {
program.Send(item)
stream := httpClient.Event.ListStreaming(ctx)
for stream.Next() {
evt := stream.Current().AsUnion()
program.Send(evt)
}
if err := stream.Err(); err != nil {
slog.Error("Error streaming events", "error", err)
program.Send(err)
}
}()
+7 -5
View File
@@ -14,8 +14,9 @@ require (
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
github.com/muesli/reflow v0.3.0
github.com/muesli/termenv v0.16.0
github.com/oapi-codegen/runtime v1.1.1
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
github.com/sst/opencode-sdk-go v0.1.0-alpha.7
github.com/tidwall/gjson v1.14.4
rsc.io/qr v0.2.0
)
@@ -23,7 +24,6 @@ require golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
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/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
@@ -35,7 +35,6 @@ require (
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/goccy/go-yaml v1.17.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/invopop/yaml v0.3.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
@@ -48,6 +47,9 @@ require (
github.com/sosodev/duration v1.3.1 // indirect
github.com/speakeasy-api/openapi-overlay v0.9.0 // indirect
github.com/spf13/cobra v1.9.1 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/tools v0.31.0 // indirect
@@ -68,10 +70,10 @@ require (
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-runewidth v0.0.16 // indirect
github.com/mattn/go-runewidth v0.0.16
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rivo/uniseg v0.4.7
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+12 -11
View File
@@ -4,15 +4,12 @@ github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.18.0 h1:6h53Q4hW83SuF+jcsp7CVhLsMozzvQvO8HBbKQW+gn4=
github.com/alecthomas/chroma/v2 v2.18.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/atombender/go-jsonschema v0.20.0 h1:AHg0LeI0HcjQ686ALwUNqVJjNRcSXpIR6U+wC2J0aFY=
github.com/atombender/go-jsonschema v0.20.0/go.mod h1:ZmbuR11v2+cMM0PdP6ySxtyZEGFBmhgF4xa4J6Hdls8=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
@@ -23,7 +20,6 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
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/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/v2 v2.0.0-beta.3 h1:5A2e3myxXMpCES+kjEWgGsaf9VgZXjZbLi5iMTH7j40=
@@ -96,8 +92,6 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
@@ -110,7 +104,6 @@ github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso=
github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -148,8 +141,6 @@ github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 h1:ykgG34472DWey7TSjd8vIfNykXgjOgYJZoQbKfEeY/Q=
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1/go.mod h1:N5+lY1tiTDV3V1BeHtOxeWXHoPVeApvsvjJqegfoaz8=
github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro=
github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
@@ -190,14 +181,24 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/sst/opencode-sdk-go v0.1.0-alpha.7 h1:trfzTMn9o/h2fxE4z+BtJPZvCTdVHjwgXnAH/rTAx0I=
github.com/sst/opencode-sdk-go v0.1.0-alpha.7/go.mod h1:uagorfAHZsVy6vf0xY6TlQraM4uCILdZ5tKKhl1oToM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
+97 -147
View File
@@ -11,34 +11,36 @@ import (
"log/slog"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/toast"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
"github.com/sst/opencode/pkg/client"
)
var RootPath string
var CwdPath string
type App struct {
Info client.AppInfo
Info opencode.App
Version string
StatePath string
Config *client.ConfigInfo
Client *client.ClientWithResponses
Config *opencode.Config
Client *opencode.Client
State *config.State
Provider *client.ProviderInfo
Model *client.ModelInfo
Session *client.SessionInfo
Messages []client.MessageInfo
Provider *opencode.Provider
Model *opencode.Model
Session *opencode.Session
Messages []opencode.Message
Commands commands.CommandRegistry
}
type SessionSelectedMsg = *client.SessionInfo
type SessionSelectedMsg = *opencode.Session
type ModelSelectedMsg struct {
Provider client.ProviderInfo
Model client.ModelInfo
Provider opencode.Provider
Model opencode.Model
}
type SessionClearedMsg struct{}
type CompactSessionMsg struct{}
@@ -50,31 +52,25 @@ type CompletionDialogTriggeredMsg struct {
InitialValue string
}
type OptimisticMessageAddedMsg struct {
Message client.MessageInfo
Message opencode.Message
}
func New(
ctx context.Context,
version string,
appInfo client.AppInfo,
httpClient *client.ClientWithResponses,
appInfo opencode.App,
httpClient *opencode.Client,
) (*App, error) {
RootPath = appInfo.Path.Root
CwdPath = appInfo.Path.Cwd
configResponse, err := httpClient.PostConfigGetWithResponse(ctx)
configInfo, err := httpClient.Config.Get(ctx)
if err != nil {
return nil, err
}
if configResponse.StatusCode() != 200 || configResponse.JSON200 == nil {
return nil, fmt.Errorf("failed to get config: %d", configResponse.StatusCode())
}
configInfo := configResponse.JSON200
if configInfo.Keybinds == nil {
leader := "ctrl+x"
keybinds := client.ConfigKeybinds{
Leader: &leader,
}
configInfo.Keybinds = &keybinds
if configInfo.Keybinds.Leader == "" {
configInfo.Keybinds.Leader = "ctrl+x"
}
appStatePath := filepath.Join(appInfo.Path.State, "tui")
@@ -84,16 +80,16 @@ func New(
config.SaveState(appStatePath, appState)
}
if configInfo.Theme != nil {
appState.Theme = *configInfo.Theme
if configInfo.Theme != "" {
appState.Theme = configInfo.Theme
}
if configInfo.Model != nil {
splits := strings.Split(*configInfo.Model, "/")
if configInfo.Model != "" {
splits := strings.Split(configInfo.Model, "/")
appState.Provider = splits[0]
appState.Model = strings.Join(splits[1:], "/")
}
// Load themes from all directories
if err := theme.LoadThemesFromDirectories(
appInfo.Path.Config,
appInfo.Path.Root,
@@ -103,6 +99,12 @@ func New(
}
if appState.Theme != "" {
if appState.Theme == "system" && styles.Terminal != nil {
theme.UpdateSystemTheme(
styles.Terminal.Background,
styles.Terminal.BackgroundIsDark,
)
}
theme.SetTheme(appState.Theme)
}
@@ -115,8 +117,8 @@ func New(
Config: configInfo,
State: appState,
Client: httpClient,
Session: &client.SessionInfo{},
Messages: []client.MessageInfo{},
Session: &opencode.Session{},
Messages: []opencode.Message{},
Commands: commands.LoadFromConfig(configInfo),
}
@@ -125,23 +127,19 @@ func New(
func (a *App) InitializeProvider() tea.Cmd {
return func() tea.Msg {
providersResponse, err := a.Client.PostProviderListWithResponse(context.Background())
providersResponse, err := a.Client.Config.Providers(context.Background())
if err != nil {
slog.Error("Failed to list providers", "error", err)
// TODO: notify user
return nil
}
if providersResponse != nil && providersResponse.StatusCode() != 200 {
slog.Error("failed to retrieve providers", "status", providersResponse.StatusCode(), "message", string(providersResponse.Body))
return nil
}
providers := []client.ProviderInfo{}
var defaultProvider *client.ProviderInfo
var defaultModel *client.ModelInfo
providers := providersResponse.Providers
var defaultProvider *opencode.Provider
var defaultModel *opencode.Model
var anthropic *client.ProviderInfo
for _, provider := range providersResponse.JSON200.Providers {
if provider.Id == "anthropic" {
var anthropic *opencode.Provider
for _, provider := range providers {
if provider.ID == "anthropic" {
anthropic = &provider
}
}
@@ -152,7 +150,7 @@ func (a *App) InitializeProvider() tea.Cmd {
defaultModel = getDefaultModel(providersResponse, *anthropic)
}
for _, provider := range providersResponse.JSON200.Providers {
for _, provider := range providers {
if defaultProvider == nil || defaultModel == nil {
defaultProvider = &provider
defaultModel = getDefaultModel(providersResponse, provider)
@@ -164,14 +162,14 @@ func (a *App) InitializeProvider() tea.Cmd {
return nil
}
var currentProvider *client.ProviderInfo
var currentModel *client.ModelInfo
var currentProvider *opencode.Provider
var currentModel *opencode.Model
for _, provider := range providers {
if provider.Id == a.State.Provider {
if provider.ID == a.State.Provider {
currentProvider = &provider
for _, model := range provider.Models {
if model.Id == a.State.Model {
if model.ID == a.State.Model {
currentModel = &model
}
}
@@ -182,7 +180,6 @@ func (a *App) InitializeProvider() tea.Cmd {
currentModel = defaultModel
}
// TODO: handle no provider or model setup, yet
return ModelSelectedMsg{
Provider: *currentProvider,
Model: *currentModel,
@@ -190,8 +187,8 @@ func (a *App) InitializeProvider() tea.Cmd {
}
}
func getDefaultModel(response *client.PostProviderListResponse, provider client.ProviderInfo) *client.ModelInfo {
if match, ok := response.JSON200.Default[provider.Id]; ok {
func getDefaultModel(response *opencode.ConfigProvidersResponse, provider opencode.Provider) *opencode.Model {
if match, ok := response.Default[provider.ID]; ok {
model := provider.Models[match]
return &model
} else {
@@ -215,7 +212,7 @@ func (a *App) IsBusy() bool {
}
lastMessage := a.Messages[len(a.Messages)-1]
return lastMessage.Metadata.Time.Completed == nil
return lastMessage.Metadata.Time.Completed == 0
}
func (a *App) SaveState() {
@@ -238,19 +235,14 @@ func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
go func() {
response, err := a.Client.PostSessionInitialize(ctx, client.PostSessionInitializeJSONRequestBody{
SessionID: a.Session.Id,
ProviderID: a.Provider.Id,
ModelID: a.Model.Id,
_, err := a.Client.Session.Init(ctx, a.Session.ID, opencode.SessionInitParams{
ProviderID: opencode.F(a.Provider.ID),
ModelID: opencode.F(a.Model.ID),
})
if err != nil {
slog.Error("Failed to initialize project", "error", err)
// status.Error(err.Error())
}
if response != nil && response.StatusCode != 200 {
slog.Error("Failed to initialize project", "error", response.StatusCode)
// status.Error(fmt.Sprintf("failed to initialize project: %d", response.StatusCode))
}
}()
return tea.Batch(cmds...)
@@ -258,48 +250,37 @@ func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
func (a *App) CompactSession(ctx context.Context) tea.Cmd {
go func() {
response, err := a.Client.PostSessionSummarizeWithResponse(ctx, client.PostSessionSummarizeJSONRequestBody{
SessionID: a.Session.Id,
ProviderID: a.Provider.Id,
ModelID: a.Model.Id,
_, err := a.Client.Session.Summarize(ctx, a.Session.ID, opencode.SessionSummarizeParams{
ProviderID: opencode.F(a.Provider.ID),
ModelID: opencode.F(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)
_, err := a.Client.App.Init(ctx)
if err != nil {
slog.Error("Failed to mark project as initialized", "error", err)
return err
}
if response != nil && response.StatusCode != 200 {
return fmt.Errorf("failed to initialize project: %d", response.StatusCode)
}
return nil
}
func (a *App) CreateSession(ctx context.Context) (*client.SessionInfo, error) {
resp, err := a.Client.PostSessionCreateWithResponse(ctx)
func (a *App) CreateSession(ctx context.Context) (*opencode.Session, error) {
session, err := a.Client.Session.New(ctx)
if err != nil {
return nil, err
}
if resp != nil && resp.StatusCode() != 200 {
return nil, fmt.Errorf("failed to create session: %d", resp.StatusCode())
}
session := resp.JSON200
return session, nil
}
func (a *App) SendChatMessage(ctx context.Context, text string, attachments []Attachment) tea.Cmd {
var cmds []tea.Cmd
if a.Session.Id == "" {
if a.Session.ID == "" {
session, err := a.CreateSession(ctx)
if err != nil {
return toast.NewErrorToast(err.Error())
@@ -308,26 +289,18 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
}
part := client.MessagePart{}
part.FromMessagePartText(client.MessagePartText{
Type: "text",
Text: text,
})
parts := []client.MessagePart{part}
optimisticMessage := client.MessageInfo{
Id: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()),
Role: client.User,
Parts: parts,
Metadata: client.MessageMetadata{
SessionID: a.Session.Id,
Time: struct {
Completed *float32 `json:"completed,omitempty"`
Created float32 `json:"created"`
}{
Created: float32(time.Now().Unix()),
optimisticMessage := opencode.Message{
ID: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()),
Role: opencode.MessageRoleUser,
Parts: []opencode.MessagePart{{
Type: opencode.MessagePartTypeText,
Text: text,
}},
Metadata: opencode.MessageMetadata{
SessionID: a.Session.ID,
Time: opencode.MessageMetadataTime{
Created: float64(time.Now().Unix()),
},
Tool: make(map[string]client.MessageMetadata_Tool_AdditionalProperties),
},
}
@@ -335,22 +308,21 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
cmds = append(cmds, util.CmdHandler(OptimisticMessageAddedMsg{Message: optimisticMessage}))
cmds = append(cmds, func() tea.Msg {
response, err := a.Client.PostSessionChat(ctx, client.PostSessionChatJSONRequestBody{
SessionID: a.Session.Id,
Parts: parts,
ProviderID: a.Provider.Id,
ModelID: a.Model.Id,
_, err := a.Client.Session.Chat(ctx, a.Session.ID, opencode.SessionChatParams{
Parts: opencode.F([]opencode.MessagePartUnionParam{
opencode.TextPartParam{
Type: opencode.F(opencode.TextPartTypeText),
Text: opencode.F(text),
},
}),
ProviderID: opencode.F(a.Provider.ID),
ModelID: opencode.F(a.Model.ID),
})
if err != nil {
errormsg := fmt.Sprintf("failed to send message: %v", err)
slog.Error(errormsg)
return toast.NewErrorToast(errormsg)()
}
if response != nil && response.StatusCode != 200 {
errormsg := fmt.Sprintf("failed to send message: %d", response.StatusCode)
slog.Error(errormsg)
return toast.NewErrorToast(errormsg)()
}
return nil
})
@@ -360,83 +332,61 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
}
func (a *App) Cancel(ctx context.Context, sessionID string) error {
response, err := a.Client.PostSessionAbort(ctx, client.PostSessionAbortJSONRequestBody{
SessionID: sessionID,
})
_, err := a.Client.Session.Abort(ctx, sessionID)
if err != nil {
slog.Error("Failed to cancel session", "error", err)
// 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))
return fmt.Errorf("failed to cancel session: %d", response.StatusCode)
}
return nil
}
func (a *App) ListSessions(ctx context.Context) ([]client.SessionInfo, error) {
resp, err := a.Client.PostSessionListWithResponse(ctx)
func (a *App) ListSessions(ctx context.Context) ([]opencode.Session, error) {
response, err := a.Client.Session.List(ctx)
if err != nil {
return nil, err
}
if resp.StatusCode() != 200 {
return nil, fmt.Errorf("failed to list sessions: %d", resp.StatusCode())
if response == nil {
return []opencode.Session{}, nil
}
if resp.JSON200 == nil {
return []client.SessionInfo{}, nil
}
sessions := *resp.JSON200
sessions := *response
sort.Slice(sessions, func(i, j int) bool {
return sessions[i].Time.Created-sessions[j].Time.Created > 0
})
return sessions, nil
}
func (a *App) DeleteSession(ctx context.Context, sessionID string) error {
resp, err := a.Client.PostSessionDeleteWithResponse(ctx, client.PostSessionDeleteJSONRequestBody{
SessionID: sessionID,
})
_, err := a.Client.Session.Delete(ctx, sessionID)
if err != nil {
slog.Error("Failed to delete session", "error", err)
return err
}
if resp.StatusCode() != 200 {
return fmt.Errorf("failed to delete session: %d", resp.StatusCode())
}
return nil
}
func (a *App) ListMessages(ctx context.Context, sessionId string) ([]client.MessageInfo, error) {
resp, err := a.Client.PostSessionMessagesWithResponse(ctx, client.PostSessionMessagesJSONRequestBody{SessionID: sessionId})
func (a *App) ListMessages(ctx context.Context, sessionId string) ([]opencode.Message, error) {
response, err := a.Client.Session.Messages(ctx, sessionId)
if err != nil {
return nil, err
}
if resp.StatusCode() != 200 {
return nil, fmt.Errorf("failed to list messages: %d", resp.StatusCode())
if response == nil {
return []opencode.Message{}, nil
}
if resp.JSON200 == nil {
return []client.MessageInfo{}, nil
}
messages := *resp.JSON200
messages := *response
return messages, nil
}
func (a *App) ListProviders(ctx context.Context) ([]client.ProviderInfo, error) {
resp, err := a.Client.PostProviderListWithResponse(ctx)
func (a *App) ListProviders(ctx context.Context) ([]opencode.Provider, error) {
response, err := a.Client.Config.Providers(ctx)
if err != nil {
return nil, err
}
if resp.StatusCode() != 200 {
return nil, fmt.Errorf("failed to list sessions: %d", resp.StatusCode())
}
if resp.JSON200 == nil {
return []client.ProviderInfo{}, nil
if response == nil {
return []opencode.Provider{}, nil
}
providers := *resp.JSON200
providers := *response
return providers.Providers, nil
}
+4 -4
View File
@@ -6,7 +6,7 @@ import (
"strings"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode/pkg/client"
"github.com/sst/opencode-sdk-go"
)
type ExecuteCommandMsg Command
@@ -123,7 +123,7 @@ func parseBindings(bindings ...string) []Keybinding {
return parsedBindings
}
func LoadFromConfig(config *client.ConfigInfo) CommandRegistry {
func LoadFromConfig(config *opencode.Config) CommandRegistry {
defaults := []Command{
{
Name: AppHelpCommand,
@@ -269,10 +269,10 @@ func LoadFromConfig(config *client.ConfigInfo) CommandRegistry {
}
registry := make(CommandRegistry)
keybinds := map[string]string{}
marshalled, _ := json.Marshal(*config.Keybinds)
marshalled, _ := json.Marshal(config.Keybinds)
json.Unmarshal(marshalled, &keybinds)
for _, command := range defaults {
if keybind, ok := keybinds[string(command.Name)]; ok {
if keybind, ok := keybinds[string(command.Name)]; ok && keybind != "" {
command.Keybindings = parseBindings(keybind)
}
registry[command.Name] = command
@@ -9,6 +9,7 @@ import (
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
@@ -37,7 +38,7 @@ func (c *CommandCompletionProvider) GetEmptyMessage() string {
func getCommandCompletionItem(cmd commands.Command, space int, t theme.Theme) dialog.CompletionItemI {
spacer := strings.Repeat(" ", space)
title := " /" + cmd.Trigger + lipgloss.NewStyle().Foreground(t.TextMuted()).Render(spacer+cmd.Description)
title := " /" + cmd.Trigger + styles.NewStyle().Foreground(t.TextMuted()).Render(spacer+cmd.Description)
value := string(cmd.Name)
return dialog.NewCompletionItem(dialog.CompletionItem{
Title: title,
@@ -3,9 +3,9 @@ package completions
import (
"context"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/pkg/client"
)
type filesAndFoldersContextGroup struct {
@@ -29,17 +29,14 @@ func (cg *filesAndFoldersContextGroup) GetEmptyMessage() string {
}
func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) {
response, err := cg.app.Client.PostFileSearchWithResponse(context.Background(), client.PostFileSearchJSONRequestBody{
Query: query,
})
files, err := cg.app.Client.File.Search(
context.Background(),
opencode.FileSearchParams{Query: opencode.F(query)},
)
if err != nil {
return []string{}, err
}
if response.JSON200 == nil {
return []string{}, nil
}
return *response.JSON200, nil
return *files, nil
}
func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
+59 -30
View File
@@ -22,10 +22,13 @@ import (
type EditorComponent interface {
tea.Model
tea.ViewModel
layout.Sizeable
SetSize(width, height int) tea.Cmd
Content() string
Lines() int
Value() string
Focused() bool
Focus() (tea.Model, tea.Cmd)
Blur()
Submit() (tea.Model, tea.Cmd)
Clear() (tea.Model, tea.Cmd)
Paste() (tea.Model, tea.Cmd)
@@ -48,7 +51,7 @@ type editorComponent struct {
}
func (m *editorComponent) Init() tea.Cmd {
return tea.Batch(textarea.Blink, m.spinner.Tick, tea.EnableReportFocus)
return tea.Batch(m.textarea.Focus(), m.spinner.Tick, tea.EnableReportFocus)
}
func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -69,7 +72,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case dialog.ThemeSelectedMsg:
m.textarea = createTextArea(&m.textarea)
m.spinner = createSpinner()
return m, tea.Batch(m.spinner.Tick, textarea.Blink)
return m, tea.Batch(m.spinner.Tick, m.textarea.Focus())
case dialog.CompletionSelectedMsg:
if msg.IsCommand {
commandName := strings.TrimPrefix(msg.CompletionValue, "/")
@@ -80,7 +83,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
} else {
existingValue := m.textarea.Value()
// Replace the current token (after last space)
lastSpaceIndex := strings.LastIndex(existingValue, " ")
if lastSpaceIndex == -1 {
@@ -104,12 +107,11 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m *editorComponent) Content() string {
t := theme.CurrentTheme()
base := styles.BaseStyle().Background(t.Background()).Render
muted := styles.Muted().Background(t.Background()).Render
promptStyle := lipgloss.NewStyle().
base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
promptStyle := styles.NewStyle().Foreground(t.Primary()).
Padding(0, 0, 0, 1).
Bold(true).
Foreground(t.Primary())
Bold(true)
prompt := promptStyle.Render(">")
textarea := lipgloss.JoinHorizontal(
@@ -117,11 +119,16 @@ func (m *editorComponent) Content() string {
prompt,
m.textarea.View(),
)
textarea = styles.BaseStyle().
textarea = styles.NewStyle().
Background(t.BackgroundElement()).
Width(m.width).
PaddingTop(1).
PaddingBottom(1).
Background(t.BackgroundElement()).
BorderStyle(lipgloss.ThickBorder()).
BorderForeground(t.Border()).
BorderBackground(t.Background()).
BorderLeft(true).
BorderRight(true).
Render(textarea)
hint := base(m.getSubmitKeyText()) + muted(" send ")
@@ -140,10 +147,10 @@ func (m *editorComponent) Content() string {
}
space := m.width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
spacer := lipgloss.NewStyle().Background(t.Background()).Width(space).Render("")
spacer := styles.NewStyle().Background(t.Background()).Width(space).Render("")
info := hint + spacer + model
info = styles.Padded().Background(t.Background()).Render(info)
info = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(info)
content := strings.Join([]string{"", textarea, info}, "\n")
return content
@@ -151,11 +158,31 @@ func (m *editorComponent) Content() string {
func (m *editorComponent) View() string {
if m.Lines() > 1 {
return ""
t := theme.CurrentTheme()
return lipgloss.Place(
m.width,
m.height,
lipgloss.Center,
lipgloss.Center,
"",
styles.WhitespaceStyle(t.Background()),
)
}
return m.Content()
}
func (m *editorComponent) Focused() bool {
return m.textarea.Focused()
}
func (m *editorComponent) Focus() (tea.Model, tea.Cmd) {
return m, m.textarea.Focus()
}
func (m *editorComponent) Blur() {
m.textarea.Blur()
}
func (m *editorComponent) GetSize() (width, height int) {
return m.width, m.height
}
@@ -163,8 +190,6 @@ func (m *editorComponent) GetSize() (width, height int) {
func (m *editorComponent) SetSize(width, height int) tea.Cmd {
m.width = width
m.height = height
m.textarea.SetWidth(width - 5) // account for the prompt and padding right
// m.textarea.SetHeight(height - 4)
return nil
}
@@ -297,38 +322,42 @@ func createTextArea(existing *textarea.Model) textarea.Model {
ta := textarea.New()
ta.Styles.Blurred.Base = lipgloss.NewStyle().Background(bgColor).Foreground(textColor)
ta.Styles.Blurred.CursorLine = lipgloss.NewStyle().Background(bgColor)
ta.Styles.Blurred.Placeholder = lipgloss.NewStyle().Background(bgColor).Foreground(textMutedColor)
ta.Styles.Blurred.Text = lipgloss.NewStyle().Background(bgColor).Foreground(textColor)
ta.Styles.Focused.Base = lipgloss.NewStyle().Background(bgColor).Foreground(textColor)
ta.Styles.Focused.CursorLine = lipgloss.NewStyle().Background(bgColor)
ta.Styles.Focused.Placeholder = lipgloss.NewStyle().Background(bgColor).Foreground(textMutedColor)
ta.Styles.Focused.Text = lipgloss.NewStyle().Background(bgColor).Foreground(textColor)
ta.Styles.Blurred.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
ta.Styles.Blurred.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
ta.Styles.Blurred.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss()
ta.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
ta.Styles.Focused.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
ta.Styles.Focused.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
ta.Styles.Focused.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss()
ta.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
ta.Styles.Cursor.Color = t.Primary()
ta.Prompt = " "
ta.ShowLineNumbers = false
ta.CharLimit = -1
ta.SetWidth(layout.Current.Container.Width - 6)
if existing != nil {
ta.SetValue(existing.Value())
ta.SetWidth(existing.Width())
// ta.SetWidth(existing.Width())
ta.SetHeight(existing.Height())
}
ta.Focus()
// ta.Focus()
return ta
}
func createSpinner() spinner.Model {
t := theme.CurrentTheme()
return spinner.New(
spinner.WithSpinner(spinner.Ellipsis),
spinner.WithStyle(
styles.
Muted().
Background(theme.CurrentTheme().Background()).
Width(3)),
styles.NewStyle().
Background(t.Background()).
Foreground(t.TextMuted()).
Width(3).
Lipgloss(),
),
)
}
+421 -385
View File
@@ -12,18 +12,19 @@ import (
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/lipgloss/v2/compat"
"github.com/charmbracelet/x/ansi"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/diff"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/pkg/client"
"github.com/tidwall/gjson"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
func toMarkdown(content string, width int, backgroundColor compat.AdaptiveColor) string {
r := styles.GetMarkdownRenderer(width, backgroundColor)
r := styles.GetMarkdownRenderer(width-7, backgroundColor)
content = strings.ReplaceAll(content, app.RootPath+"/", "")
rendered, _ := r.Render(content)
lines := strings.Split(rendered, "\n")
@@ -49,9 +50,8 @@ func toMarkdown(content string, width int, backgroundColor compat.AdaptiveColor)
}
type blockRenderer struct {
align *lipgloss.Position
border bool
borderColor *compat.AdaptiveColor
fullWidth bool
paddingTop int
paddingBottom int
paddingLeft int
@@ -62,15 +62,9 @@ type blockRenderer struct {
type renderingOption func(*blockRenderer)
func WithFullWidth() renderingOption {
func WithNoBorder() renderingOption {
return func(c *blockRenderer) {
c.fullWidth = true
}
}
func WithAlign(align lipgloss.Position) renderingOption {
return func(c *blockRenderer) {
c.align = &align
c.border = false
}
}
@@ -92,6 +86,15 @@ func WithMarginBottom(padding int) renderingOption {
}
}
func WithPadding(padding int) renderingOption {
return func(c *blockRenderer) {
c.paddingTop = padding
c.paddingBottom = padding
c.paddingLeft = padding
c.paddingRight = padding
}
}
func WithPaddingLeft(padding int) renderingOption {
return func(c *blockRenderer) {
c.paddingLeft = padding
@@ -116,10 +119,15 @@ func WithPaddingBottom(padding int) renderingOption {
}
}
func renderContentBlock(content string, options ...renderingOption) string {
func renderContentBlock(
content string,
width int,
align lipgloss.Position,
options ...renderingOption,
) string {
t := theme.CurrentTheme()
renderer := &blockRenderer{
fullWidth: false,
border: true,
paddingTop: 1,
paddingBottom: 1,
paddingLeft: 2,
@@ -129,63 +137,44 @@ func renderContentBlock(content string, options ...renderingOption) string {
option(renderer)
}
style := styles.BaseStyle().
// MarginTop(renderer.marginTop).
// MarginBottom(renderer.marginBottom).
PaddingTop(renderer.paddingTop).
PaddingBottom(renderer.paddingBottom).
PaddingLeft(renderer.paddingLeft).
PaddingRight(renderer.paddingRight).
Background(t.BackgroundPanel()).
Foreground(t.TextMuted()).
BorderStyle(lipgloss.ThickBorder())
align := lipgloss.Left
if renderer.align != nil {
align = *renderer.align
}
borderColor := t.BackgroundPanel()
if renderer.borderColor != nil {
borderColor = *renderer.borderColor
}
switch align {
case lipgloss.Left:
style := styles.NewStyle().
Foreground(t.TextMuted()).
Background(t.BackgroundPanel()).
Width(width).
PaddingTop(renderer.paddingTop).
PaddingBottom(renderer.paddingBottom).
PaddingLeft(renderer.paddingLeft).
PaddingRight(renderer.paddingRight).
AlignHorizontal(lipgloss.Left)
if renderer.border {
style = style.
BorderStyle(lipgloss.ThickBorder()).
BorderLeft(true).
BorderRight(true).
AlignHorizontal(align).
BorderLeftForeground(borderColor).
BorderLeftBackground(t.Background()).
BorderRightForeground(t.BackgroundPanel()).
BorderRightBackground(t.Background())
case lipgloss.Right:
style = style.
BorderRight(true).
BorderLeft(true).
AlignHorizontal(align).
BorderRightForeground(borderColor).
BorderRightBackground(t.Background()).
BorderLeftForeground(t.BackgroundPanel()).
BorderLeftBackground(t.Background())
}
if renderer.fullWidth {
style = style.Width(layout.Current.Container.Width)
}
content = style.Render(content)
content = lipgloss.PlaceHorizontal(
layout.Current.Container.Width,
align,
width,
lipgloss.Left,
content,
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
styles.WhitespaceStyle(t.Background()),
)
content = lipgloss.PlaceHorizontal(
layout.Current.Viewport.Width,
lipgloss.Center,
align,
content,
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
styles.WhitespaceStyle(t.Background()),
)
if renderer.marginTop > 0 {
for range renderer.marginTop {
@@ -197,24 +186,19 @@ func renderContentBlock(content string, options ...renderingOption) string {
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 {
func renderText(
message opencode.Message,
text string,
author string,
showToolDetails bool,
width int,
align lipgloss.Position,
toolCalls ...opencode.ToolInvocationPart,
) string {
t := theme.CurrentTheme()
width := layout.Current.Container.Width
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] {
@@ -223,93 +207,280 @@ func renderText(message client.MessageInfo, text string, author string) string {
}
info := fmt.Sprintf("%s (%s)", author, timestamp)
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 - 2
messageStyle := styles.NewStyle().
Background(t.BackgroundPanel()).
Foreground(t.Text())
if message.Role == opencode.MessageRoleUser {
messageStyle = messageStyle.Width(width - 6)
}
if message.Role == client.User {
text = strings.ReplaceAll(text, "<", "\\<")
text = strings.ReplaceAll(text, ">", "\\>")
content := messageStyle.Render(text)
if message.Role == opencode.MessageRoleAssistant {
content = toMarkdown(text, width, t.BackgroundPanel())
}
content := toMarkdown(text, markdownWidth, t.BackgroundPanel())
if !showToolDetails && toolCalls != nil && len(toolCalls) > 0 {
content = content + "\n\n"
for _, toolCall := range toolCalls {
title := renderToolTitle(toolCall, message.Metadata, width)
metadata := opencode.MessageMetadataTool{}
if _, ok := message.Metadata.Tool[toolCall.ToolInvocation.ToolCallID]; ok {
metadata = message.Metadata.Tool[toolCall.ToolInvocation.ToolCallID]
}
style := styles.NewStyle()
if _, ok := metadata.ExtraFields["error"]; ok {
style = style.Foreground(t.Error())
}
title = style.Render(title)
title = "∟ " + title + "\n"
content = content + title
}
}
content = strings.Join([]string{content, info}, "\n")
switch message.Role {
case client.User:
return renderContentBlock(content,
WithAlign(lipgloss.Right),
case opencode.MessageRoleUser:
return renderContentBlock(
content,
width,
align,
WithBorderColor(t.Secondary()),
)
case client.Assistant:
return renderContentBlock(content,
WithAlign(lipgloss.Left),
case opencode.MessageRoleAssistant:
return renderContentBlock(
content,
width,
align,
WithBorderColor(t.Accent()),
)
}
return ""
}
func renderToolInvocation(
toolCall client.MessageToolInvocationToolCall,
result *string,
metadata client.MessageMetadata_Tool_AdditionalProperties,
showDetails bool,
isLast bool,
contentOnly bool,
func renderToolDetails(
toolCall opencode.ToolInvocationPart,
messageMetadata opencode.MessageMetadata,
width int,
align lipgloss.Position,
) string {
ignoredTools := []string{"todoread"}
if slices.Contains(ignoredTools, toolCall.ToolName) {
if slices.Contains(ignoredTools, toolCall.ToolInvocation.ToolName) {
return ""
}
outerWidth := layout.Current.Container.Width
innerWidth := outerWidth - 6
paddingTop := 0
paddingBottom := 0
if showDetails {
paddingTop = 1
if result == nil || *result == "" {
paddingBottom = 1
toolCallID := toolCall.ToolInvocation.ToolCallID
metadata := opencode.MessageMetadataTool{}
if _, ok := messageMetadata.Tool[toolCallID]; ok {
metadata = messageMetadata.Tool[toolCallID]
}
var result *string
if toolCall.ToolInvocation.Result != "" {
result = &toolCall.ToolInvocation.Result
}
if toolCall.ToolInvocation.State == "partial-call" {
title := renderToolTitle(toolCall, messageMetadata, width)
return renderContentBlock(title, width, align)
}
toolArgsMap := make(map[string]any)
if toolCall.ToolInvocation.Args != nil {
value := toolCall.ToolInvocation.Args
if m, ok := value.(map[string]any); ok {
toolArgsMap = m
keys := make([]string, 0, len(toolArgsMap))
for key := range toolArgsMap {
keys = append(keys, key)
}
slices.Sort(keys)
}
}
body := ""
finished := result != nil && *result != ""
t := theme.CurrentTheme()
style := styles.Muted().
Width(outerWidth).
Background(t.BackgroundPanel()).
PaddingTop(paddingTop).
PaddingBottom(paddingBottom).
PaddingLeft(2).
PaddingRight(2).
BorderLeft(true).
BorderRight(true).
BorderBackground(t.Background()).
BorderForeground(t.BackgroundPanel()).
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.BackgroundPanel())
return renderContentBlock(style.Render(title),
WithAlign(lipgloss.Left),
WithBorderColor(t.Accent()),
WithPaddingTop(0),
WithPaddingBottom(1),
)
switch toolCall.ToolInvocation.ToolName {
case "read":
preview := metadata.ExtraFields["preview"]
if preview != nil && toolArgsMap["filePath"] != nil {
filename := toolArgsMap["filePath"].(string)
body = preview.(string)
body = renderFile(filename, body, width, WithTruncate(6))
}
case "edit":
if filename, ok := toolArgsMap["filePath"].(string); ok {
diffField := metadata.ExtraFields["diff"]
if diffField != nil {
patch := diffField.(string)
var formattedDiff string
formattedDiff, _ = diff.FormatUnifiedDiff(
filename,
patch,
diff.WithWidth(width-2),
)
formattedDiff = strings.TrimSpace(formattedDiff)
formattedDiff = styles.NewStyle().
BorderStyle(lipgloss.ThickBorder()).
BorderBackground(t.Background()).
BorderForeground(t.BackgroundPanel()).
BorderLeft(true).
BorderRight(true).
Render(formattedDiff)
style = style.Foreground(t.TextMuted())
return style.Render(title)
body = strings.TrimSpace(formattedDiff)
body = renderContentBlock(
body,
width,
align,
WithNoBorder(),
WithPadding(0),
)
if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
body += "\n" + renderContentBlock(diagnostics, width, align)
}
title := renderToolTitle(toolCall, messageMetadata, width)
title = renderContentBlock(title, width, align)
content := title + "\n" + body
return content
}
}
case "write":
if filename, ok := toolArgsMap["filePath"].(string); ok {
if content, ok := toolArgsMap["content"].(string); ok {
body = renderFile(filename, content, width)
if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
body += "\n\n" + diagnostics
}
}
}
case "bash":
stdout := metadata.ExtraFields["stdout"]
if stdout != nil {
command := toolArgsMap["command"].(string)
body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
body = toMarkdown(body, width, t.BackgroundPanel())
}
case "webfetch":
if format, ok := toolArgsMap["format"].(string); ok && result != nil {
body = *result
body = truncateHeight(body, 10)
if format == "html" || format == "markdown" {
body = toMarkdown(body, width, t.BackgroundPanel())
}
}
case "todowrite":
todos := metadata.JSON.ExtraFields["todos"]
if !todos.IsNull() && finished {
strTodos := todos.Raw()
todos := gjson.Parse(strTodos)
for _, todo := range todos.Array() {
content := todo.Get("content").String()
switch todo.Get("status").String() {
case "completed":
body += fmt.Sprintf("- [x] %s\n", content)
// case "in-progress":
// body += fmt.Sprintf("- [ ] %s\n", content)
default:
body += fmt.Sprintf("- [ ] %s\n", content)
}
}
body = toMarkdown(body, width, t.BackgroundPanel())
}
case "task":
summary := metadata.JSON.ExtraFields["summary"]
if !summary.IsNull() {
strValue := summary.Raw()
toolcalls := gjson.Parse(strValue).Array()
steps := []string{}
for _, toolcall := range toolcalls {
call := toolcall.Value().(map[string]any)
if toolInvocation, ok := call["toolInvocation"].(map[string]any); ok {
data, _ := json.Marshal(toolInvocation)
var toolCall opencode.ToolInvocationPart
_ = json.Unmarshal(data, &toolCall)
if metadata, ok := call["metadata"].(map[string]any); ok {
data, _ = json.Marshal(metadata)
var toolMetadata opencode.MessageMetadataTool
_ = json.Unmarshal(data, &toolMetadata)
step := renderToolTitle(toolCall, messageMetadata, width)
step = "∟ " + step
steps = append(steps, step)
}
}
}
body = strings.Join(steps, "\n")
}
default:
if result == nil {
empty := ""
result = &empty
}
body = *result
body = truncateHeight(body, 10)
}
error := ""
if err, ok := metadata.ExtraFields["error"].(bool); ok && err {
if message, ok := metadata.ExtraFields["message"].(string); ok {
error = message
}
}
if error != "" {
body = styles.NewStyle().
Foreground(t.Error()).
Background(t.BackgroundPanel()).
Render(error)
}
if body == "" && error == "" && result != nil {
body = *result
body = truncateHeight(body, 10)
}
title := renderToolTitle(toolCall, messageMetadata, width)
content := title + "\n\n" + body
return renderContentBlock(content, width, align)
}
func renderToolName(name string) string {
switch name {
case "webfetch":
return "Fetch"
case "todowrite", "todoread":
return "Plan"
default:
normalizedName := name
if strings.HasPrefix(name, "opencode_") {
normalizedName = strings.TrimPrefix(name, "opencode_")
}
return cases.Title(language.Und).String(normalizedName)
}
}
func renderToolTitle(
toolCall opencode.ToolInvocationPart,
messageMetadata opencode.MessageMetadata,
width int,
) string {
// TODO: handle truncate to width
if toolCall.ToolInvocation.State == "partial-call" {
return renderToolAction(toolCall.ToolInvocation.ToolName)
}
toolArgs := ""
toolArgsMap := make(map[string]any)
if toolCall.Args != nil {
value := *toolCall.Args
if toolCall.ToolInvocation.Args != nil {
value := toolCall.ToolInvocation.Args
if m, ok := value.(map[string]any); ok {
toolArgsMap = m
@@ -327,277 +498,29 @@ func renderToolInvocation(
}
}
body := ""
error := ""
finished := result != nil && *result != ""
if e, ok := metadata.Get("error"); ok && e.(bool) == true {
if m, ok := metadata.Get("message"); ok {
style = style.BorderLeftForeground(t.Error())
error = styles.BaseStyle().
Background(t.BackgroundPanel()).
Foreground(t.Error()).
Render(m.(string))
error = renderContentBlock(
error,
WithFullWidth(),
WithBorderColor(t.Error()),
WithMarginBottom(1),
)
}
}
title := ""
switch toolCall.ToolName {
title := renderToolName(toolCall.ToolInvocation.ToolName)
switch toolCall.ToolInvocation.ToolName {
case "read":
toolArgs = renderArgs(&toolArgsMap, "filePath")
title = fmt.Sprintf("READ %s", toolArgs)
if preview, ok := metadata.Get("preview"); ok && toolArgsMap["filePath"] != nil {
filename := toolArgsMap["filePath"].(string)
body = preview.(string)
body = renderFile(filename, body, WithTruncate(6))
}
case "edit":
title = fmt.Sprintf("%s %s", title, toolArgs)
case "edit", "write":
if filename, ok := toolArgsMap["filePath"].(string); ok {
title = fmt.Sprintf("EDIT %s", relative(filename))
if d, ok := metadata.Get("diff"); ok {
patch := d.(string)
var formattedDiff string
if layout.Current.Viewport.Width < 80 {
formattedDiff, _ = diff.FormatUnifiedDiff(
filename,
patch,
diff.WithWidth(layout.Current.Container.Width-2),
)
} else {
diffWidth := min(layout.Current.Viewport.Width-2, 120)
formattedDiff, _ = diff.FormatDiff(filename, patch, diff.WithTotalWidth(diffWidth))
}
formattedDiff = strings.TrimSpace(formattedDiff)
formattedDiff = lipgloss.NewStyle().
BorderStyle(lipgloss.ThickBorder()).
BorderBackground(t.Background()).
BorderForeground(t.BackgroundPanel()).
BorderLeft(true).
BorderRight(true).
Render(formattedDiff)
if showDetails {
style = style.Width(lipgloss.Width(formattedDiff))
title += "\n"
}
body = strings.TrimSpace(formattedDiff)
body = lipgloss.Place(
layout.Current.Viewport.Width,
lipgloss.Height(body)+1,
lipgloss.Center,
lipgloss.Top,
body,
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
)
}
title = fmt.Sprintf("%s %s", title, relative(filename))
}
case "write":
if filename, ok := toolArgsMap["filePath"].(string); ok {
title = fmt.Sprintf("WRITE %s", relative(filename))
if content, ok := toolArgsMap["content"].(string); ok {
body = renderFile(filename, content)
}
}
case "bash":
case "bash", "task":
if description, ok := toolArgsMap["description"].(string); ok {
title = fmt.Sprintf("SHELL %s", description)
}
if stdout, ok := metadata.Get("stdout"); ok {
command := toolArgsMap["command"].(string)
stdout := stdout.(string)
body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
body = toMarkdown(body, innerWidth, t.BackgroundPanel())
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
title = fmt.Sprintf("%s %s", title, description)
}
case "webfetch":
toolArgs = renderArgs(&toolArgsMap, "url")
title = fmt.Sprintf("FETCH %s", toolArgs)
if format, ok := toolArgsMap["format"].(string); ok {
if result != nil {
body = *result
body = truncateHeight(body, 10)
if format == "html" || format == "markdown" {
body = toMarkdown(body, innerWidth, t.BackgroundPanel())
}
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
}
}
case "todowrite":
title = fmt.Sprintf("PLAN")
if to, ok := metadata.Get("todos"); ok && finished {
todos := to.([]any)
for _, todo := range todos {
t := todo.(map[string]any)
content := t["content"].(string)
switch t["status"].(string) {
case "completed":
body += fmt.Sprintf("- [x] %s\n", content)
// case "in-progress":
// body += fmt.Sprintf("- [ ] %s\n", content)
default:
body += fmt.Sprintf("- [ ] %s\n", content)
}
}
body = toMarkdown(body, innerWidth, t.BackgroundPanel())
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
}
case "task":
if description, ok := toolArgsMap["description"].(string); ok {
title = fmt.Sprintf("TASK %s", description)
if summary, ok := metadata.Get("summary"); ok {
toolcalls := summary.([]any)
// toolcalls :=
steps := []string{}
for _, toolcall := range toolcalls {
call := toolcall.(map[string]any)
if toolInvocation, ok := call["toolInvocation"].(map[string]any); ok {
data, _ := json.Marshal(toolInvocation)
var toolCall client.MessageToolInvocationToolCall
_ = json.Unmarshal(data, &toolCall)
if metadata, ok := call["metadata"].(map[string]any); ok {
data, _ = json.Marshal(metadata)
var toolMetadata client.MessageMetadata_Tool_AdditionalProperties
_ = json.Unmarshal(data, &toolMetadata)
step := renderToolInvocation(
toolCall,
nil,
toolMetadata,
false,
false,
true,
)
steps = append(steps, step)
}
}
}
body = strings.Join(steps, "\n")
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
}
}
title = fmt.Sprintf("%s %s", title, toolArgs)
case "todowrite", "todoread":
// title is just the tool name
default:
toolName := renderToolName(toolCall.ToolName)
toolName := renderToolName(toolCall.ToolInvocation.ToolName)
title = fmt.Sprintf("%s %s", toolName, toolArgs)
if result == nil {
empty := ""
result = &empty
}
body = *result
body = truncateHeight(body, 10)
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
}
if contentOnly {
title = "∟ " + title
return title
}
if !showDetails {
title = "∟ " + title
padding := calculatePadding()
style := lipgloss.NewStyle().Width(outerWidth - padding - 4).Background(t.BackgroundPanel())
paddingBottom := 0
if isLast {
paddingBottom = 1
}
return renderContentBlock(style.Render(title),
WithAlign(lipgloss.Left),
WithBorderColor(t.Accent()),
WithPaddingTop(0),
WithPaddingBottom(paddingBottom),
)
}
if body == "" && error == "" {
body = *result
body = truncateHeight(body, 10)
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
}
content := style.Render(title)
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 showDetails && error != "" {
content += "\n" + error
}
return content
}
func renderToolName(name string) string {
switch name {
case "list":
return "LIST"
case "webfetch":
return "FETCH"
case "todowrite":
return "PLAN"
default:
normalizedName := name
if strings.HasPrefix(name, "opencode_") {
normalizedName = strings.TrimPrefix(name, "opencode_")
}
return cases.Upper(language.Und).String(normalizedName)
}
}
type fileRenderer struct {
filename string
content string
height int
}
type fileRenderingOption func(*fileRenderer)
func WithTruncate(height int) fileRenderingOption {
return func(c *fileRenderer) {
c.height = height
}
}
func renderFile(filename string, content string, options ...fileRenderingOption) string {
t := theme.CurrentTheme()
renderer := &fileRenderer{
filename: filename,
content: content,
}
for _, option := range options {
option(renderer)
}
lines := []string{}
for line := range strings.SplitSeq(content, "\n") {
line = strings.TrimRightFunc(line, unicode.IsSpace)
line = strings.ReplaceAll(line, "\t", " ")
lines = append(lines, line)
}
content = strings.Join(lines, "\n")
width := layout.Current.Container.Width - 8
if renderer.height > 0 {
content = truncateHeight(content, renderer.height)
}
content = fmt.Sprintf("```%s\n%s\n```", extension(renderer.filename), content)
content = toMarkdown(content, width, t.BackgroundPanel())
return renderContentBlock(content, WithFullWidth(), WithMarginBottom(1))
return title
}
func renderToolAction(name string) string {
@@ -605,7 +528,7 @@ func renderToolAction(name string) string {
case "task":
return "Searching..."
case "bash":
return "Building command..."
return "Writing command..."
case "edit":
return "Preparing edit..."
case "webfetch":
@@ -624,12 +547,54 @@ func renderToolAction(name string) string {
return "Planning..."
case "patch":
return "Preparing patch..."
case "batch":
return "Running batch operations..."
}
return "Working..."
}
type fileRenderer struct {
filename string
content string
height int
}
type fileRenderingOption func(*fileRenderer)
func WithTruncate(height int) fileRenderingOption {
return func(c *fileRenderer) {
c.height = height
}
}
func renderFile(
filename string,
content string,
width int,
options ...fileRenderingOption) string {
t := theme.CurrentTheme()
renderer := &fileRenderer{
filename: filename,
content: content,
}
for _, option := range options {
option(renderer)
}
lines := []string{}
for line := range strings.SplitSeq(content, "\n") {
line = strings.TrimRightFunc(line, unicode.IsSpace)
line = strings.ReplaceAll(line, "\t", " ")
lines = append(lines, line)
}
content = strings.Join(lines, "\n")
if renderer.height > 0 {
content = truncateHeight(content, renderer.height)
}
content = fmt.Sprintf("```%s\n%s\n```", extension(renderer.filename), content)
content = toMarkdown(content, width, t.BackgroundPanel())
return content
}
func renderArgs(args *map[string]any, titleKey string) string {
if args == nil || len(*args) == 0 {
return ""
@@ -672,6 +637,7 @@ func truncateHeight(content string, height int) string {
}
func relative(path string) string {
path = strings.TrimPrefix(path, app.CwdPath+"/")
return strings.TrimPrefix(path, app.RootPath+"/")
}
@@ -684,3 +650,73 @@ func extension(path string) string {
}
return ext
}
// Diagnostic represents an LSP diagnostic
type Diagnostic struct {
Range struct {
Start struct {
Line int `json:"line"`
Character int `json:"character"`
} `json:"start"`
} `json:"range"`
Severity int `json:"severity"`
Message string `json:"message"`
}
// renderDiagnostics formats LSP diagnostics for display in the TUI
func renderDiagnostics(metadata opencode.MessageMetadataTool, filePath string) string {
if diagnosticsData, ok := metadata.ExtraFields["diagnostics"].(map[string]any); ok {
if fileDiagnostics, ok := diagnosticsData[filePath].([]any); ok {
var errorDiagnostics []string
for _, diagInterface := range fileDiagnostics {
diagMap, ok := diagInterface.(map[string]any)
if !ok {
continue
}
// Parse the diagnostic
var diag Diagnostic
diagBytes, err := json.Marshal(diagMap)
if err != nil {
continue
}
if err := json.Unmarshal(diagBytes, &diag); err != nil {
continue
}
// Only show error diagnostics (severity === 1)
if diag.Severity != 1 {
continue
}
line := diag.Range.Start.Line + 1 // 1-based
column := diag.Range.Start.Character + 1 // 1-based
errorDiagnostics = append(errorDiagnostics, fmt.Sprintf("Error [%d:%d] %s", line, column, diag.Message))
}
if len(errorDiagnostics) == 0 {
return ""
}
t := theme.CurrentTheme()
var result strings.Builder
for _, diagnostic := range errorDiagnostics {
if result.Len() > 0 {
result.WriteString("\n")
}
result.WriteString(styles.NewStyle().Foreground(t.Error()).Render(diagnostic))
}
return result.String()
}
}
return ""
// diagnosticsData should be a map[string][]Diagnostic
// strDiagnosticsData := diagnosticsData.Raw()
// diagnosticsMap := gjson.Parse(strDiagnosticsData).Value().(map[string]any)
// fileDiagnostics, ok := diagnosticsMap[filePath]
// if !ok {
// return ""
// }
// diagnosticsList, ok := fileDiagnostics.([]any)
// if !ok {
// return ""
// }
}
+153 -213
View File
@@ -1,7 +1,6 @@
package chat
import (
"slices"
"strings"
"time"
@@ -9,18 +8,19 @@ import (
"github.com/charmbracelet/bubbles/v2/viewport"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/commands"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/pkg/client"
"github.com/sst/opencode/internal/util"
)
type MessagesComponent interface {
tea.Model
tea.ViewModel
SetSize(width, height int) tea.Cmd
PageUp() (tea.Model, tea.Cmd)
PageDown() (tea.Model, tea.Cmd)
HalfPageUp() (tea.Model, tea.Cmd)
@@ -38,7 +38,6 @@ type messagesComponent struct {
viewport viewport.Model
spinner spinner.Model
attachments viewport.Model
commands commands.CommandsComponent
cache *MessageCache
rendering bool
showToolDetails bool
@@ -48,7 +47,7 @@ type renderFinishedMsg struct{}
type ToggleToolDetailsMsg struct{}
func (m *messagesComponent) Init() tea.Cmd {
return tea.Batch(m.viewport.Init(), m.spinner.Tick, m.commands.Init())
return tea.Batch(m.viewport.Init(), m.spinner.Tick)
}
func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -83,7 +82,7 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.tail {
m.viewport.GotoBottom()
}
case client.EventSessionUpdated, client.EventMessageUpdated:
case opencode.EventListResponseEventSessionUpdated, opencode.EventListResponseEventMessageUpdated:
m.renderView()
if m.tail {
m.viewport.GotoBottom()
@@ -99,143 +98,160 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.spinner = spinner
cmds = append(cmds, cmd)
updated, cmd := m.commands.Update(msg)
m.commands = updated.(commands.CommandsComponent)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
type blockType int
const (
none blockType = iota
userTextBlock
assistantTextBlock
toolInvocationBlock
errorBlock
)
func (m *messagesComponent) renderView() {
if m.width == 0 {
return
}
measure := util.Measure("messages.renderView")
defer measure("messageCount", len(m.app.Messages))
t := theme.CurrentTheme()
blocks := make([]string, 0)
previousBlockType := none
align := lipgloss.Center
width := layout.Current.Container.Width
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 = m.app.Info.User
case client.Assistant:
author = message.Metadata.Assistant.ModelID
}
for i, p := range message.Parts {
part, err := p.ValueByDiscriminator()
if err != nil {
continue //TODO: handle error?
}
switch part.(type) {
// case client.MessagePartStepStart:
// messages = append(messages, "")
case client.MessagePartText:
text := part.(client.MessagePartText)
key := m.cache.GenerateKey(message.Id, text.Text, layout.Current.Viewport.Width)
content, cached = m.cache.Get(key)
if !cached {
content = renderText(message, text.Text, author)
m.cache.Set(key, content)
}
if previousBlockType != none {
blocks = append(blocks, "")
}
blocks = append(blocks, content)
if message.Role == client.User {
previousBlockType = userTextBlock
} else if message.Role == client.Assistant {
previousBlockType = assistantTextBlock
}
case client.MessagePartToolInvocation:
isLastToolInvocation := slices.Contains(lastToolIndices, i)
toolInvocationPart := part.(client.MessagePartToolInvocation)
toolCall, _ := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolCall()
metadata := client.MessageMetadata_Tool_AdditionalProperties{}
if _, ok := message.Metadata.Tool[toolCall.ToolCallId]; ok {
metadata = message.Metadata.Tool[toolCall.ToolCallId]
}
var result *string
resultPart, resultError := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolResult()
if resultError == nil {
result = &resultPart.Result
}
if toolCall.State == "result" {
key := m.cache.GenerateKey(message.Id,
toolCall.ToolCallId,
m.showToolDetails,
layout.Current.Viewport.Width,
)
case opencode.MessageRoleUser:
for _, part := range message.Parts {
switch part := part.AsUnion().(type) {
case opencode.TextPart:
key := m.cache.GenerateKey(message.ID, part.Text, layout.Current.Viewport.Width)
content, cached = m.cache.Get(key)
if !cached {
content = renderToolInvocation(
toolCall,
result,
metadata,
content = renderText(
message,
part.Text,
m.app.Info.User,
m.showToolDetails,
isLastToolInvocation,
false,
width,
align,
)
m.cache.Set(key, content)
}
} else {
// if the tool call isn't finished, don't cache
content = renderToolInvocation(
toolCall,
result,
metadata,
m.showToolDetails,
isLastToolInvocation,
false,
)
if content != "" {
blocks = append(blocks, content)
}
}
if previousBlockType != toolInvocationBlock && m.showToolDetails {
blocks = append(blocks, "")
}
blocks = append(blocks, content)
previousBlockType = toolInvocationBlock
}
case opencode.MessageRoleAssistant:
for i, p := range message.Parts {
switch part := p.AsUnion().(type) {
case opencode.TextPart:
finished := message.Metadata.Time.Completed > 0
remainingParts := message.Parts[i+1:]
toolCallParts := make([]opencode.ToolInvocationPart, 0)
for _, part := range remainingParts {
switch part := part.AsUnion().(type) {
case opencode.TextPart:
// we only want tool calls associated with the current text part.
// if we hit another text part, we're done.
break
case opencode.ToolInvocationPart:
toolCallParts = append(toolCallParts, part)
if part.ToolInvocation.State != "result" {
// i don't think there's a case where a tool call isn't in result state
// and the message time is 0, but just in case
finished = false
}
}
}
if finished {
key := m.cache.GenerateKey(message.ID, p.Text, layout.Current.Viewport.Width, m.showToolDetails)
content, cached = m.cache.Get(key)
if !cached {
content = renderText(
message,
p.Text,
message.Metadata.Assistant.ModelID,
m.showToolDetails,
width,
align,
toolCallParts...,
)
m.cache.Set(key, content)
}
} else {
content = renderText(
message,
p.Text,
message.Metadata.Assistant.ModelID,
m.showToolDetails,
width,
align,
toolCallParts...,
)
}
if content != "" {
blocks = append(blocks, content)
}
case opencode.ToolInvocationPart:
if !m.showToolDetails {
continue
}
if part.ToolInvocation.State == "result" {
key := m.cache.GenerateKey(message.ID,
part.ToolInvocation.ToolCallID,
m.showToolDetails,
layout.Current.Viewport.Width,
)
content, cached = m.cache.Get(key)
if !cached {
content = renderToolDetails(
part,
message.Metadata,
width,
align,
)
m.cache.Set(key, content)
}
} else {
// if the tool call isn't finished, don't cache
content = renderToolDetails(
part,
message.Metadata,
width,
align,
)
}
if content != "" {
blocks = append(blocks, content)
}
}
}
}
error := ""
if message.Metadata.Error != nil {
errorValue, _ := message.Metadata.Error.ValueByDiscriminator()
switch errorValue.(type) {
case client.UnknownError:
clientError := errorValue.(client.UnknownError)
error = clientError.Data.Message
error = renderContentBlock(error, WithBorderColor(t.Error()), WithFullWidth(), WithMarginTop(1), WithMarginBottom(1))
blocks = append(blocks, error)
previousBlockType = errorBlock
}
switch err := message.Metadata.Error.AsUnion().(type) {
case nil:
case opencode.MessageMetadataErrorMessageOutputLengthError:
error = "Message output length exceeded"
case opencode.ProviderAuthError:
error = err.Data.Message
case opencode.UnknownError:
error = err.Data.Message
}
if error != "" {
error = renderContentBlock(
error,
width,
align,
WithBorderColor(t.Error()),
)
blocks = append(blocks, error)
}
}
@@ -244,38 +260,38 @@ func (m *messagesComponent) renderView() {
centered = append(centered, lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
block,
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
block+"\n",
styles.WhitespaceStyle(t.Background()),
))
}
m.viewport.SetHeight(m.height - lipgloss.Height(m.header()))
m.viewport.SetContent("\n" + strings.Join(centered, "\n") + "\n")
m.viewport.SetHeight(m.height - lipgloss.Height(m.header()) + 1)
m.viewport.SetContent("\n" + strings.Join(centered, "\n"))
}
func (m *messagesComponent) header() string {
if m.app.Session.Id == "" {
if m.app.Session.ID == "" {
return ""
}
t := theme.CurrentTheme()
width := layout.Current.Container.Width
base := styles.BaseStyle().Background(t.Background()).Render
muted := styles.Muted().Background(t.Background()).Render
base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
muted := styles.NewStyle().Foreground(t.TextMuted()).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 != "" {
headerLines = append(headerLines, muted(m.app.Session.Share.Url))
if m.app.Session.Share.URL != "" {
headerLines = append(headerLines, muted(m.app.Session.Share.URL))
} else {
headerLines = append(headerLines, base("/share")+muted(" to create a shareable link"))
}
header := strings.Join(headerLines, "\n")
header = styles.BaseStyle().
header = styles.NewStyle().
Background(t.Background()).
Width(width).
PaddingLeft(2).
PaddingRight(2).
Background(t.Background()).
BorderLeft(true).
BorderRight(true).
BorderBackground(t.Background()).
@@ -287,9 +303,7 @@ func (m *messagesComponent) header() string {
}
func (m *messagesComponent) View() string {
if len(m.app.Messages) == 0 {
return m.home()
}
t := theme.CurrentTheme()
if m.rendering {
return lipgloss.Place(
m.width,
@@ -297,83 +311,18 @@ func (m *messagesComponent) View() string {
lipgloss.Center,
lipgloss.Center,
"Loading session...",
styles.WhitespaceStyle(t.Background()),
)
}
t := theme.CurrentTheme()
return lipgloss.JoinVertical(
lipgloss.Left,
lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
m.header(),
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
),
m.viewport.View(),
header := lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
m.header(),
styles.WhitespaceStyle(t.Background()),
)
}
func (m *messagesComponent) home() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle().Background(t.Background())
base := baseStyle.Render
muted := styles.Muted().Background(t.Background()).Render
open := `
█▀▀█ █▀▀█ █▀▀ █▀▀▄
█░░█ █░░█ █▀▀ █░░█
▀▀▀▀ █▀▀▀ ▀▀▀ ▀ ▀ `
code := `
█▀▀ █▀▀█ █▀▀▄ █▀▀
█░░ █░░█ █░░█ █▀▀
▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀`
logo := lipgloss.JoinHorizontal(
lipgloss.Top,
muted(open),
base(code),
)
// cwd := app.Info.Path.Cwd
// config := app.Info.Path.Config
versionStyle := lipgloss.NewStyle().
return styles.NewStyle().
Background(t.Background()).
Foreground(t.TextMuted()).
Width(lipgloss.Width(logo)).
Align(lipgloss.Right)
version := versionStyle.Render(m.app.Version)
logoAndVersion := strings.Join([]string{logo, version}, "\n")
logoAndVersion = lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
logoAndVersion,
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
)
m.commands.SetBackgroundColor(t.Background())
commands := lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
m.commands.View(),
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
)
lines := []string{}
lines = append(lines, logoAndVersion)
lines = append(lines, "")
lines = append(lines, "")
// lines = append(lines, base("cwd ")+muted(cwd))
// lines = append(lines, base("config ")+muted(config))
// lines = append(lines, "")
lines = append(lines, commands)
return lipgloss.Place(
m.width,
m.height,
lipgloss.Center,
lipgloss.Center,
baseStyle.Render(strings.Join(lines, "\n")),
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
)
Render(header + "\n" + m.viewport.View())
}
func (m *messagesComponent) SetSize(width, height int) tea.Cmd {
@@ -390,7 +339,6 @@ func (m *messagesComponent) SetSize(width, height int) tea.Cmd {
m.viewport.SetHeight(height - lipgloss.Height(m.header()))
m.attachments.SetWidth(width + 40)
m.attachments.SetHeight(3)
m.commands.SetSize(width, height)
m.renderView()
return nil
}
@@ -454,19 +402,11 @@ func NewMessagesComponent(app *app.App) MessagesComponent {
attachments := viewport.New()
vp.KeyMap = viewport.KeyMap{}
t := theme.CurrentTheme()
commandsView := commands.New(
app,
commands.WithBackground(t.Background()),
commands.WithLimit(6),
)
return &messagesComponent{
app: app,
viewport: vp,
spinner: s,
attachments: attachments,
commands: commandsView,
showToolDetails: true,
cache: NewMessageCache(),
tail: true,
@@ -9,15 +9,13 @@ import (
"github.com/charmbracelet/lipgloss/v2/compat"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
type CommandsComponent interface {
tea.Model
tea.ViewModel
layout.Sizeable
SetSize(width, height int) tea.Cmd
SetBackgroundColor(color compat.AdaptiveColor)
}
@@ -44,31 +42,12 @@ func (c *commandsComponent) SetBackgroundColor(color compat.AdaptiveColor) {
c.background = &color
}
func (c *commandsComponent) Init() tea.Cmd {
return nil
}
func (c *commandsComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
c.width = msg.Width
c.height = msg.Height
}
return c, nil
}
func (c *commandsComponent) View() string {
t := theme.CurrentTheme()
triggerStyle := lipgloss.NewStyle().
Foreground(t.Primary()).
Bold(true)
descriptionStyle := lipgloss.NewStyle().
Foreground(t.Text())
keybindStyle := lipgloss.NewStyle().
Foreground(t.TextMuted())
triggerStyle := styles.NewStyle().Foreground(t.Primary()).Bold(true)
descriptionStyle := styles.NewStyle().Foreground(t.Text())
keybindStyle := styles.NewStyle().Foreground(t.TextMuted())
if c.background != nil {
triggerStyle = triggerStyle.Background(*c.background)
@@ -99,10 +78,11 @@ func (c *commandsComponent) View() string {
}
if len(commandsToShow) == 0 {
muted := styles.NewStyle().Foreground(theme.CurrentTheme().TextMuted())
if c.showAll {
return styles.Muted().Render("No commands available")
return muted.Render("No commands available")
}
return styles.Muted().Render("No commands with triggers available")
return muted.Render("No commands with triggers available")
}
// Calculate column widths
@@ -133,7 +113,7 @@ func (c *commandsComponent) View() string {
if c.showKeybinds {
for _, kb := range cmd.Keybindings {
if kb.RequiresLeader {
keybindStrs = append(keybindStrs, *c.app.Config.Keybinds.Leader+" "+kb.Key)
keybindStrs = append(keybindStrs, c.app.Config.Keybinds.Leader+" "+kb.Key)
} else {
keybindStrs = append(keybindStrs, kb.Key)
}
@@ -188,7 +168,7 @@ func (c *commandsComponent) View() string {
// Remove trailing newline
result := strings.TrimSuffix(output.String(), "\n")
if c.background != nil {
result = lipgloss.NewStyle().Background(c.background).Width(maxWidth).Render(result)
result = styles.NewStyle().Background(*c.background).Width(maxWidth).Render(result)
}
return result
@@ -6,6 +6,7 @@ import (
"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/styles"
@@ -26,7 +27,7 @@ type CompletionItemI interface {
func (ci *CompletionItem) Render(selected bool, width int) string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
baseStyle := styles.NewStyle().Foreground(t.Text())
itemStyle := baseStyle.
Background(t.BackgroundElement()).
@@ -34,8 +35,7 @@ func (ci *CompletionItem) Render(selected bool, width int) string {
Padding(0, 1)
if selected {
itemStyle = itemStyle.
Foreground(t.Primary())
itemStyle = itemStyle.Foreground(t.Primary())
}
title := itemStyle.Render(
@@ -185,7 +185,7 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (c *completionDialogComponent) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
baseStyle := styles.NewStyle().Foreground(t.Text())
maxWidth := 40
completions := c.list.GetItems()
@@ -199,8 +199,14 @@ func (c *completionDialogComponent) View() string {
c.list.SetMaxWidth(maxWidth)
return baseStyle.Padding(0, 0).
return baseStyle.
Padding(0, 0).
Background(t.BackgroundElement()).
BorderStyle(lipgloss.ThickBorder()).
BorderLeft(true).
BorderRight(true).
BorderForeground(t.Border()).
BorderBackground(t.Background()).
Width(c.width).
Render(c.list.View())
}
@@ -20,10 +20,7 @@ type helpDialog struct {
}
func (h *helpDialog) Init() tea.Cmd {
return tea.Batch(
h.commandsComponent.Init(),
h.viewport.Init(),
)
return h.viewport.Init()
}
func (h *helpDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -38,10 +35,6 @@ func (h *helpDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
h.commandsComponent.SetSize(msg.Width-4, msg.Height-6)
}
// Update commands component first to get the latest content
_, cmdCmd := h.commandsComponent.Update(msg)
cmds = append(cmds, cmdCmd)
// Update viewport content
h.viewport.SetContent(h.commandsComponent.View())
@@ -94,7 +94,7 @@ func (m InitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// View implements tea.Model.
func (m InitDialogCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
baseStyle := styles.NewStyle().Foreground(t.Text())
// Calculate width needed for content
maxWidth := 60 // Width for explanation text
@@ -10,6 +10,7 @@ import (
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/components/modal"
@@ -17,7 +18,6 @@ import (
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
"github.com/sst/opencode/pkg/client"
)
const (
@@ -32,8 +32,8 @@ type ModelDialog interface {
type modelDialog struct {
app *app.App
availableProviders []client.ProviderInfo
provider client.ProviderInfo
availableProviders []opencode.Provider
provider opencode.Provider
width int
height int
hScrollOffset int
@@ -69,7 +69,7 @@ var modelKeys = modelKeyMap{
}
func (m *modelDialog) Init() tea.Cmd {
m.setupModelsForProvider(m.provider.Id)
m.setupModelsForProvider(m.provider.ID)
return nil
}
@@ -90,7 +90,7 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, modelKeys.Enter):
selectedItem, _ := m.modelList.GetSelectedItem()
models := m.models()
var selectedModel client.ModelInfo
var selectedModel opencode.Model
for _, model := range models {
if model.Name == string(selectedItem) {
selectedModel = model
@@ -119,8 +119,8 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd
}
func (m *modelDialog) models() []client.ModelInfo {
models := slices.SortedFunc(maps.Values(m.provider.Models), func(a, b client.ModelInfo) int {
func (m *modelDialog) models() []opencode.Model {
models := slices.SortedFunc(maps.Values(m.provider.Models), func(a, b opencode.Model) int {
return strings.Compare(a.Name, b.Name)
})
return models
@@ -139,7 +139,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)
m.setupModelsForProvider(m.provider.ID)
}
func (m *modelDialog) View() string {
@@ -158,7 +158,7 @@ func (m *modelDialog) getScrollIndicators(maxWidth int) string {
}
t := theme.CurrentTheme()
return styles.BaseStyle().
return styles.NewStyle().
Foreground(t.TextMuted()).
Width(maxWidth).
Align(lipgloss.Right).
@@ -175,9 +175,9 @@ func (m *modelDialog) setupModelsForProvider(providerId string) {
m.modelList = list.NewStringList(modelNames, numVisibleModels, "No models available", true)
m.modelList.SetMaxWidth(maxDialogWidth)
if m.app.Provider != nil && m.app.Model != nil && m.app.Provider.Id == providerId {
if m.app.Provider != nil && m.app.Model != nil && m.app.Provider.ID == providerId {
for i, model := range models {
if model.Id == m.app.Model.Id {
if model.ID == m.app.Model.ID {
m.modelList.SetSelectedIndex(i)
break
}
@@ -200,7 +200,7 @@ func NewModelDialog(app *app.App) ModelDialog {
hScrollOffset := 0
if app.Provider != nil {
for i, provider := range availableProviders {
if provider.Id == app.Provider.Id {
if provider.ID == app.Provider.ID {
currentProvider = provider
hScrollOffset = i
break
@@ -220,6 +220,6 @@ func NewModelDialog(app *app.App) ModelDialog {
),
}
dialog.setupModelsForProvider(currentProvider.Id)
dialog.setupModelsForProvider(currentProvider.ID)
return dialog
}
@@ -145,7 +145,7 @@ func (p *permissionDialogComponent) selectCurrentOption() tea.Cmd {
func (p *permissionDialogComponent) renderButtons() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
baseStyle := styles.NewStyle().Foreground(t.Text())
allowStyle := baseStyle
allowSessionStyle := baseStyle
@@ -355,8 +355,7 @@ func (p *permissionDialogComponent) renderDefaultContent() string {
func (p *permissionDialogComponent) styleViewport() string {
t := theme.CurrentTheme()
contentStyle := lipgloss.NewStyle().
Background(t.Background())
contentStyle := styles.NewStyle().Background(t.Background())
return contentStyle.Render(p.contentViewPort.View())
}
@@ -7,8 +7,8 @@ import (
"slices"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/muesli/reflow/truncate"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/components/modal"
@@ -17,7 +17,6 @@ import (
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
"github.com/sst/opencode/pkg/client"
)
// SessionDialog interface for the session switching dialog
@@ -33,7 +32,7 @@ type sessionItem struct {
func (s sessionItem) Render(selected bool, width int) string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
baseStyle := styles.NewStyle()
var text string
if s.isDeleteConfirming {
@@ -44,20 +43,20 @@ func (s sessionItem) Render(selected bool, width int) string {
truncatedStr := truncate.StringWithTail(text, uint(width-1), "...")
var itemStyle lipgloss.Style
var itemStyle styles.Style
if selected {
if s.isDeleteConfirming {
// Red background for delete confirmation
itemStyle = baseStyle.
Background(t.Error()).
Foreground(t.Background()).
Foreground(t.BackgroundElement()).
Width(width).
PaddingLeft(1)
} else {
// Normal selection
itemStyle = baseStyle.
Background(t.Primary()).
Foreground(t.Background()).
Foreground(t.BackgroundElement()).
Width(width).
PaddingLeft(1)
}
@@ -80,7 +79,7 @@ type sessionDialog struct {
width int
height int
modal *modal.Modal
sessions []client.SessionInfo
sessions []opencode.Session
list list.List[sessionItem]
app *app.App
deleteConfirmation int // -1 means no confirmation, >= 0 means confirming deletion of session at this index
@@ -123,7 +122,7 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
s.updateListItems()
return nil
},
s.deleteSession(sessionToDelete.Id),
s.deleteSession(sessionToDelete.ID),
)
} else {
// First press - enter delete confirmation mode
@@ -151,9 +150,9 @@ func (s *sessionDialog) Render(background string) string {
listView := s.list.View()
t := theme.CurrentTheme()
helpStyle := styles.BaseStyle().PaddingLeft(1).PaddingTop(1)
helpText := styles.BaseStyle().Foreground(t.Text()).Render("x/del")
helpText = helpText + styles.BaseStyle().Background(t.BackgroundElement()).Foreground(t.TextMuted()).Render(" delete session")
helpStyle := styles.NewStyle().PaddingLeft(1).PaddingTop(1)
helpText := styles.NewStyle().Foreground(t.Text()).Render("x/del")
helpText = helpText + styles.NewStyle().Background(t.BackgroundElement()).Foreground(t.TextMuted()).Render(" delete session")
helpText = helpStyle.Render(helpText)
content := strings.Join([]string{listView, helpText}, "\n")
@@ -194,10 +193,10 @@ func (s *sessionDialog) Close() tea.Cmd {
func NewSessionDialog(app *app.App) SessionDialog {
sessions, _ := app.ListSessions(context.Background())
var filteredSessions []client.SessionInfo
var filteredSessions []opencode.Session
var items []sessionItem
for _, sess := range sessions {
if sess.ParentID != nil {
if sess.ParentID != "" {
continue
}
filteredSessions = append(filteredSessions, sess)
@@ -103,7 +103,7 @@ func NewThemeDialog() ThemeDialog {
// Set the initial selection to the current theme
list.SetSelectedIndex(selectedIdx)
// Set the max width for the list to match the modal width
list.SetMaxWidth(36) // 40 (modal max width) - 4 (modal padding)
+155 -112
View File
@@ -8,6 +8,7 @@ import (
"regexp"
"strconv"
"strings"
"sync"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters"
@@ -19,6 +20,7 @@ import (
"github.com/sergi/go-diff/diffmatchpatch"
stylesi "github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
// -------------------------------------------------------------------------
@@ -441,84 +443,84 @@ func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg color.C
<entry type="TextWhitespace" style="%s"/>
</style>
`,
getColor(t.BackgroundPanel()), // Background
getColor(t.Text()), // Text
getColor(t.Text()), // Other
getColor(t.Error()), // Error
getChromaColor(t.BackgroundPanel()), // Background
getChromaColor(t.Text()), // Text
getChromaColor(t.Text()), // Other
getChromaColor(t.Error()), // Error
getColor(t.SyntaxKeyword()), // Keyword
getColor(t.SyntaxKeyword()), // KeywordConstant
getColor(t.SyntaxKeyword()), // KeywordDeclaration
getColor(t.SyntaxKeyword()), // KeywordNamespace
getColor(t.SyntaxKeyword()), // KeywordPseudo
getColor(t.SyntaxKeyword()), // KeywordReserved
getColor(t.SyntaxType()), // KeywordType
getChromaColor(t.SyntaxKeyword()), // Keyword
getChromaColor(t.SyntaxKeyword()), // KeywordConstant
getChromaColor(t.SyntaxKeyword()), // KeywordDeclaration
getChromaColor(t.SyntaxKeyword()), // KeywordNamespace
getChromaColor(t.SyntaxKeyword()), // KeywordPseudo
getChromaColor(t.SyntaxKeyword()), // KeywordReserved
getChromaColor(t.SyntaxType()), // KeywordType
getColor(t.Text()), // Name
getColor(t.SyntaxVariable()), // NameAttribute
getColor(t.SyntaxType()), // NameBuiltin
getColor(t.SyntaxVariable()), // NameBuiltinPseudo
getColor(t.SyntaxType()), // NameClass
getColor(t.SyntaxVariable()), // NameConstant
getColor(t.SyntaxFunction()), // NameDecorator
getColor(t.SyntaxVariable()), // NameEntity
getColor(t.SyntaxType()), // NameException
getColor(t.SyntaxFunction()), // NameFunction
getColor(t.Text()), // NameLabel
getColor(t.SyntaxType()), // NameNamespace
getColor(t.SyntaxVariable()), // NameOther
getColor(t.SyntaxKeyword()), // NameTag
getColor(t.SyntaxVariable()), // NameVariable
getColor(t.SyntaxVariable()), // NameVariableClass
getColor(t.SyntaxVariable()), // NameVariableGlobal
getColor(t.SyntaxVariable()), // NameVariableInstance
getChromaColor(t.Text()), // Name
getChromaColor(t.SyntaxVariable()), // NameAttribute
getChromaColor(t.SyntaxType()), // NameBuiltin
getChromaColor(t.SyntaxVariable()), // NameBuiltinPseudo
getChromaColor(t.SyntaxType()), // NameClass
getChromaColor(t.SyntaxVariable()), // NameConstant
getChromaColor(t.SyntaxFunction()), // NameDecorator
getChromaColor(t.SyntaxVariable()), // NameEntity
getChromaColor(t.SyntaxType()), // NameException
getChromaColor(t.SyntaxFunction()), // NameFunction
getChromaColor(t.Text()), // NameLabel
getChromaColor(t.SyntaxType()), // NameNamespace
getChromaColor(t.SyntaxVariable()), // NameOther
getChromaColor(t.SyntaxKeyword()), // NameTag
getChromaColor(t.SyntaxVariable()), // NameVariable
getChromaColor(t.SyntaxVariable()), // NameVariableClass
getChromaColor(t.SyntaxVariable()), // NameVariableGlobal
getChromaColor(t.SyntaxVariable()), // NameVariableInstance
getColor(t.SyntaxString()), // Literal
getColor(t.SyntaxString()), // LiteralDate
getColor(t.SyntaxString()), // LiteralString
getColor(t.SyntaxString()), // LiteralStringBacktick
getColor(t.SyntaxString()), // LiteralStringChar
getColor(t.SyntaxString()), // LiteralStringDoc
getColor(t.SyntaxString()), // LiteralStringDouble
getColor(t.SyntaxString()), // LiteralStringEscape
getColor(t.SyntaxString()), // LiteralStringHeredoc
getColor(t.SyntaxString()), // LiteralStringInterpol
getColor(t.SyntaxString()), // LiteralStringOther
getColor(t.SyntaxString()), // LiteralStringRegex
getColor(t.SyntaxString()), // LiteralStringSingle
getColor(t.SyntaxString()), // LiteralStringSymbol
getChromaColor(t.SyntaxString()), // Literal
getChromaColor(t.SyntaxString()), // LiteralDate
getChromaColor(t.SyntaxString()), // LiteralString
getChromaColor(t.SyntaxString()), // LiteralStringBacktick
getChromaColor(t.SyntaxString()), // LiteralStringChar
getChromaColor(t.SyntaxString()), // LiteralStringDoc
getChromaColor(t.SyntaxString()), // LiteralStringDouble
getChromaColor(t.SyntaxString()), // LiteralStringEscape
getChromaColor(t.SyntaxString()), // LiteralStringHeredoc
getChromaColor(t.SyntaxString()), // LiteralStringInterpol
getChromaColor(t.SyntaxString()), // LiteralStringOther
getChromaColor(t.SyntaxString()), // LiteralStringRegex
getChromaColor(t.SyntaxString()), // LiteralStringSingle
getChromaColor(t.SyntaxString()), // LiteralStringSymbol
getColor(t.SyntaxNumber()), // LiteralNumber
getColor(t.SyntaxNumber()), // LiteralNumberBin
getColor(t.SyntaxNumber()), // LiteralNumberFloat
getColor(t.SyntaxNumber()), // LiteralNumberHex
getColor(t.SyntaxNumber()), // LiteralNumberInteger
getColor(t.SyntaxNumber()), // LiteralNumberIntegerLong
getColor(t.SyntaxNumber()), // LiteralNumberOct
getChromaColor(t.SyntaxNumber()), // LiteralNumber
getChromaColor(t.SyntaxNumber()), // LiteralNumberBin
getChromaColor(t.SyntaxNumber()), // LiteralNumberFloat
getChromaColor(t.SyntaxNumber()), // LiteralNumberHex
getChromaColor(t.SyntaxNumber()), // LiteralNumberInteger
getChromaColor(t.SyntaxNumber()), // LiteralNumberIntegerLong
getChromaColor(t.SyntaxNumber()), // LiteralNumberOct
getColor(t.SyntaxOperator()), // Operator
getColor(t.SyntaxKeyword()), // OperatorWord
getColor(t.SyntaxPunctuation()), // Punctuation
getChromaColor(t.SyntaxOperator()), // Operator
getChromaColor(t.SyntaxKeyword()), // OperatorWord
getChromaColor(t.SyntaxPunctuation()), // Punctuation
getColor(t.SyntaxComment()), // Comment
getColor(t.SyntaxComment()), // CommentHashbang
getColor(t.SyntaxComment()), // CommentMultiline
getColor(t.SyntaxComment()), // CommentSingle
getColor(t.SyntaxComment()), // CommentSpecial
getColor(t.SyntaxKeyword()), // CommentPreproc
getChromaColor(t.SyntaxComment()), // Comment
getChromaColor(t.SyntaxComment()), // CommentHashbang
getChromaColor(t.SyntaxComment()), // CommentMultiline
getChromaColor(t.SyntaxComment()), // CommentSingle
getChromaColor(t.SyntaxComment()), // CommentSpecial
getChromaColor(t.SyntaxKeyword()), // CommentPreproc
getColor(t.Text()), // Generic
getColor(t.Error()), // GenericDeleted
getColor(t.Text()), // GenericEmph
getColor(t.Error()), // GenericError
getColor(t.Text()), // GenericHeading
getColor(t.Success()), // GenericInserted
getColor(t.TextMuted()), // GenericOutput
getColor(t.Text()), // GenericPrompt
getColor(t.Text()), // GenericStrong
getColor(t.Text()), // GenericSubheading
getColor(t.Error()), // GenericTraceback
getColor(t.Text()), // TextWhitespace
getChromaColor(t.Text()), // Generic
getChromaColor(t.Error()), // GenericDeleted
getChromaColor(t.Text()), // GenericEmph
getChromaColor(t.Error()), // GenericError
getChromaColor(t.Text()), // GenericHeading
getChromaColor(t.Success()), // GenericInserted
getChromaColor(t.TextMuted()), // GenericOutput
getChromaColor(t.Text()), // GenericPrompt
getChromaColor(t.Text()), // GenericStrong
getChromaColor(t.Text()), // GenericSubheading
getChromaColor(t.Error()), // GenericTraceback
getChromaColor(t.Text()), // TextWhitespace
)
r := strings.NewReader(syntaxThemeXml)
@@ -527,6 +529,9 @@ func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg color.C
// Modify the style to use the provided background
s, err := style.Builder().Transform(
func(t chroma.StyleEntry) chroma.StyleEntry {
if _, ok := bg.(lipgloss.NoColor); ok {
return t
}
r, g, b, _ := bg.RGBA()
t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8))
return t
@@ -546,10 +551,18 @@ func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg color.C
}
// getColor returns the appropriate hex color string based on terminal background
func getColor(adaptiveColor compat.AdaptiveColor) string {
func getColor(adaptiveColor compat.AdaptiveColor) *string {
return stylesi.AdaptiveColorToString(adaptiveColor)
}
func getChromaColor(adaptiveColor compat.AdaptiveColor) string {
color := stylesi.AdaptiveColorToString(adaptiveColor)
if color == nil {
return ""
}
return *color
}
// highlightLine applies syntax highlighting to a single line
func highlightLine(fileName string, line string, bg color.Color) string {
var buf bytes.Buffer
@@ -561,11 +574,11 @@ func highlightLine(fileName string, line string, bg color.Color) string {
}
// createStyles generates the lipgloss styles needed for rendering diffs
func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle lipgloss.Style) {
removedLineStyle = lipgloss.NewStyle().Background(t.DiffRemovedBg())
addedLineStyle = lipgloss.NewStyle().Background(t.DiffAddedBg())
contextLineStyle = lipgloss.NewStyle().Background(t.DiffContextBg())
lineNumberStyle = lipgloss.NewStyle().Background(t.DiffLineNumber()).Foreground(t.TextMuted())
func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle stylesi.Style) {
removedLineStyle = stylesi.NewStyle().Background(t.DiffRemovedBg())
addedLineStyle = stylesi.NewStyle().Background(t.DiffAddedBg())
contextLineStyle = stylesi.NewStyle().Background(t.DiffContextBg())
lineNumberStyle = stylesi.NewStyle().Foreground(t.TextMuted()).Background(t.DiffLineNumber())
return
}
@@ -613,9 +626,17 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
currentPos := 0
// Get the appropriate color based on terminal background
bgColor := lipgloss.Color(getColor(highlightBg))
fgColor := lipgloss.Color(getColor(theme.CurrentTheme().BackgroundPanel()))
bg := getColor(highlightBg)
fg := getColor(theme.CurrentTheme().BackgroundPanel())
var bgColor color.Color
var fgColor color.Color
if bg != nil {
bgColor = lipgloss.Color(*bg)
}
if fg != nil {
fgColor = lipgloss.Color(*fg)
}
for i := 0; i < len(content); {
// Check if we're at an ANSI sequence
isAnsi := false
@@ -651,12 +672,20 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
currentStyle := ansiSequences[currentPos]
// Apply foreground and background highlight
sb.WriteString("\x1b[38;2;")
r, g, b, _ := fgColor.RGBA()
sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
sb.WriteString("\x1b[48;2;")
r, g, b, _ = bgColor.RGBA()
sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
if fgColor != nil {
sb.WriteString("\x1b[38;2;")
r, g, b, _ := fgColor.RGBA()
sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
} else {
sb.WriteString("\x1b[49m")
}
if bgColor != nil {
sb.WriteString("\x1b[48;2;")
r, g, b, _ := bgColor.RGBA()
sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
} else {
sb.WriteString("\x1b[39m")
}
sb.WriteString(char)
// Full reset of all attributes to ensure clean state
@@ -677,16 +706,16 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
}
// renderLinePrefix renders the line number and marker prefix for a diff line
func renderLinePrefix(dl DiffLine, lineNum string, marker string, lineNumberStyle lipgloss.Style, t theme.Theme) string {
func renderLinePrefix(dl DiffLine, lineNum string, marker string, lineNumberStyle stylesi.Style, t theme.Theme) string {
// Style the marker based on line type
var styledMarker string
switch dl.Kind {
case LineRemoved:
styledMarker = lipgloss.NewStyle().Background(t.DiffRemovedBg()).Foreground(t.DiffRemoved()).Render(marker)
styledMarker = stylesi.NewStyle().Foreground(t.DiffRemoved()).Background(t.DiffRemovedBg()).Render(marker)
case LineAdded:
styledMarker = lipgloss.NewStyle().Background(t.DiffAddedBg()).Foreground(t.DiffAdded()).Render(marker)
styledMarker = stylesi.NewStyle().Foreground(t.DiffAdded()).Background(t.DiffAddedBg()).Render(marker)
case LineContext:
styledMarker = lipgloss.NewStyle().Background(t.DiffContextBg()).Foreground(t.TextMuted()).Render(marker)
styledMarker = stylesi.NewStyle().Foreground(t.TextMuted()).Background(t.DiffContextBg()).Render(marker)
default:
styledMarker = marker
}
@@ -695,7 +724,7 @@ func renderLinePrefix(dl DiffLine, lineNum string, marker string, lineNumberStyl
}
// renderLineContent renders the content of a diff line with syntax and intra-line highlighting
func renderLineContent(fileName string, dl DiffLine, bgStyle lipgloss.Style, highlightColor compat.AdaptiveColor, width int, t theme.Theme) string {
func renderLineContent(fileName string, dl DiffLine, bgStyle stylesi.Style, highlightColor compat.AdaptiveColor, width int) string {
// Apply syntax highlighting
content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
@@ -714,7 +743,9 @@ func renderLineContent(fileName string, dl DiffLine, bgStyle lipgloss.Style, hig
ansi.Truncate(
content,
width,
lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.TextMuted()).Render("..."),
"...",
// stylesi.NewStyleWithColors(t.TextMuted(), bgStyle.GetBackground()).Render("..."),
// stylesi.WithForeground(stylesi.NewStyle().Background(bgStyle.GetBackground()), t.TextMuted()).Render("..."),
),
)
}
@@ -725,7 +756,7 @@ func renderUnifiedLine(fileName string, dl DiffLine, width int, t theme.Theme) s
// Determine line style and marker based on line type
var marker string
var bgStyle lipgloss.Style
var bgStyle stylesi.Style
var lineNum string
var highlightColor compat.AdaptiveColor
@@ -733,8 +764,8 @@ func renderUnifiedLine(fileName string, dl DiffLine, width int, t theme.Theme) s
case LineRemoved:
marker = "-"
bgStyle = removedLineStyle
lineNumberStyle = lineNumberStyle.Foreground(t.DiffRemoved()).Background(t.DiffRemovedLineNumberBg())
highlightColor = t.DiffHighlightRemoved()
lineNumberStyle = lineNumberStyle.Background(t.DiffRemovedLineNumberBg()).Foreground(t.DiffRemoved())
highlightColor = t.DiffHighlightRemoved() // TODO: handle "none"
if dl.OldLineNo > 0 {
lineNum = fmt.Sprintf("%6d ", dl.OldLineNo)
} else {
@@ -743,8 +774,8 @@ func renderUnifiedLine(fileName string, dl DiffLine, width int, t theme.Theme) s
case LineAdded:
marker = "+"
bgStyle = addedLineStyle
lineNumberStyle = lineNumberStyle.Foreground(t.DiffAdded()).Background(t.DiffAddedLineNumberBg())
highlightColor = t.DiffHighlightAdded()
lineNumberStyle = lineNumberStyle.Background(t.DiffAddedLineNumberBg()).Foreground(t.DiffAdded())
highlightColor = t.DiffHighlightAdded() // TODO: handle "none"
if dl.NewLineNo > 0 {
lineNum = fmt.Sprintf(" %7d", dl.NewLineNo)
} else {
@@ -766,7 +797,7 @@ func renderUnifiedLine(fileName string, dl DiffLine, width int, t theme.Theme) s
// Render the content
prefixWidth := ansi.StringWidth(prefix)
contentWidth := width - prefixWidth
content := renderLineContent(fileName, dl, bgStyle, highlightColor, contentWidth, t)
content := renderLineContent(fileName, dl, bgStyle, highlightColor, contentWidth)
return prefix + content
}
@@ -780,7 +811,7 @@ func renderDiffColumnLine(
t theme.Theme,
) string {
if dl == nil {
contextLineStyle := lipgloss.NewStyle().Background(t.DiffContextBg())
contextLineStyle := stylesi.NewStyle().Background(t.DiffContextBg())
return contextLineStyle.Width(colWidth).Render("")
}
@@ -788,7 +819,7 @@ func renderDiffColumnLine(
// Determine line style based on line type and column
var marker string
var bgStyle lipgloss.Style
var bgStyle stylesi.Style
var lineNum string
var highlightColor compat.AdaptiveColor
@@ -798,8 +829,8 @@ func renderDiffColumnLine(
case LineRemoved:
marker = "-"
bgStyle = removedLineStyle
lineNumberStyle = lineNumberStyle.Foreground(t.DiffRemoved()).Background(t.DiffRemovedLineNumberBg())
highlightColor = t.DiffHighlightRemoved()
lineNumberStyle = lineNumberStyle.Background(t.DiffRemovedLineNumberBg()).Foreground(t.DiffRemoved())
highlightColor = t.DiffHighlightRemoved() // TODO: handle "none"
case LineAdded:
marker = "?"
bgStyle = contextLineStyle
@@ -818,7 +849,7 @@ func renderDiffColumnLine(
case LineAdded:
marker = "+"
bgStyle = addedLineStyle
lineNumberStyle = lineNumberStyle.Foreground(t.DiffAdded()).Background(t.DiffAddedLineNumberBg())
lineNumberStyle = lineNumberStyle.Background(t.DiffAddedLineNumberBg()).Foreground(t.DiffAdded())
highlightColor = t.DiffHighlightAdded()
case LineRemoved:
marker = "?"
@@ -849,7 +880,7 @@ func renderDiffColumnLine(
// Render the content
prefixWidth := ansi.StringWidth(prefix)
contentWidth := colWidth - prefixWidth
content := renderLineContent(fileName, *dl, bgStyle, highlightColor, contentWidth, t)
content := renderLineContent(fileName, *dl, bgStyle, highlightColor, contentWidth)
return prefix + content
}
@@ -910,11 +941,22 @@ func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) str
leftWidth := colWidth
rightWidth := config.TotalWidth - colWidth
var sb strings.Builder
for _, p := range pairs {
leftStr := renderLeftColumn(fileName, p.left, leftWidth)
rightStr := renderRightColumn(fileName, p.right, rightWidth)
sb.WriteString(leftStr + rightStr + "\n")
}
util.WriteStringsPar(&sb, pairs, func(p linePair) string {
wg := &sync.WaitGroup{}
var leftStr, rightStr string
wg.Add(2)
go func() {
defer wg.Done()
leftStr = renderLeftColumn(fileName, p.left, leftWidth)
}()
go func() {
defer wg.Done()
rightStr = renderRightColumn(fileName, p.right, rightWidth)
}()
wg.Wait()
return leftStr + rightStr + "\n"
})
return sb.String()
}
@@ -928,7 +970,8 @@ func FormatUnifiedDiff(filename string, diffText string, opts ...UnifiedOption)
var sb strings.Builder
for _, h := range diffResult.Hunks {
sb.WriteString(RenderUnifiedHunk(filename, h, opts...))
unifiedDiff := RenderUnifiedHunk(filename, h, opts...)
sb.WriteString(unifiedDiff)
}
return sb.String(), nil
@@ -944,7 +987,7 @@ func FormatDiff(filename string, diffText string, opts ...SideBySideOption) (str
var sb strings.Builder
// config := NewSideBySideConfig(opts...)
for _, h := range diffResult.Hunks {
util.WriteStringsPar(&sb, diffResult.Hunks, func(h Hunk) string {
// sb.WriteString(
// lipgloss.NewStyle().
// Background(t.DiffHunkHeader()).
@@ -952,8 +995,8 @@ func FormatDiff(filename string, diffText string, opts ...SideBySideOption) (str
// Width(config.TotalWidth).
// Render(h.Header) + "\n",
// )
sb.WriteString(RenderSideBySideHunk(filename, h, opts...))
}
return RenderSideBySideHunk(filename, h, opts...)
})
return sb.String(), nil
}
@@ -5,7 +5,6 @@ import (
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/muesli/reflow/truncate"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
@@ -174,19 +173,20 @@ type StringItem string
func (s StringItem) Render(selected bool, width int) string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
baseStyle := styles.NewStyle()
truncatedStr := truncate.StringWithTail(string(s), uint(width-1), "...")
var itemStyle lipgloss.Style
var itemStyle styles.Style
if selected {
itemStyle = baseStyle.
Background(t.Primary()).
Foreground(t.Background()).
Foreground(t.BackgroundElement()).
Width(width).
PaddingLeft(1)
} else {
itemStyle = baseStyle.
Foreground(t.TextMuted()).
PaddingLeft(1)
}
@@ -90,12 +90,8 @@ func (m *Modal) Render(contentView string, background string) string {
innerWidth := outerWidth - 4
// Base style for the modal
baseStyle := styles.BaseStyle().
Background(t.BackgroundElement()).
Foreground(t.TextMuted())
baseStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundElement())
// Add title if provided
var finalContent string
if m.title != "" {
titleStyle := baseStyle.
+2 -4
View File
@@ -3,7 +3,7 @@ package qr
import (
"strings"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"rsc.io/qr"
)
@@ -23,9 +23,7 @@ func Generate(text string) (string, int, error) {
}
// Create lipgloss style for QR code with theme colors
qrStyle := lipgloss.NewStyle().
Foreground(t.Text()).
Background(t.Background())
qrStyle := styles.NewStyleWithColors(t.Text(), t.Background())
var result strings.Builder
@@ -36,18 +36,19 @@ func (m statusComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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
base := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundElement()).Render
emphasis := styles.NewStyle().Foreground(t.Text()).Background(t.BackgroundElement()).Bold(true).Render
open := base("open")
code := emphasis("code ")
version := base(m.app.Version)
return styles.Padded().
return styles.NewStyle().
Background(t.BackgroundElement()).
Padding(0, 1).
Render(open + code + version)
}
func formatTokensAndCost(tokens float32, contextWindow float32, cost float32) string {
func formatTokensAndCost(tokens float64, contextWindow float64, cost float64) string {
// Format tokens in human-readable format (e.g., 110K, 1.2M)
var formattedTokens string
switch {
@@ -76,8 +77,8 @@ func formatTokensAndCost(tokens float32, contextWindow float32, cost float32) st
func (m statusComponent) View() string {
t := theme.CurrentTheme()
if m.app.Session.Id == "" {
return styles.BaseStyle().
if m.app.Session.ID == "" {
return styles.NewStyle().
Background(t.Background()).
Width(m.width).
Height(2).
@@ -86,34 +87,38 @@ func (m statusComponent) View() string {
logo := m.logo()
cwd := styles.Padded().
cwd := styles.NewStyle().
Foreground(t.TextMuted()).
Background(t.BackgroundPanel()).
Padding(0, 1).
Render(m.app.Info.Path.Cwd)
sessionInfo := ""
if m.app.Session.Id != "" {
tokens := float32(0)
cost := float32(0)
if m.app.Session.ID != "" {
tokens := float64(0)
cost := float64(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)
cost += message.Metadata.Assistant.Cost
usage := message.Metadata.Assistant.Tokens
if usage.Output > 0 {
if message.Metadata.Assistant.Summary {
tokens = usage.Output
continue
}
tokens = (usage.Input +
usage.Cache.Write +
usage.Cache.Read +
usage.Output +
usage.Reasoning)
}
}
sessionInfo = styles.Padded().
Background(t.BackgroundElement()).
sessionInfo = styles.NewStyle().
Foreground(t.TextMuted()).
Background(t.BackgroundElement()).
Padding(0, 1).
Render(formatTokensAndCost(tokens, contextWindow, cost))
}
@@ -123,11 +128,11 @@ func (m statusComponent) View() string {
0,
m.width-lipgloss.Width(logo)-lipgloss.Width(cwd)-lipgloss.Width(sessionInfo),
)
spacer := lipgloss.NewStyle().Background(t.BackgroundPanel()).Width(space).Render("")
spacer := styles.NewStyle().Background(t.BackgroundPanel()).Width(space).Render("")
status := logo + cwd + spacer + sessionInfo
blank := styles.BaseStyle().Background(t.Background()).Width(m.width).Render("")
blank := styles.NewStyle().Background(t.Background()).Width(m.width).Render("")
return blank + "\n" + status
}
@@ -90,9 +90,9 @@ func (tm *ToastManager) Update(msg tea.Msg) (*ToastManager, tea.Cmd) {
func (tm *ToastManager) renderSingleToast(toast Toast) string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle().
Background(t.BackgroundElement()).
baseStyle := styles.NewStyle().
Foreground(t.Text()).
Background(t.BackgroundElement()).
Padding(1, 2)
maxWidth := max(40, layout.Current.Viewport.Width/3)
@@ -101,15 +101,14 @@ func (tm *ToastManager) renderSingleToast(toast Toast) string {
// Build content with wrapping
var content strings.Builder
if toast.Title != nil {
titleStyle := lipgloss.NewStyle().
Foreground(toast.Color).
titleStyle := styles.NewStyle().Foreground(toast.Color).
Bold(true)
content.WriteString(titleStyle.Render(*toast.Title))
content.WriteString("\n")
}
// Wrap message text
messageStyle := lipgloss.NewStyle()
messageStyle := styles.NewStyle()
contentWidth := lipgloss.Width(toast.Message)
if contentWidth > contentMaxWidth {
messageStyle = messageStyle.Width(contentMaxWidth)
-8
View File
@@ -7,7 +7,6 @@ import (
"os"
"github.com/BurntSushi/toml"
"github.com/sst/opencode/pkg/client"
)
type State struct {
@@ -22,13 +21,6 @@ func NewState() *State {
}
}
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 SaveState(filePath string, state *State) error {
-293
View File
@@ -1,293 +0,0 @@
package layout
import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/theme"
)
type Container interface {
tea.Model
tea.ViewModel
Sizeable
Focusable
Alignable
}
type container struct {
width int
height int
x int
y int
content tea.ViewModel
paddingTop int
paddingRight int
paddingBottom int
paddingLeft int
borderTop bool
borderRight bool
borderBottom bool
borderLeft bool
borderStyle lipgloss.Border
maxWidth int
align lipgloss.Position
focused bool
}
func (c *container) Init() tea.Cmd {
if model, ok := c.content.(tea.Model); ok {
return model.Init()
}
return nil
}
func (c *container) Update(msg tea.Msg) (tea.Model, tea.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 {
t := theme.CurrentTheme()
style := lipgloss.NewStyle()
width := c.width
height := c.height
// Apply max width constraint if set
if c.maxWidth > 0 && width > c.maxWidth {
width = c.maxWidth
}
style = style.Background(t.Background())
// Apply border if any side is enabled
if c.borderTop || c.borderRight || c.borderBottom || c.borderLeft {
// Adjust width and height for borders
if c.borderTop {
height--
}
if c.borderBottom {
height--
}
if c.borderLeft {
width--
}
if c.borderRight {
width--
}
style = style.Border(c.borderStyle, c.borderTop, c.borderRight, c.borderBottom, c.borderLeft)
// Use primary color for border if focused
if c.focused {
style = style.BorderBackground(t.Background()).BorderForeground(t.Primary())
} else {
style = style.BorderBackground(t.Background()).BorderForeground(t.Border())
}
}
style = style.
Width(width).
Height(height).
PaddingTop(c.paddingTop).
PaddingRight(c.paddingRight).
PaddingBottom(c.paddingBottom).
PaddingLeft(c.paddingLeft)
return style.Render(c.content.View())
}
func (c *container) SetSize(width, height int) tea.Cmd {
c.width = width
c.height = height
// Apply max width constraint if set
effectiveWidth := width
if c.maxWidth > 0 && width > c.maxWidth {
effectiveWidth = c.maxWidth
}
// If the content implements Sizeable, adjust its size to account for padding and borders
if sizeable, ok := c.content.(Sizeable); ok {
// Calculate horizontal space taken by padding and borders
horizontalSpace := c.paddingLeft + c.paddingRight
if c.borderLeft {
horizontalSpace++
}
if c.borderRight {
horizontalSpace++
}
// Calculate vertical space taken by padding and borders
verticalSpace := c.paddingTop + c.paddingBottom
if c.borderTop {
verticalSpace++
}
if c.borderBottom {
verticalSpace++
}
// Set content size with adjusted dimensions
contentWidth := max(0, effectiveWidth-horizontalSpace)
contentHeight := max(0, height-verticalSpace)
return sizeable.SetSize(contentWidth, contentHeight)
}
return nil
}
func (c *container) GetSize() (int, int) {
return min(c.width, c.maxWidth), c.height
}
func (c *container) MaxWidth() int {
return c.maxWidth
}
func (c *container) Alignment() lipgloss.Position {
return c.align
}
// Focus sets the container as focused
func (c *container) Focus() tea.Cmd {
c.focused = true
if focusable, ok := c.content.(Focusable); ok {
return focusable.Focus()
}
return nil
}
// Blur removes focus from the container
func (c *container) Blur() tea.Cmd {
c.focused = false
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
func (c *container) GetPosition() (x, y int) {
return c.x, c.y
}
func (c *container) SetPosition(x, y int) {
c.x = x
c.y = y
}
type ContainerOption func(*container)
func NewContainer(content tea.ViewModel, options ...ContainerOption) Container {
c := &container{
content: content,
borderStyle: lipgloss.NormalBorder(),
}
for _, option := range options {
option(c)
}
return c
}
// Padding options
func WithPadding(top, right, bottom, left int) ContainerOption {
return func(c *container) {
c.paddingTop = top
c.paddingRight = right
c.paddingBottom = bottom
c.paddingLeft = left
}
}
func WithPaddingAll(padding int) ContainerOption {
return WithPadding(padding, padding, padding, padding)
}
func WithPaddingHorizontal(padding int) ContainerOption {
return func(c *container) {
c.paddingLeft = padding
c.paddingRight = padding
}
}
func WithPaddingVertical(padding int) ContainerOption {
return func(c *container) {
c.paddingTop = padding
c.paddingBottom = padding
}
}
func WithBorder(top, right, bottom, left bool) ContainerOption {
return func(c *container) {
c.borderTop = top
c.borderRight = right
c.borderBottom = bottom
c.borderLeft = left
}
}
func WithBorderAll() ContainerOption {
return WithBorder(true, true, true, true)
}
func WithBorderHorizontal() ContainerOption {
return WithBorder(true, false, true, false)
}
func WithBorderVertical() ContainerOption {
return WithBorder(false, true, false, true)
}
func WithBorderStyle(style lipgloss.Border) ContainerOption {
return func(c *container) {
c.borderStyle = style
}
}
func WithRoundedBorder() ContainerOption {
return WithBorderStyle(lipgloss.RoundedBorder())
}
func WithThickBorder() ContainerOption {
return WithBorderStyle(lipgloss.ThickBorder())
}
func WithDoubleBorder() ContainerOption {
return WithBorderStyle(lipgloss.DoubleBorder())
}
func WithMaxWidth(maxWidth int) ContainerOption {
return func(c *container) {
c.maxWidth = maxWidth
}
}
func WithAlign(align lipgloss.Position) ContainerOption {
return func(c *container) {
c.align = align
}
}
func WithAlignLeft() ContainerOption {
return WithAlign(lipgloss.Left)
}
func WithAlignCenter() ContainerOption {
return WithAlign(lipgloss.Center)
}
func WithAlignRight() ContainerOption {
return WithAlign(lipgloss.Right)
}
+220 -220
View File
@@ -1,254 +1,254 @@
package layout
import (
tea "github.com/charmbracelet/bubbletea/v2"
"strings"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/styles"
)
type FlexDirection int
type Direction int
const (
FlexDirectionHorizontal FlexDirection = iota
FlexDirectionVertical
Row Direction = iota
Column
)
type FlexChildSize struct {
Fixed bool
Size int
type Justify int
const (
JustifyStart Justify = iota
JustifyEnd
JustifyCenter
JustifySpaceBetween
JustifySpaceAround
)
type Align int
const (
AlignStart Align = iota
AlignEnd
AlignCenter
AlignStretch // Only applicable in the cross-axis
)
type FlexOptions struct {
Direction Direction
Justify Justify
Align Align
Width int
Height int
}
var FlexChildSizeGrow = FlexChildSize{Fixed: false}
func FlexChildSizeFixed(size int) FlexChildSize {
return FlexChildSize{Fixed: true, Size: size}
type FlexItem struct {
View string
FixedSize int // Fixed size in the main axis (width for Row, height for Column)
Grow bool // If true, the item will grow to fill available space
}
type FlexLayout interface {
tea.ViewModel
Sizeable
SetChildren(panes []tea.ViewModel) tea.Cmd
SetSizes(sizes []FlexChildSize) tea.Cmd
SetDirection(direction FlexDirection) tea.Cmd
}
type flexLayout struct {
width int
height int
direction FlexDirection
children []tea.ViewModel
sizes []FlexChildSize
}
type FlexLayoutOption func(*flexLayout)
func (f *flexLayout) View() string {
if len(f.children) == 0 {
// Render lays out a series of view strings based on flexbox-like rules.
func Render(opts FlexOptions, items ...FlexItem) string {
if len(items) == 0 {
return ""
}
t := theme.CurrentTheme()
views := make([]string, 0, len(f.children))
for i, child := range f.children {
if child == nil {
continue
}
// Calculate dimensions for each item
mainAxisSize := opts.Width
crossAxisSize := opts.Height
if opts.Direction == Column {
mainAxisSize = opts.Height
crossAxisSize = opts.Width
}
alignment := lipgloss.Center
if alignable, ok := child.(Alignable); ok {
alignment = alignable.Alignment()
// Calculate total fixed size and count grow items
totalFixedSize := 0
growCount := 0
for _, item := range items {
if item.FixedSize > 0 {
totalFixedSize += item.FixedSize
} else if item.Grow {
growCount++
}
var childWidth, childHeight int
if f.direction == FlexDirectionHorizontal {
childWidth, childHeight = f.calculateChildSize(i)
view := lipgloss.PlaceHorizontal(
childWidth,
alignment,
child.View(),
// TODO: make configurable WithBackgroundStyle
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
)
views = append(views, view)
}
// Calculate available space for grow items
availableSpace := max(mainAxisSize-totalFixedSize, 0)
// Calculate size for each grow item
growItemSize := 0
if growCount > 0 && availableSpace > 0 {
growItemSize = availableSpace / growCount
}
// Prepare sized views
sizedViews := make([]string, len(items))
actualSizes := make([]int, len(items))
for i, item := range items {
view := item.View
// Determine the size for this item
itemSize := 0
if item.FixedSize > 0 {
itemSize = item.FixedSize
} else if item.Grow && growItemSize > 0 {
itemSize = growItemSize
} else {
childWidth, childHeight = f.calculateChildSize(i)
view := lipgloss.Place(
f.width,
childHeight,
lipgloss.Center,
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) calculateChildSize(index int) (width, height int) {
if index >= len(f.children) {
return 0, 0
}
totalFixed := 0
flexCount := 0
for i, child := range f.children {
if child == nil {
continue
}
if i < len(f.sizes) && f.sizes[i].Fixed {
if f.direction == FlexDirectionHorizontal {
totalFixed += f.sizes[i].Size
// No fixed size and not growing - use natural size
if opts.Direction == Row {
itemSize = lipgloss.Width(view)
} else {
totalFixed += f.sizes[i].Size
itemSize = lipgloss.Height(view)
}
}
// Apply size constraints
if opts.Direction == Row {
// For row direction, constrain width and handle height alignment
if itemSize > 0 {
view = styles.NewStyle().
Width(itemSize).
Height(crossAxisSize).
Render(view)
}
// Apply cross-axis alignment
switch opts.Align {
case AlignCenter:
view = lipgloss.PlaceVertical(crossAxisSize, lipgloss.Center, view)
case AlignEnd:
view = lipgloss.PlaceVertical(crossAxisSize, lipgloss.Bottom, view)
case AlignStart:
view = lipgloss.PlaceVertical(crossAxisSize, lipgloss.Top, view)
case AlignStretch:
// Already stretched by Height setting above
}
} else {
flexCount++
// For column direction, constrain height and handle width alignment
if itemSize > 0 {
view = styles.NewStyle().
Height(itemSize).
Width(crossAxisSize).
Render(view)
}
// Apply cross-axis alignment
switch opts.Align {
case AlignCenter:
view = lipgloss.PlaceHorizontal(crossAxisSize, lipgloss.Center, view)
case AlignEnd:
view = lipgloss.PlaceHorizontal(crossAxisSize, lipgloss.Right, view)
case AlignStart:
view = lipgloss.PlaceHorizontal(crossAxisSize, lipgloss.Left, view)
case AlignStretch:
// Already stretched by Width setting above
}
}
sizedViews[i] = view
if opts.Direction == Row {
actualSizes[i] = lipgloss.Width(view)
} else {
actualSizes[i] = lipgloss.Height(view)
}
}
if f.direction == FlexDirectionHorizontal {
height = f.height
if index < len(f.sizes) && f.sizes[index].Fixed {
width = f.sizes[index].Size
} else if flexCount > 0 {
remainingSpace := f.width - totalFixed
width = remainingSpace / flexCount
// Calculate total actual size
totalActualSize := 0
for _, size := range actualSizes {
totalActualSize += size
}
// Apply justification
remainingSpace := max(mainAxisSize-totalActualSize, 0)
// Calculate spacing based on justification
var spaceBefore, spaceBetween, spaceAfter int
switch opts.Justify {
case JustifyStart:
spaceAfter = remainingSpace
case JustifyEnd:
spaceBefore = remainingSpace
case JustifyCenter:
spaceBefore = remainingSpace / 2
spaceAfter = remainingSpace - spaceBefore
case JustifySpaceBetween:
if len(items) > 1 {
spaceBetween = remainingSpace / (len(items) - 1)
} else {
spaceAfter = remainingSpace
}
case JustifySpaceAround:
if len(items) > 0 {
spaceAround := remainingSpace / (len(items) * 2)
spaceBefore = spaceAround
spaceAfter = spaceAround
spaceBetween = spaceAround * 2
}
}
// Build the final layout
var parts []string
// Add space before if needed
if spaceBefore > 0 {
if opts.Direction == Row {
parts = append(parts, strings.Repeat(" ", spaceBefore))
} else {
parts = append(parts, strings.Repeat("\n", spaceBefore))
}
}
// Add items with spacing
for i, view := range sizedViews {
parts = append(parts, view)
// Add space between items (not after the last one)
if i < len(sizedViews)-1 && spaceBetween > 0 {
if opts.Direction == Row {
parts = append(parts, strings.Repeat(" ", spaceBetween))
} else {
parts = append(parts, strings.Repeat("\n", spaceBetween))
}
}
}
// Add space after if needed
if spaceAfter > 0 {
if opts.Direction == Row {
parts = append(parts, strings.Repeat(" ", spaceAfter))
} else {
parts = append(parts, strings.Repeat("\n", spaceAfter))
}
}
// Join the parts
if opts.Direction == Row {
return lipgloss.JoinHorizontal(lipgloss.Top, parts...)
} else {
width = f.width
if index < len(f.sizes) && f.sizes[index].Fixed {
height = f.sizes[index].Size
} else if flexCount > 0 {
remainingSpace := f.height - totalFixed
height = remainingSpace / flexCount
}
}
return width, height
}
func (f *flexLayout) SetSize(width, height int) tea.Cmd {
f.width = width
f.height = height
var cmds []tea.Cmd
currentX, currentY := 0, 0
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)
actualY = (f.height - paneHeight) / 2
} else {
// In vertical layout, horizontal alignment affects X position
contentWidth := paneWidth
if alignable, ok := child.(Alignable); ok {
if alignable.MaxWidth() > 0 && contentWidth > alignable.MaxWidth() {
contentWidth = alignable.MaxWidth()
}
}
switch alignment {
case lipgloss.Center:
actualX = (f.width - contentWidth) / 2
case lipgloss.Right:
actualX = f.width - contentWidth
case lipgloss.Left:
actualX = 0
}
}
// Set position if the pane is Alignable
if c, ok := child.(Alignable); ok {
c.SetPosition(actualX, actualY)
}
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
} else {
currentY += paneHeight
}
}
}
return tea.Batch(cmds...)
}
func (f *flexLayout) GetSize() (int, int) {
return f.width, f.height
}
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) SetSizes(sizes []FlexChildSize) tea.Cmd {
f.sizes = sizes
if f.width > 0 && f.height > 0 {
return f.SetSize(f.width, f.height)
}
return nil
}
func (f *flexLayout) SetDirection(direction FlexDirection) tea.Cmd {
f.direction = direction
if f.width > 0 && f.height > 0 {
return f.SetSize(f.width, f.height)
}
return nil
}
func NewFlexLayout(children []tea.ViewModel, options ...FlexLayoutOption) FlexLayout {
layout := &flexLayout{
children: children,
direction: FlexDirectionHorizontal,
sizes: []FlexChildSize{},
}
for _, option := range options {
option(layout)
}
return layout
}
func WithDirection(direction FlexDirection) FlexLayoutOption {
return func(f *flexLayout) {
f.direction = direction
return lipgloss.JoinVertical(lipgloss.Left, parts...)
}
}
func WithChildren(children ...tea.ViewModel) FlexLayoutOption {
return func(f *flexLayout) {
f.children = children
}
// Helper function to create a simple vertical layout
func Vertical(width, height int, items ...FlexItem) string {
return Render(FlexOptions{
Direction: Column,
Width: width,
Height: height,
Justify: JustifyStart,
Align: AlignStretch,
}, items...)
}
func WithSizes(sizes ...FlexChildSize) FlexLayoutOption {
return func(f *flexLayout) {
f.sizes = sizes
}
// Helper function to create a simple horizontal layout
func Horizontal(width, height int, items ...FlexItem) string {
return Render(FlexOptions{
Direction: Row,
Width: width,
Height: height,
Justify: JustifyStart,
Align: AlignStretch,
}, items...)
}
-34
View File
@@ -1,11 +1,7 @@
package layout
import (
"reflect"
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
)
var Current *LayoutInfo
@@ -34,33 +30,3 @@ type Modal interface {
Render(background string) string
Close() tea.Cmd
}
type Focusable interface {
Focus() tea.Cmd
Blur() tea.Cmd
IsFocused() bool
}
type Sizeable interface {
SetSize(width, height int) tea.Cmd
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 {
return nil
}
for i := range typ.NumField() {
v := reflect.ValueOf(t).Field(i)
bindings = append(bindings, v.Interface().(key.Binding))
}
return
}
@@ -1,6 +1,9 @@
package styles
import "image/color"
type TerminalInfo struct {
Background color.Color
BackgroundIsDark bool
}
@@ -8,6 +11,7 @@ var Terminal *TerminalInfo
func init() {
Terminal = &TerminalInfo{
Background: color.Black,
BackgroundIsDark: true,
}
}
+62 -55
View File
@@ -3,6 +3,7 @@ package styles
import (
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/glamour/ansi"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/lipgloss/v2/compat"
"github.com/lucasb-eyer/go-colorful"
"github.com/sst/opencode/internal/theme"
@@ -29,7 +30,7 @@ func GetMarkdownRenderer(width int, backgroundColor compat.AdaptiveColor) *glamo
// using adaptive colors from the provided theme.
func generateMarkdownStyleConfig(backgroundColor compat.AdaptiveColor) ansi.StyleConfig {
t := theme.CurrentTheme()
background := stringPtr(AdaptiveColorToString(backgroundColor))
background := AdaptiveColorToString(backgroundColor)
return ansi.StyleConfig{
Document: ansi.StyleBlock{
@@ -37,12 +38,12 @@ func generateMarkdownStyleConfig(backgroundColor compat.AdaptiveColor) ansi.Styl
BlockPrefix: "",
BlockSuffix: "",
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.MarkdownText())),
Color: AdaptiveColorToString(t.MarkdownText()),
},
},
BlockQuote: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Color: stringPtr(AdaptiveColorToString(t.MarkdownBlockQuote())),
Color: AdaptiveColorToString(t.MarkdownBlockQuote()),
Italic: boolPtr(true),
Prefix: "┃ ",
},
@@ -54,108 +55,108 @@ func generateMarkdownStyleConfig(backgroundColor compat.AdaptiveColor) ansi.Styl
StyleBlock: ansi.StyleBlock{
IndentToken: stringPtr(" "),
StylePrimitive: ansi.StylePrimitive{
Color: stringPtr(AdaptiveColorToString(t.MarkdownText())),
Color: AdaptiveColorToString(t.MarkdownText()),
},
},
},
Heading: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
BlockSuffix: "\n",
Color: stringPtr(AdaptiveColorToString(t.MarkdownHeading())),
Color: AdaptiveColorToString(t.MarkdownHeading()),
Bold: boolPtr(true),
},
},
H1: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "# ",
Color: stringPtr(AdaptiveColorToString(t.MarkdownHeading())),
Color: AdaptiveColorToString(t.MarkdownHeading()),
Bold: boolPtr(true),
},
},
H2: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "## ",
Color: stringPtr(AdaptiveColorToString(t.MarkdownHeading())),
Color: AdaptiveColorToString(t.MarkdownHeading()),
Bold: boolPtr(true),
},
},
H3: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "### ",
Color: stringPtr(AdaptiveColorToString(t.MarkdownHeading())),
Color: AdaptiveColorToString(t.MarkdownHeading()),
Bold: boolPtr(true),
},
},
H4: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "#### ",
Color: stringPtr(AdaptiveColorToString(t.MarkdownHeading())),
Color: AdaptiveColorToString(t.MarkdownHeading()),
Bold: boolPtr(true),
},
},
H5: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "##### ",
Color: stringPtr(AdaptiveColorToString(t.MarkdownHeading())),
Color: AdaptiveColorToString(t.MarkdownHeading()),
Bold: boolPtr(true),
},
},
H6: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "###### ",
Color: stringPtr(AdaptiveColorToString(t.MarkdownHeading())),
Color: AdaptiveColorToString(t.MarkdownHeading()),
Bold: boolPtr(true),
},
},
Strikethrough: ansi.StylePrimitive{
CrossedOut: boolPtr(true),
Color: stringPtr(AdaptiveColorToString(t.TextMuted())),
Color: AdaptiveColorToString(t.TextMuted()),
},
Emph: ansi.StylePrimitive{
Color: stringPtr(AdaptiveColorToString(t.MarkdownEmph())),
Color: AdaptiveColorToString(t.MarkdownEmph()),
Italic: boolPtr(true),
},
Strong: ansi.StylePrimitive{
Bold: boolPtr(true),
Color: stringPtr(AdaptiveColorToString(t.MarkdownStrong())),
Color: AdaptiveColorToString(t.MarkdownStrong()),
},
HorizontalRule: ansi.StylePrimitive{
Color: stringPtr(AdaptiveColorToString(t.MarkdownHorizontalRule())),
Color: AdaptiveColorToString(t.MarkdownHorizontalRule()),
Format: "\n─────────────────────────────────────────\n",
},
Item: ansi.StylePrimitive{
BlockPrefix: "• ",
Color: stringPtr(AdaptiveColorToString(t.MarkdownListItem())),
Color: AdaptiveColorToString(t.MarkdownListItem()),
},
Enumeration: ansi.StylePrimitive{
BlockPrefix: ". ",
Color: stringPtr(AdaptiveColorToString(t.MarkdownListEnumeration())),
Color: AdaptiveColorToString(t.MarkdownListEnumeration()),
},
Task: ansi.StyleTask{
Ticked: "[✓] ",
Unticked: "[ ] ",
},
Link: ansi.StylePrimitive{
Color: stringPtr(AdaptiveColorToString(t.MarkdownLink())),
Color: AdaptiveColorToString(t.MarkdownLink()),
Underline: boolPtr(true),
},
LinkText: ansi.StylePrimitive{
Color: stringPtr(AdaptiveColorToString(t.MarkdownLinkText())),
Color: AdaptiveColorToString(t.MarkdownLinkText()),
Bold: boolPtr(true),
},
Image: ansi.StylePrimitive{
Color: stringPtr(AdaptiveColorToString(t.MarkdownImage())),
Color: AdaptiveColorToString(t.MarkdownImage()),
Underline: boolPtr(true),
Format: "🖼 {{.text}}",
},
ImageText: ansi.StylePrimitive{
Color: stringPtr(AdaptiveColorToString(t.MarkdownImageText())),
Color: AdaptiveColorToString(t.MarkdownImageText()),
Format: "{{.text}}",
},
Code: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.MarkdownCode())),
Color: AdaptiveColorToString(t.MarkdownCode()),
Prefix: "",
Suffix: "",
},
@@ -165,7 +166,7 @@ func generateMarkdownStyleConfig(backgroundColor compat.AdaptiveColor) ansi.Styl
StylePrimitive: ansi.StylePrimitive{
BackgroundColor: background,
Prefix: " ",
Color: stringPtr(AdaptiveColorToString(t.MarkdownCodeBlock())),
Color: AdaptiveColorToString(t.MarkdownCodeBlock()),
},
},
Chroma: &ansi.Chroma{
@@ -174,109 +175,109 @@ func generateMarkdownStyleConfig(backgroundColor compat.AdaptiveColor) ansi.Styl
},
Text: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.MarkdownText())),
Color: AdaptiveColorToString(t.MarkdownText()),
},
Error: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.Error())),
Color: AdaptiveColorToString(t.Error()),
},
Comment: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.SyntaxComment())),
Color: AdaptiveColorToString(t.SyntaxComment()),
},
CommentPreproc: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.SyntaxKeyword())),
Color: AdaptiveColorToString(t.SyntaxKeyword()),
},
Keyword: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.SyntaxKeyword())),
Color: AdaptiveColorToString(t.SyntaxKeyword()),
},
KeywordReserved: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.SyntaxKeyword())),
Color: AdaptiveColorToString(t.SyntaxKeyword()),
},
KeywordNamespace: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.SyntaxKeyword())),
Color: AdaptiveColorToString(t.SyntaxKeyword()),
},
KeywordType: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.SyntaxType())),
Color: AdaptiveColorToString(t.SyntaxType()),
},
Operator: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.SyntaxOperator())),
Color: AdaptiveColorToString(t.SyntaxOperator()),
},
Punctuation: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.SyntaxPunctuation())),
Color: AdaptiveColorToString(t.SyntaxPunctuation()),
},
Name: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.SyntaxVariable())),
Color: AdaptiveColorToString(t.SyntaxVariable()),
},
NameBuiltin: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.SyntaxVariable())),
Color: AdaptiveColorToString(t.SyntaxVariable()),
},
NameTag: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.SyntaxKeyword())),
Color: AdaptiveColorToString(t.SyntaxKeyword()),
},
NameAttribute: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.SyntaxFunction())),
Color: AdaptiveColorToString(t.SyntaxFunction()),
},
NameClass: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.SyntaxType())),
Color: AdaptiveColorToString(t.SyntaxType()),
},
NameConstant: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.SyntaxVariable())),
Color: AdaptiveColorToString(t.SyntaxVariable()),
},
NameDecorator: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.SyntaxFunction())),
Color: AdaptiveColorToString(t.SyntaxFunction()),
},
NameFunction: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.SyntaxFunction())),
Color: AdaptiveColorToString(t.SyntaxFunction()),
},
LiteralNumber: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.SyntaxNumber())),
Color: AdaptiveColorToString(t.SyntaxNumber()),
},
LiteralString: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.SyntaxString())),
Color: AdaptiveColorToString(t.SyntaxString()),
},
LiteralStringEscape: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.SyntaxKeyword())),
Color: AdaptiveColorToString(t.SyntaxKeyword()),
},
GenericDeleted: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.DiffRemoved())),
Color: AdaptiveColorToString(t.DiffRemoved()),
},
GenericEmph: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.MarkdownEmph())),
Color: AdaptiveColorToString(t.MarkdownEmph()),
Italic: boolPtr(true),
},
GenericInserted: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.DiffAdded())),
Color: AdaptiveColorToString(t.DiffAdded()),
},
GenericStrong: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.MarkdownStrong())),
Color: AdaptiveColorToString(t.MarkdownStrong()),
Bold: boolPtr(true),
},
GenericSubheading: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.MarkdownHeading())),
Color: AdaptiveColorToString(t.MarkdownHeading()),
},
},
},
@@ -293,14 +294,14 @@ func generateMarkdownStyleConfig(backgroundColor compat.AdaptiveColor) ansi.Styl
},
DefinitionDescription: ansi.StylePrimitive{
BlockPrefix: "\n ",
Color: stringPtr(AdaptiveColorToString(t.MarkdownLinkText())),
Color: AdaptiveColorToString(t.MarkdownLinkText()),
},
Text: ansi.StylePrimitive{
Color: stringPtr(AdaptiveColorToString(t.MarkdownText())),
Color: AdaptiveColorToString(t.MarkdownText()),
},
Paragraph: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Color: stringPtr(AdaptiveColorToString(t.MarkdownText())),
Color: AdaptiveColorToString(t.MarkdownText()),
},
},
}
@@ -308,11 +309,17 @@ func generateMarkdownStyleConfig(backgroundColor compat.AdaptiveColor) ansi.Styl
// AdaptiveColorToString converts a compat.AdaptiveColor to the appropriate
// hex color string based on the current terminal background
func AdaptiveColorToString(color compat.AdaptiveColor) string {
func AdaptiveColorToString(color compat.AdaptiveColor) *string {
if Terminal.BackgroundIsDark {
if _, ok := color.Dark.(lipgloss.NoColor); ok {
return nil
}
c1, _ := colorful.MakeColor(color.Dark)
return c1.Hex()
return stringPtr(c1.Hex())
}
if _, ok := color.Light.(lipgloss.NoColor); ok {
return nil
}
c1, _ := colorful.MakeColor(color.Light)
return c1.Hex()
return stringPtr(c1.Hex())
}
+2 -149
View File
@@ -3,155 +3,8 @@ package styles
import (
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/lipgloss/v2/compat"
"github.com/sst/opencode/internal/theme"
)
// BaseStyle returns the base style with background and foreground colors
func BaseStyle() lipgloss.Style {
t := theme.CurrentTheme()
return lipgloss.NewStyle().Foreground(t.Text())
}
func Panel() lipgloss.Style {
t := theme.CurrentTheme()
return lipgloss.NewStyle().
Background(t.BackgroundPanel()).
Border(lipgloss.NormalBorder(), true, false, true, false).
BorderForeground(t.BorderSubtle()).
Foreground(t.Text())
}
// Regular returns a basic unstyled lipgloss.Style
func Regular() lipgloss.Style {
return lipgloss.NewStyle()
}
func Muted() lipgloss.Style {
t := theme.CurrentTheme()
return lipgloss.NewStyle().Foreground(t.TextMuted())
}
// Bold returns a bold style
func Bold() lipgloss.Style {
return BaseStyle().Bold(true)
}
// Padded returns a style with horizontal padding
func Padded() lipgloss.Style {
return BaseStyle().Padding(0, 1)
}
// Border returns a style with a normal border
func Border() lipgloss.Style {
t := theme.CurrentTheme()
return Regular().
Border(lipgloss.NormalBorder()).
BorderForeground(t.Border())
}
// ThickBorder returns a style with a thick border
func ThickBorder() lipgloss.Style {
t := theme.CurrentTheme()
return Regular().
Border(lipgloss.ThickBorder()).
BorderForeground(t.Border())
}
// DoubleBorder returns a style with a double border
func DoubleBorder() lipgloss.Style {
t := theme.CurrentTheme()
return Regular().
Border(lipgloss.DoubleBorder()).
BorderForeground(t.Border())
}
// FocusedBorder returns a style with a border using the focused border color
func FocusedBorder() lipgloss.Style {
t := theme.CurrentTheme()
return Regular().
Border(lipgloss.NormalBorder()).
BorderForeground(t.BorderActive())
}
// DimBorder returns a style with a border using the dim border color
func DimBorder() lipgloss.Style {
t := theme.CurrentTheme()
return Regular().
Border(lipgloss.NormalBorder()).
BorderForeground(t.BorderSubtle())
}
// PrimaryColor returns the primary color from the current theme
func PrimaryColor() compat.AdaptiveColor {
return theme.CurrentTheme().Primary()
}
// SecondaryColor returns the secondary color from the current theme
func SecondaryColor() compat.AdaptiveColor {
return theme.CurrentTheme().Secondary()
}
// AccentColor returns the accent color from the current theme
func AccentColor() compat.AdaptiveColor {
return theme.CurrentTheme().Accent()
}
// ErrorColor returns the error color from the current theme
func ErrorColor() compat.AdaptiveColor {
return theme.CurrentTheme().Error()
}
// WarningColor returns the warning color from the current theme
func WarningColor() compat.AdaptiveColor {
return theme.CurrentTheme().Warning()
}
// SuccessColor returns the success color from the current theme
func SuccessColor() compat.AdaptiveColor {
return theme.CurrentTheme().Success()
}
// InfoColor returns the info color from the current theme
func InfoColor() compat.AdaptiveColor {
return theme.CurrentTheme().Info()
}
// TextColor returns the text color from the current theme
func TextColor() compat.AdaptiveColor {
return theme.CurrentTheme().Text()
}
// TextMutedColor returns the muted text color from the current theme
func TextMutedColor() compat.AdaptiveColor {
return theme.CurrentTheme().TextMuted()
}
// BackgroundColor returns the background color from the current theme
func BackgroundColor() compat.AdaptiveColor {
return theme.CurrentTheme().Background()
}
// BackgroundPanelColor returns the subtle background color from the current theme
func BackgroundPanelColor() compat.AdaptiveColor {
return theme.CurrentTheme().BackgroundPanel()
}
// BackgroundElementColor returns the darker background color from the current theme
func BackgroundElementColor() compat.AdaptiveColor {
return theme.CurrentTheme().BackgroundElement()
}
// BorderColor returns the border color from the current theme
func BorderColor() compat.AdaptiveColor {
return theme.CurrentTheme().Border()
}
// BorderActiveColor returns the active border color from the current theme
func BorderActiveColor() compat.AdaptiveColor {
return theme.CurrentTheme().BorderActive()
}
// BorderSubtleColor returns the subtle border color from the current theme
func BorderSubtleColor() compat.AdaptiveColor {
return theme.CurrentTheme().BorderSubtle()
func WhitespaceStyle(bg compat.AdaptiveColor) lipgloss.WhitespaceOption {
return lipgloss.WithWhitespaceStyle(NewStyle().Background(bg).Lipgloss())
}
+295
View File
@@ -0,0 +1,295 @@
package styles
import (
"image/color"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/lipgloss/v2/compat"
)
// IsNoColor checks if a color is the special NoColor type
func IsNoColor(c color.Color) bool {
_, ok := c.(lipgloss.NoColor)
return ok
}
// Style wraps lipgloss.Style to provide a fluent API for handling "none" colors
type Style struct {
lipgloss.Style
}
// NewStyle creates a new Style with proper handling of "none" colors
func NewStyle() Style {
return Style{lipgloss.NewStyle()}
}
func (s Style) Lipgloss() lipgloss.Style {
return s.Style
}
// Foreground sets the foreground color, handling "none" appropriately
func (s Style) Foreground(c compat.AdaptiveColor) Style {
if IsNoColor(c.Dark) && IsNoColor(c.Light) {
return Style{s.Style.UnsetForeground()}
}
return Style{s.Style.Foreground(c)}
}
// Background sets the background color, handling "none" appropriately
func (s Style) Background(c compat.AdaptiveColor) Style {
if IsNoColor(c.Dark) && IsNoColor(c.Light) {
return Style{s.Style.UnsetBackground()}
}
return Style{s.Style.Background(c)}
}
// BorderForeground sets the border foreground color, handling "none" appropriately
func (s Style) BorderForeground(c compat.AdaptiveColor) Style {
if IsNoColor(c.Dark) && IsNoColor(c.Light) {
return Style{s.Style.UnsetBorderForeground()}
}
return Style{s.Style.BorderForeground(c)}
}
// BorderBackground sets the border background color, handling "none" appropriately
func (s Style) BorderBackground(c compat.AdaptiveColor) Style {
if IsNoColor(c.Dark) && IsNoColor(c.Light) {
return Style{s.Style.UnsetBorderBackground()}
}
return Style{s.Style.BorderBackground(c)}
}
// BorderTopForeground sets the border top foreground color, handling "none" appropriately
func (s Style) BorderTopForeground(c compat.AdaptiveColor) Style {
if IsNoColor(c.Dark) && IsNoColor(c.Light) {
return Style{s.Style.UnsetBorderTopForeground()}
}
return Style{s.Style.BorderTopForeground(c)}
}
// BorderTopBackground sets the border top background color, handling "none" appropriately
func (s Style) BorderTopBackground(c compat.AdaptiveColor) Style {
if IsNoColor(c.Dark) && IsNoColor(c.Light) {
return Style{s.Style.UnsetBorderTopBackground()}
}
return Style{s.Style.BorderTopBackground(c)}
}
// BorderBottomForeground sets the border bottom foreground color, handling "none" appropriately
func (s Style) BorderBottomForeground(c compat.AdaptiveColor) Style {
if IsNoColor(c.Dark) && IsNoColor(c.Light) {
return Style{s.Style.UnsetBorderBottomForeground()}
}
return Style{s.Style.BorderBottomForeground(c)}
}
// BorderBottomBackground sets the border bottom background color, handling "none" appropriately
func (s Style) BorderBottomBackground(c compat.AdaptiveColor) Style {
if IsNoColor(c.Dark) && IsNoColor(c.Light) {
return Style{s.Style.UnsetBorderBottomBackground()}
}
return Style{s.Style.BorderBottomBackground(c)}
}
// BorderLeftForeground sets the border left foreground color, handling "none" appropriately
func (s Style) BorderLeftForeground(c compat.AdaptiveColor) Style {
if IsNoColor(c.Dark) && IsNoColor(c.Light) {
return Style{s.Style.UnsetBorderLeftForeground()}
}
return Style{s.Style.BorderLeftForeground(c)}
}
// BorderLeftBackground sets the border left background color, handling "none" appropriately
func (s Style) BorderLeftBackground(c compat.AdaptiveColor) Style {
if IsNoColor(c.Dark) && IsNoColor(c.Light) {
return Style{s.Style.UnsetBorderLeftBackground()}
}
return Style{s.Style.BorderLeftBackground(c)}
}
// BorderRightForeground sets the border right foreground color, handling "none" appropriately
func (s Style) BorderRightForeground(c compat.AdaptiveColor) Style {
if IsNoColor(c.Dark) && IsNoColor(c.Light) {
return Style{s.Style.UnsetBorderRightForeground()}
}
return Style{s.Style.BorderRightForeground(c)}
}
// BorderRightBackground sets the border right background color, handling "none" appropriately
func (s Style) BorderRightBackground(c compat.AdaptiveColor) Style {
if IsNoColor(c.Dark) && IsNoColor(c.Light) {
return Style{s.Style.UnsetBorderRightBackground()}
}
return Style{s.Style.BorderRightBackground(c)}
}
// Render applies the style to a string
func (s Style) Render(str string) string {
return s.Style.Render(str)
}
// Common lipgloss.Style method delegations for seamless usage
func (s Style) Bold(v bool) Style {
return Style{s.Style.Bold(v)}
}
func (s Style) Italic(v bool) Style {
return Style{s.Style.Italic(v)}
}
func (s Style) Underline(v bool) Style {
return Style{s.Style.Underline(v)}
}
func (s Style) Strikethrough(v bool) Style {
return Style{s.Style.Strikethrough(v)}
}
func (s Style) Blink(v bool) Style {
return Style{s.Style.Blink(v)}
}
func (s Style) Faint(v bool) Style {
return Style{s.Style.Faint(v)}
}
func (s Style) Reverse(v bool) Style {
return Style{s.Style.Reverse(v)}
}
func (s Style) Width(i int) Style {
return Style{s.Style.Width(i)}
}
func (s Style) Height(i int) Style {
return Style{s.Style.Height(i)}
}
func (s Style) Padding(i ...int) Style {
return Style{s.Style.Padding(i...)}
}
func (s Style) PaddingTop(i int) Style {
return Style{s.Style.PaddingTop(i)}
}
func (s Style) PaddingBottom(i int) Style {
return Style{s.Style.PaddingBottom(i)}
}
func (s Style) PaddingLeft(i int) Style {
return Style{s.Style.PaddingLeft(i)}
}
func (s Style) PaddingRight(i int) Style {
return Style{s.Style.PaddingRight(i)}
}
func (s Style) Margin(i ...int) Style {
return Style{s.Style.Margin(i...)}
}
func (s Style) MarginTop(i int) Style {
return Style{s.Style.MarginTop(i)}
}
func (s Style) MarginBottom(i int) Style {
return Style{s.Style.MarginBottom(i)}
}
func (s Style) MarginLeft(i int) Style {
return Style{s.Style.MarginLeft(i)}
}
func (s Style) MarginRight(i int) Style {
return Style{s.Style.MarginRight(i)}
}
func (s Style) Border(b lipgloss.Border, sides ...bool) Style {
return Style{s.Style.Border(b, sides...)}
}
func (s Style) BorderStyle(b lipgloss.Border) Style {
return Style{s.Style.BorderStyle(b)}
}
func (s Style) BorderTop(v bool) Style {
return Style{s.Style.BorderTop(v)}
}
func (s Style) BorderBottom(v bool) Style {
return Style{s.Style.BorderBottom(v)}
}
func (s Style) BorderLeft(v bool) Style {
return Style{s.Style.BorderLeft(v)}
}
func (s Style) BorderRight(v bool) Style {
return Style{s.Style.BorderRight(v)}
}
func (s Style) Align(p ...lipgloss.Position) Style {
return Style{s.Style.Align(p...)}
}
func (s Style) AlignHorizontal(p lipgloss.Position) Style {
return Style{s.Style.AlignHorizontal(p)}
}
func (s Style) AlignVertical(p lipgloss.Position) Style {
return Style{s.Style.AlignVertical(p)}
}
func (s Style) Inline(v bool) Style {
return Style{s.Style.Inline(v)}
}
func (s Style) MaxWidth(n int) Style {
return Style{s.Style.MaxWidth(n)}
}
func (s Style) MaxHeight(n int) Style {
return Style{s.Style.MaxHeight(n)}
}
func (s Style) TabWidth(n int) Style {
return Style{s.Style.TabWidth(n)}
}
func (s Style) UnsetBold() Style {
return Style{s.Style.UnsetBold()}
}
func (s Style) UnsetItalic() Style {
return Style{s.Style.UnsetItalic()}
}
func (s Style) UnsetUnderline() Style {
return Style{s.Style.UnsetUnderline()}
}
func (s Style) UnsetStrikethrough() Style {
return Style{s.Style.UnsetStrikethrough()}
}
func (s Style) UnsetBlink() Style {
return Style{s.Style.UnsetBlink()}
}
func (s Style) UnsetFaint() Style {
return Style{s.Style.UnsetFaint()}
}
func (s Style) UnsetReverse() Style {
return Style{s.Style.UnsetReverse()}
}
func (s Style) Copy() Style {
return Style{s.Style}
}
func (s Style) Inherit(i Style) Style {
return Style{s.Style.Inherit(i.Style)}
}
+11 -2
View File
@@ -171,7 +171,7 @@ func (r *colorResolver) resolveColor(key string, value any) (any, error) {
switch v := value.(type) {
case string:
if strings.HasPrefix(v, "#") {
if strings.HasPrefix(v, "#") || v == "none" {
return v, nil
}
return r.resolveReference(v)
@@ -205,7 +205,7 @@ func (r *colorResolver) resolveColor(key string, value any) (any, error) {
func (r *colorResolver) resolveColorValue(value any) (any, error) {
switch v := value.(type) {
case string:
if strings.HasPrefix(v, "#") {
if strings.HasPrefix(v, "#") || v == "none" {
return v, nil
}
return r.resolveReference(v)
@@ -240,6 +240,12 @@ func (r *colorResolver) resolveReference(ref string) (any, error) {
func parseResolvedColor(value any) (compat.AdaptiveColor, error) {
switch v := value.(type) {
case string:
if v == "none" {
return compat.AdaptiveColor{
Dark: lipgloss.NoColor{},
Light: lipgloss.NoColor{},
}, nil
}
return compat.AdaptiveColor{
Dark: lipgloss.Color(v),
Light: lipgloss.Color(v),
@@ -277,6 +283,9 @@ func parseResolvedColor(value any) (compat.AdaptiveColor, error) {
func parseColorValue(value any) (color.Color, error) {
switch v := value.(type) {
case string:
if v == "none" {
return lipgloss.NoColor{}, nil
}
return lipgloss.Color(v), nil
case float64:
return lipgloss.Color(fmt.Sprintf("%d", int(v))), nil
+18 -12
View File
@@ -21,7 +21,7 @@ func TestLoadThemesFromJSON(t *testing.T) {
}
// Check for expected themes
expectedThemes := []string{"tokyonight", "opencode", "everforest", "ayu", "example"}
expectedThemes := []string{"tokyonight", "opencode", "everforest", "ayu"}
for _, expected := range expectedThemes {
found := slices.Contains(themes, expected)
if !found {
@@ -43,22 +43,28 @@ func TestLoadThemesFromJSON(t *testing.T) {
}
func TestColorReferenceResolution(t *testing.T) {
// Test the example theme which uses references
example := GetTheme("example")
if example == nil {
t.Fatal("Failed to get example theme")
// Load themes first
err := LoadThemesFromJSON()
if err != nil {
t.Fatalf("Failed to load themes: %v", err)
}
// Check that brandBlue reference was resolved
primary := example.Primary()
// Test a theme that uses references (e.g., solarized uses color definitions)
solarized := GetTheme("solarized")
if solarized == nil {
t.Fatal("Failed to get solarized theme")
}
// Check that color references were resolved
primary := solarized.Primary()
if primary.Dark == nil || primary.Light == nil {
t.Error("Primary color (brandBlue reference) not resolved")
t.Error("Primary color reference not resolved")
}
// Check that nested reference (borderActive -> primary -> brandBlue) works
borderActive := example.BorderActive()
if borderActive.Dark == nil || borderActive.Light == nil {
t.Error("BorderActive color (nested reference) not resolved")
// Check that all colors are properly resolved
text := solarized.Text()
if text.Dark == nil || text.Light == nil {
t.Error("Text color reference not resolved")
}
}
+129 -5
View File
@@ -2,19 +2,25 @@ package theme
import (
"fmt"
"image/color"
"slices"
"strconv"
"strings"
"sync"
"github.com/alecthomas/chroma/v2/styles"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/lipgloss/v2/compat"
"github.com/charmbracelet/x/ansi"
)
// Manager handles theme registration, selection, and retrieval.
// It maintains a registry of available themes and tracks the currently active theme.
type Manager struct {
themes map[string]Theme
currentName string
mu sync.RWMutex
themes map[string]Theme
currentName string
currentUsesAnsiCache bool // Cache whether current theme uses ANSI colors
mu sync.RWMutex
}
// Global instance of the theme manager
@@ -34,6 +40,7 @@ func RegisterTheme(name string, theme Theme) {
// If this is the first theme, make it the default
if globalManager.currentName == "" {
globalManager.currentName = name
globalManager.currentUsesAnsiCache = themeUsesAnsiColors(theme)
}
}
@@ -44,11 +51,13 @@ func SetTheme(name string) error {
defer globalManager.mu.Unlock()
delete(styles.Registry, "charm")
if _, exists := globalManager.themes[name]; !exists {
theme, exists := globalManager.themes[name]
if !exists {
return fmt.Errorf("theme '%s' not found", name)
}
globalManager.currentName = name
globalManager.currentUsesAnsiCache = themeUsesAnsiColors(theme)
return nil
}
@@ -84,12 +93,16 @@ func AvailableThemes() []string {
names = append(names, name)
}
slices.SortFunc(names, func(a, b string) int {
// list system theme first
if a == "opencode" {
return -1
} else if b == "opencode" {
return 1
}
if a == "system" {
return -1
} else if b == "system" {
return 1
}
return strings.Compare(a, b)
})
return names
@@ -103,3 +116,114 @@ func GetTheme(name string) Theme {
return globalManager.themes[name]
}
// UpdateSystemTheme updates the system theme with terminal background info
func UpdateSystemTheme(terminalBg color.Color, isDark bool) {
globalManager.mu.Lock()
defer globalManager.mu.Unlock()
dynamicTheme := NewSystemTheme(terminalBg, isDark)
globalManager.themes["system"] = dynamicTheme
if globalManager.currentName == "system" {
globalManager.currentUsesAnsiCache = themeUsesAnsiColors(dynamicTheme)
}
}
// CurrentThemeUsesAnsiColors returns true if the current theme uses ANSI 0-16 colors
func CurrentThemeUsesAnsiColors() bool {
// globalManager.mu.RLock()
// defer globalManager.mu.RUnlock()
return globalManager.currentUsesAnsiCache
}
// isAnsiColor checks if a color represents an ANSI 0-16 color
func isAnsiColor(c color.Color) bool {
if _, ok := c.(lipgloss.NoColor); ok {
return false
}
if _, ok := c.(ansi.BasicColor); ok {
return true
}
// For other color types, check if they represent ANSI colors
// by examining their string representation
if stringer, ok := c.(fmt.Stringer); ok {
str := stringer.String()
// Check if it's a numeric ANSI color (0-15)
if num, err := strconv.Atoi(str); err == nil && num >= 0 && num <= 15 {
return true
}
}
return false
}
// adaptiveColorUsesAnsi checks if an AdaptiveColor uses ANSI colors
func adaptiveColorUsesAnsi(ac compat.AdaptiveColor) bool {
if isAnsiColor(ac.Dark) {
return true
}
if isAnsiColor(ac.Light) {
return true
}
return false
}
// themeUsesAnsiColors checks if a theme uses any ANSI 0-16 colors
func themeUsesAnsiColors(theme Theme) bool {
if theme == nil {
return false
}
return adaptiveColorUsesAnsi(theme.Primary()) ||
adaptiveColorUsesAnsi(theme.Secondary()) ||
adaptiveColorUsesAnsi(theme.Accent()) ||
adaptiveColorUsesAnsi(theme.Error()) ||
adaptiveColorUsesAnsi(theme.Warning()) ||
adaptiveColorUsesAnsi(theme.Success()) ||
adaptiveColorUsesAnsi(theme.Info()) ||
adaptiveColorUsesAnsi(theme.Text()) ||
adaptiveColorUsesAnsi(theme.TextMuted()) ||
adaptiveColorUsesAnsi(theme.Background()) ||
adaptiveColorUsesAnsi(theme.BackgroundPanel()) ||
adaptiveColorUsesAnsi(theme.BackgroundElement()) ||
adaptiveColorUsesAnsi(theme.Border()) ||
adaptiveColorUsesAnsi(theme.BorderActive()) ||
adaptiveColorUsesAnsi(theme.BorderSubtle()) ||
adaptiveColorUsesAnsi(theme.DiffAdded()) ||
adaptiveColorUsesAnsi(theme.DiffRemoved()) ||
adaptiveColorUsesAnsi(theme.DiffContext()) ||
adaptiveColorUsesAnsi(theme.DiffHunkHeader()) ||
adaptiveColorUsesAnsi(theme.DiffHighlightAdded()) ||
adaptiveColorUsesAnsi(theme.DiffHighlightRemoved()) ||
adaptiveColorUsesAnsi(theme.DiffAddedBg()) ||
adaptiveColorUsesAnsi(theme.DiffRemovedBg()) ||
adaptiveColorUsesAnsi(theme.DiffContextBg()) ||
adaptiveColorUsesAnsi(theme.DiffLineNumber()) ||
adaptiveColorUsesAnsi(theme.DiffAddedLineNumberBg()) ||
adaptiveColorUsesAnsi(theme.DiffRemovedLineNumberBg()) ||
adaptiveColorUsesAnsi(theme.MarkdownText()) ||
adaptiveColorUsesAnsi(theme.MarkdownHeading()) ||
adaptiveColorUsesAnsi(theme.MarkdownLink()) ||
adaptiveColorUsesAnsi(theme.MarkdownLinkText()) ||
adaptiveColorUsesAnsi(theme.MarkdownCode()) ||
adaptiveColorUsesAnsi(theme.MarkdownBlockQuote()) ||
adaptiveColorUsesAnsi(theme.MarkdownEmph()) ||
adaptiveColorUsesAnsi(theme.MarkdownStrong()) ||
adaptiveColorUsesAnsi(theme.MarkdownHorizontalRule()) ||
adaptiveColorUsesAnsi(theme.MarkdownListItem()) ||
adaptiveColorUsesAnsi(theme.MarkdownListEnumeration()) ||
adaptiveColorUsesAnsi(theme.MarkdownImage()) ||
adaptiveColorUsesAnsi(theme.MarkdownImageText()) ||
adaptiveColorUsesAnsi(theme.MarkdownCodeBlock()) ||
adaptiveColorUsesAnsi(theme.SyntaxComment()) ||
adaptiveColorUsesAnsi(theme.SyntaxKeyword()) ||
adaptiveColorUsesAnsi(theme.SyntaxFunction()) ||
adaptiveColorUsesAnsi(theme.SyntaxVariable()) ||
adaptiveColorUsesAnsi(theme.SyntaxString()) ||
adaptiveColorUsesAnsi(theme.SyntaxNumber()) ||
adaptiveColorUsesAnsi(theme.SyntaxType()) ||
adaptiveColorUsesAnsi(theme.SyntaxOperator()) ||
adaptiveColorUsesAnsi(theme.SyntaxPunctuation())
}
+299
View File
@@ -0,0 +1,299 @@
package theme
import (
"fmt"
"image/color"
"math"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/lipgloss/v2/compat"
)
// SystemTheme is a dynamic theme that derives its gray scale colors
// from the terminal's background color at runtime
type SystemTheme struct {
BaseTheme
terminalBg color.Color
terminalBgIsDark bool
}
// NewSystemTheme creates a new instance of the dynamic system theme
func NewSystemTheme(terminalBg color.Color, isDark bool) *SystemTheme {
theme := &SystemTheme{
terminalBg: terminalBg,
terminalBgIsDark: isDark,
}
theme.initializeColors()
return theme
}
// initializeColors sets up all theme colors
func (t *SystemTheme) initializeColors() {
// Generate gray scale based on terminal background
grays := t.generateGrayScale()
// Set ANSI colors for primary colors
t.PrimaryColor = compat.AdaptiveColor{
Dark: lipgloss.Cyan,
Light: lipgloss.Cyan,
}
t.SecondaryColor = compat.AdaptiveColor{
Dark: lipgloss.Magenta,
Light: lipgloss.Magenta,
}
t.AccentColor = compat.AdaptiveColor{
Dark: lipgloss.Cyan,
Light: lipgloss.Cyan,
}
// Status colors using ANSI
t.ErrorColor = compat.AdaptiveColor{
Dark: lipgloss.Red,
Light: lipgloss.Red,
}
t.WarningColor = compat.AdaptiveColor{
Dark: lipgloss.Yellow,
Light: lipgloss.Yellow,
}
t.SuccessColor = compat.AdaptiveColor{
Dark: lipgloss.Green,
Light: lipgloss.Green,
}
t.InfoColor = compat.AdaptiveColor{
Dark: lipgloss.Cyan,
Light: lipgloss.Cyan,
}
// Text colors
t.TextColor = compat.AdaptiveColor{
Dark: lipgloss.NoColor{},
Light: lipgloss.NoColor{},
}
// Derive muted text color from terminal foreground
t.TextMutedColor = t.generateMutedTextColor()
// Background colors
t.BackgroundColor = compat.AdaptiveColor{
Dark: lipgloss.NoColor{},
Light: lipgloss.NoColor{},
}
t.BackgroundPanelColor = grays[2]
t.BackgroundElementColor = grays[3]
// Border colors
t.BorderSubtleColor = grays[6]
t.BorderColor = grays[7]
t.BorderActiveColor = grays[8]
// Diff colors using ANSI colors
t.DiffAddedColor = compat.AdaptiveColor{
Dark: lipgloss.Color("2"), // green
Light: lipgloss.Color("2"),
}
t.DiffRemovedColor = compat.AdaptiveColor{
Dark: lipgloss.Color("1"), // red
Light: lipgloss.Color("1"),
}
t.DiffContextColor = grays[7] // Use gray for context
t.DiffHunkHeaderColor = grays[7]
t.DiffHighlightAddedColor = compat.AdaptiveColor{
Dark: lipgloss.Color("2"), // green
Light: lipgloss.Color("2"),
}
t.DiffHighlightRemovedColor = compat.AdaptiveColor{
Dark: lipgloss.Color("1"), // red
Light: lipgloss.Color("1"),
}
// Use subtle gray backgrounds for diff
t.DiffAddedBgColor = grays[2]
t.DiffRemovedBgColor = grays[2]
t.DiffContextBgColor = grays[1]
t.DiffLineNumberColor = grays[6]
t.DiffAddedLineNumberBgColor = grays[3]
t.DiffRemovedLineNumberBgColor = grays[3]
// Markdown colors using ANSI
t.MarkdownTextColor = compat.AdaptiveColor{
Dark: lipgloss.NoColor{},
Light: lipgloss.NoColor{},
}
t.MarkdownHeadingColor = compat.AdaptiveColor{
Dark: lipgloss.NoColor{},
Light: lipgloss.NoColor{},
}
t.MarkdownLinkColor = compat.AdaptiveColor{
Dark: lipgloss.Color("4"), // blue
Light: lipgloss.Color("4"),
}
t.MarkdownLinkTextColor = compat.AdaptiveColor{
Dark: lipgloss.Color("6"), // cyan
Light: lipgloss.Color("6"),
}
t.MarkdownCodeColor = compat.AdaptiveColor{
Dark: lipgloss.Color("2"), // green
Light: lipgloss.Color("2"),
}
t.MarkdownBlockQuoteColor = compat.AdaptiveColor{
Dark: lipgloss.Color("3"), // yellow
Light: lipgloss.Color("3"),
}
t.MarkdownEmphColor = compat.AdaptiveColor{
Dark: lipgloss.Color("3"), // yellow
Light: lipgloss.Color("3"),
}
t.MarkdownStrongColor = compat.AdaptiveColor{
Dark: lipgloss.NoColor{},
Light: lipgloss.NoColor{},
}
t.MarkdownHorizontalRuleColor = t.BorderColor
t.MarkdownListItemColor = compat.AdaptiveColor{
Dark: lipgloss.Color("4"), // blue
Light: lipgloss.Color("4"),
}
t.MarkdownListEnumerationColor = compat.AdaptiveColor{
Dark: lipgloss.Color("6"), // cyan
Light: lipgloss.Color("6"),
}
t.MarkdownImageColor = compat.AdaptiveColor{
Dark: lipgloss.Color("4"), // blue
Light: lipgloss.Color("4"),
}
t.MarkdownImageTextColor = compat.AdaptiveColor{
Dark: lipgloss.Color("6"), // cyan
Light: lipgloss.Color("6"),
}
t.MarkdownCodeBlockColor = compat.AdaptiveColor{
Dark: lipgloss.NoColor{},
Light: lipgloss.NoColor{},
}
// Syntax colors
t.SyntaxCommentColor = t.TextMutedColor // Use same as muted text
t.SyntaxKeywordColor = compat.AdaptiveColor{
Dark: lipgloss.Color("5"), // magenta
Light: lipgloss.Color("5"),
}
t.SyntaxFunctionColor = compat.AdaptiveColor{
Dark: lipgloss.Color("4"), // blue
Light: lipgloss.Color("4"),
}
t.SyntaxVariableColor = compat.AdaptiveColor{
Dark: lipgloss.NoColor{},
Light: lipgloss.NoColor{},
}
t.SyntaxStringColor = compat.AdaptiveColor{
Dark: lipgloss.Color("2"), // green
Light: lipgloss.Color("2"),
}
t.SyntaxNumberColor = compat.AdaptiveColor{
Dark: lipgloss.Color("3"), // yellow
Light: lipgloss.Color("3"),
}
t.SyntaxTypeColor = compat.AdaptiveColor{
Dark: lipgloss.Color("6"), // cyan
Light: lipgloss.Color("6"),
}
t.SyntaxOperatorColor = compat.AdaptiveColor{
Dark: lipgloss.Color("6"), // cyan
Light: lipgloss.Color("6"),
}
t.SyntaxPunctuationColor = compat.AdaptiveColor{
Dark: lipgloss.NoColor{},
Light: lipgloss.NoColor{},
}
}
// generateGrayScale creates a gray scale based on the terminal background
func (t *SystemTheme) generateGrayScale() map[int]compat.AdaptiveColor {
grays := make(map[int]compat.AdaptiveColor)
r, g, b, _ := t.terminalBg.RGBA()
bgR := float64(r >> 8)
bgG := float64(g >> 8)
bgB := float64(b >> 8)
luminance := 0.299*bgR + 0.587*bgG + 0.114*bgB
for i := 1; i <= 12; i++ {
var stepColor string
factor := float64(i) / 12.0
if t.terminalBgIsDark {
if luminance < 10 {
grayValue := int(factor * 0.4 * 255)
stepColor = fmt.Sprintf("#%02x%02x%02x", grayValue, grayValue, grayValue)
} else {
newLum := luminance + (255-luminance)*factor*0.4
ratio := newLum / luminance
newR := math.Min(bgR*ratio, 255)
newG := math.Min(bgG*ratio, 255)
newB := math.Min(bgB*ratio, 255)
stepColor = fmt.Sprintf("#%02x%02x%02x", int(newR), int(newG), int(newB))
}
} else {
if luminance > 245 {
grayValue := int(255 - factor*0.4*255)
stepColor = fmt.Sprintf("#%02x%02x%02x", grayValue, grayValue, grayValue)
} else {
newLum := luminance * (1 - factor*0.4)
ratio := newLum / luminance
newR := math.Max(bgR*ratio, 0)
newG := math.Max(bgG*ratio, 0)
newB := math.Max(bgB*ratio, 0)
stepColor = fmt.Sprintf("#%02x%02x%02x", int(newR), int(newG), int(newB))
}
}
grays[i] = compat.AdaptiveColor{
Dark: lipgloss.Color(stepColor),
Light: lipgloss.Color(stepColor),
}
}
return grays
}
// generateMutedTextColor creates a muted gray color based on the terminal background
func (t *SystemTheme) generateMutedTextColor() compat.AdaptiveColor {
bgR, bgG, bgB, _ := t.terminalBg.RGBA()
bgRf := float64(bgR >> 8)
bgGf := float64(bgG >> 8)
bgBf := float64(bgB >> 8)
bgLum := 0.299*bgRf + 0.587*bgGf + 0.114*bgBf
var grayValue int
if t.terminalBgIsDark {
if bgLum < 10 {
// Very dark/black background
// grays[3] would be around #2e (46), so we need much lighter
grayValue = 180 // #b4b4b4
} else {
// Scale up for lighter dark backgrounds
// Ensure we're always significantly brighter than BackgroundElement
grayValue = min(int(160+(bgLum*0.3)), 200)
}
} else {
if bgLum > 245 {
// Very light/white background
// grays[3] would be around #f5 (245), so we need much darker
grayValue = 75 // #4b4b4b
} else {
// Scale down for darker light backgrounds
// Ensure we're always significantly darker than BackgroundElement
grayValue = max(int(100-((255-bgLum)*0.2)), 60)
}
}
mutedColor := fmt.Sprintf("#%02x%02x%02x", grayValue, grayValue, grayValue)
return compat.AdaptiveColor{
Dark: lipgloss.Color(mutedColor),
Light: lipgloss.Color(mutedColor),
}
}
@@ -78,4 +78,3 @@
"syntaxPunctuation": "darkFg"
}
}
@@ -110,4 +110,3 @@
"syntaxPunctuation": { "dark": "darkText", "light": "lightText" }
}
}
@@ -0,0 +1,228 @@
{
"$schema": "https://opencode.ai/theme.json",
"defs": {
"background": "#193549",
"backgroundAlt": "#122738",
"backgroundPanel": "#1f4662",
"foreground": "#ffffff",
"foregroundMuted": "#adb7c9",
"yellow": "#ffc600",
"yellowBright": "#ffe14c",
"orange": "#ff9d00",
"orangeBright": "#ffb454",
"mint": "#2affdf",
"mintBright": "#7efff5",
"blue": "#0088ff",
"blueBright": "#5cb7ff",
"pink": "#ff628c",
"pinkBright": "#ff86a5",
"green": "#9eff80",
"greenBright": "#b9ff9f",
"purple": "#9a5feb",
"purpleBright": "#b88cfd",
"red": "#ff0088",
"redBright": "#ff5fb3"
},
"theme": {
"primary": {
"dark": "blue",
"light": "#0066cc"
},
"secondary": {
"dark": "purple",
"light": "#7c4dff"
},
"accent": {
"dark": "mint",
"light": "#00acc1"
},
"error": {
"dark": "red",
"light": "#e91e63"
},
"warning": {
"dark": "yellow",
"light": "#ff9800"
},
"success": {
"dark": "green",
"light": "#4caf50"
},
"info": {
"dark": "orange",
"light": "#ff5722"
},
"text": {
"dark": "foreground",
"light": "#193549"
},
"textMuted": {
"dark": "foregroundMuted",
"light": "#5c6b7d"
},
"background": {
"dark": "#193549",
"light": "#ffffff"
},
"backgroundPanel": {
"dark": "#122738",
"light": "#f5f7fa"
},
"backgroundElement": {
"dark": "#1f4662",
"light": "#e8ecf1"
},
"border": {
"dark": "#1f4662",
"light": "#d3dae3"
},
"borderActive": {
"dark": "blue",
"light": "#0066cc"
},
"borderSubtle": {
"dark": "#0e1e2e",
"light": "#e8ecf1"
},
"diffAdded": {
"dark": "green",
"light": "#4caf50"
},
"diffRemoved": {
"dark": "red",
"light": "#e91e63"
},
"diffContext": {
"dark": "foregroundMuted",
"light": "#5c6b7d"
},
"diffHunkHeader": {
"dark": "mint",
"light": "#00acc1"
},
"diffHighlightAdded": {
"dark": "greenBright",
"light": "#4caf50"
},
"diffHighlightRemoved": {
"dark": "redBright",
"light": "#e91e63"
},
"diffAddedBg": {
"dark": "#1a3a2a",
"light": "#e8f5e9"
},
"diffRemovedBg": {
"dark": "#3a1a2a",
"light": "#ffebee"
},
"diffContextBg": {
"dark": "#122738",
"light": "#f5f7fa"
},
"diffLineNumber": {
"dark": "#2d5a7b",
"light": "#b0bec5"
},
"diffAddedLineNumberBg": {
"dark": "#1a3a2a",
"light": "#e8f5e9"
},
"diffRemovedLineNumberBg": {
"dark": "#3a1a2a",
"light": "#ffebee"
},
"markdownText": {
"dark": "foreground",
"light": "#193549"
},
"markdownHeading": {
"dark": "yellow",
"light": "#ff9800"
},
"markdownLink": {
"dark": "blue",
"light": "#0066cc"
},
"markdownLinkText": {
"dark": "mint",
"light": "#00acc1"
},
"markdownCode": {
"dark": "green",
"light": "#4caf50"
},
"markdownBlockQuote": {
"dark": "foregroundMuted",
"light": "#5c6b7d"
},
"markdownEmph": {
"dark": "orange",
"light": "#ff5722"
},
"markdownStrong": {
"dark": "pink",
"light": "#e91e63"
},
"markdownHorizontalRule": {
"dark": "#2d5a7b",
"light": "#d3dae3"
},
"markdownListItem": {
"dark": "blue",
"light": "#0066cc"
},
"markdownListEnumeration": {
"dark": "mint",
"light": "#00acc1"
},
"markdownImage": {
"dark": "blue",
"light": "#0066cc"
},
"markdownImageText": {
"dark": "mint",
"light": "#00acc1"
},
"markdownCodeBlock": {
"dark": "foreground",
"light": "#193549"
},
"syntaxComment": {
"dark": "#0088ff",
"light": "#5c6b7d"
},
"syntaxKeyword": {
"dark": "orange",
"light": "#ff5722"
},
"syntaxFunction": {
"dark": "yellow",
"light": "#ff9800"
},
"syntaxVariable": {
"dark": "foreground",
"light": "#193549"
},
"syntaxString": {
"dark": "green",
"light": "#4caf50"
},
"syntaxNumber": {
"dark": "pink",
"light": "#e91e63"
},
"syntaxType": {
"dark": "mint",
"light": "#00acc1"
},
"syntaxOperator": {
"dark": "orange",
"light": "#ff5722"
},
"syntaxPunctuation": {
"dark": "foreground",
"light": "#193549"
}
}
}
@@ -0,0 +1,219 @@
{
"$schema": "https://opencode.ai/theme.json",
"defs": {
"background": "#282a36",
"currentLine": "#44475a",
"selection": "#44475a",
"foreground": "#f8f8f2",
"comment": "#6272a4",
"cyan": "#8be9fd",
"green": "#50fa7b",
"orange": "#ffb86c",
"pink": "#ff79c6",
"purple": "#bd93f9",
"red": "#ff5555",
"yellow": "#f1fa8c"
},
"theme": {
"primary": {
"dark": "purple",
"light": "purple"
},
"secondary": {
"dark": "pink",
"light": "pink"
},
"accent": {
"dark": "cyan",
"light": "cyan"
},
"error": {
"dark": "red",
"light": "red"
},
"warning": {
"dark": "yellow",
"light": "yellow"
},
"success": {
"dark": "green",
"light": "green"
},
"info": {
"dark": "orange",
"light": "orange"
},
"text": {
"dark": "foreground",
"light": "#282a36"
},
"textMuted": {
"dark": "comment",
"light": "#6272a4"
},
"background": {
"dark": "#282a36",
"light": "#f8f8f2"
},
"backgroundPanel": {
"dark": "#21222c",
"light": "#e8e8e2"
},
"backgroundElement": {
"dark": "currentLine",
"light": "#d8d8d2"
},
"border": {
"dark": "currentLine",
"light": "#c8c8c2"
},
"borderActive": {
"dark": "purple",
"light": "purple"
},
"borderSubtle": {
"dark": "#191a21",
"light": "#e0e0e0"
},
"diffAdded": {
"dark": "green",
"light": "green"
},
"diffRemoved": {
"dark": "red",
"light": "red"
},
"diffContext": {
"dark": "comment",
"light": "#6272a4"
},
"diffHunkHeader": {
"dark": "comment",
"light": "#6272a4"
},
"diffHighlightAdded": {
"dark": "green",
"light": "green"
},
"diffHighlightRemoved": {
"dark": "red",
"light": "red"
},
"diffAddedBg": {
"dark": "#1a3a1a",
"light": "#e0ffe0"
},
"diffRemovedBg": {
"dark": "#3a1a1a",
"light": "#ffe0e0"
},
"diffContextBg": {
"dark": "#21222c",
"light": "#e8e8e2"
},
"diffLineNumber": {
"dark": "currentLine",
"light": "#c8c8c2"
},
"diffAddedLineNumberBg": {
"dark": "#1a3a1a",
"light": "#e0ffe0"
},
"diffRemovedLineNumberBg": {
"dark": "#3a1a1a",
"light": "#ffe0e0"
},
"markdownText": {
"dark": "foreground",
"light": "#282a36"
},
"markdownHeading": {
"dark": "purple",
"light": "purple"
},
"markdownLink": {
"dark": "cyan",
"light": "cyan"
},
"markdownLinkText": {
"dark": "pink",
"light": "pink"
},
"markdownCode": {
"dark": "green",
"light": "green"
},
"markdownBlockQuote": {
"dark": "comment",
"light": "#6272a4"
},
"markdownEmph": {
"dark": "yellow",
"light": "yellow"
},
"markdownStrong": {
"dark": "orange",
"light": "orange"
},
"markdownHorizontalRule": {
"dark": "comment",
"light": "#6272a4"
},
"markdownListItem": {
"dark": "purple",
"light": "purple"
},
"markdownListEnumeration": {
"dark": "cyan",
"light": "cyan"
},
"markdownImage": {
"dark": "cyan",
"light": "cyan"
},
"markdownImageText": {
"dark": "pink",
"light": "pink"
},
"markdownCodeBlock": {
"dark": "foreground",
"light": "#282a36"
},
"syntaxComment": {
"dark": "comment",
"light": "#6272a4"
},
"syntaxKeyword": {
"dark": "pink",
"light": "pink"
},
"syntaxFunction": {
"dark": "green",
"light": "green"
},
"syntaxVariable": {
"dark": "foreground",
"light": "#282a36"
},
"syntaxString": {
"dark": "yellow",
"light": "yellow"
},
"syntaxNumber": {
"dark": "purple",
"light": "purple"
},
"syntaxType": {
"dark": "cyan",
"light": "cyan"
},
"syntaxOperator": {
"dark": "pink",
"light": "pink"
},
"syntaxPunctuation": {
"dark": "foreground",
"light": "#282a36"
}
}
}
@@ -239,4 +239,3 @@
}
}
}
@@ -0,0 +1,233 @@
{
"$schema": "https://opencode.ai/theme.json",
"defs": {
"darkBg": "#0d1117",
"darkBgAlt": "#010409",
"darkBgPanel": "#161b22",
"darkFg": "#c9d1d9",
"darkFgMuted": "#8b949e",
"darkBlue": "#58a6ff",
"darkGreen": "#3fb950",
"darkRed": "#f85149",
"darkOrange": "#d29922",
"darkPurple": "#bc8cff",
"darkPink": "#ff7b72",
"darkYellow": "#e3b341",
"darkCyan": "#39c5cf",
"lightBg": "#ffffff",
"lightBgAlt": "#f6f8fa",
"lightBgPanel": "#f0f3f6",
"lightFg": "#24292f",
"lightFgMuted": "#57606a",
"lightBlue": "#0969da",
"lightGreen": "#1a7f37",
"lightRed": "#cf222e",
"lightOrange": "#bc4c00",
"lightPurple": "#8250df",
"lightPink": "#bf3989",
"lightYellow": "#9a6700",
"lightCyan": "#1b7c83"
},
"theme": {
"primary": {
"dark": "darkBlue",
"light": "lightBlue"
},
"secondary": {
"dark": "darkPurple",
"light": "lightPurple"
},
"accent": {
"dark": "darkCyan",
"light": "lightCyan"
},
"error": {
"dark": "darkRed",
"light": "lightRed"
},
"warning": {
"dark": "darkYellow",
"light": "lightYellow"
},
"success": {
"dark": "darkGreen",
"light": "lightGreen"
},
"info": {
"dark": "darkOrange",
"light": "lightOrange"
},
"text": {
"dark": "darkFg",
"light": "lightFg"
},
"textMuted": {
"dark": "darkFgMuted",
"light": "lightFgMuted"
},
"background": {
"dark": "darkBg",
"light": "lightBg"
},
"backgroundPanel": {
"dark": "darkBgAlt",
"light": "lightBgAlt"
},
"backgroundElement": {
"dark": "darkBgPanel",
"light": "lightBgPanel"
},
"border": {
"dark": "#30363d",
"light": "#d0d7de"
},
"borderActive": {
"dark": "darkBlue",
"light": "lightBlue"
},
"borderSubtle": {
"dark": "#21262d",
"light": "#d8dee4"
},
"diffAdded": {
"dark": "darkGreen",
"light": "lightGreen"
},
"diffRemoved": {
"dark": "darkRed",
"light": "lightRed"
},
"diffContext": {
"dark": "darkFgMuted",
"light": "lightFgMuted"
},
"diffHunkHeader": {
"dark": "darkBlue",
"light": "lightBlue"
},
"diffHighlightAdded": {
"dark": "#3fb950",
"light": "#1a7f37"
},
"diffHighlightRemoved": {
"dark": "#f85149",
"light": "#cf222e"
},
"diffAddedBg": {
"dark": "#033a16",
"light": "#dafbe1"
},
"diffRemovedBg": {
"dark": "#67060c",
"light": "#ffebe9"
},
"diffContextBg": {
"dark": "darkBgAlt",
"light": "lightBgAlt"
},
"diffLineNumber": {
"dark": "#484f58",
"light": "#afb8c1"
},
"diffAddedLineNumberBg": {
"dark": "#033a16",
"light": "#dafbe1"
},
"diffRemovedLineNumberBg": {
"dark": "#67060c",
"light": "#ffebe9"
},
"markdownText": {
"dark": "darkFg",
"light": "lightFg"
},
"markdownHeading": {
"dark": "darkBlue",
"light": "lightBlue"
},
"markdownLink": {
"dark": "darkBlue",
"light": "lightBlue"
},
"markdownLinkText": {
"dark": "darkCyan",
"light": "lightCyan"
},
"markdownCode": {
"dark": "darkPink",
"light": "lightPink"
},
"markdownBlockQuote": {
"dark": "darkFgMuted",
"light": "lightFgMuted"
},
"markdownEmph": {
"dark": "darkYellow",
"light": "lightYellow"
},
"markdownStrong": {
"dark": "darkOrange",
"light": "lightOrange"
},
"markdownHorizontalRule": {
"dark": "#30363d",
"light": "#d0d7de"
},
"markdownListItem": {
"dark": "darkBlue",
"light": "lightBlue"
},
"markdownListEnumeration": {
"dark": "darkCyan",
"light": "lightCyan"
},
"markdownImage": {
"dark": "darkBlue",
"light": "lightBlue"
},
"markdownImageText": {
"dark": "darkCyan",
"light": "lightCyan"
},
"markdownCodeBlock": {
"dark": "darkFg",
"light": "lightFg"
},
"syntaxComment": {
"dark": "darkFgMuted",
"light": "lightFgMuted"
},
"syntaxKeyword": {
"dark": "darkPink",
"light": "lightRed"
},
"syntaxFunction": {
"dark": "darkPurple",
"light": "lightPurple"
},
"syntaxVariable": {
"dark": "darkOrange",
"light": "lightOrange"
},
"syntaxString": {
"dark": "darkCyan",
"light": "lightBlue"
},
"syntaxNumber": {
"dark": "darkBlue",
"light": "lightCyan"
},
"syntaxType": {
"dark": "darkOrange",
"light": "lightOrange"
},
"syntaxOperator": {
"dark": "darkPink",
"light": "lightRed"
},
"syntaxPunctuation": {
"dark": "darkFg",
"light": "lightFg"
}
}
}
@@ -0,0 +1,235 @@
{
"$schema": "https://opencode.ai/theme.json",
"defs": {
"darkBg": "#263238",
"darkBgAlt": "#1e272c",
"darkBgPanel": "#37474f",
"darkFg": "#eeffff",
"darkFgMuted": "#546e7a",
"darkRed": "#f07178",
"darkPink": "#f78c6c",
"darkOrange": "#ffcb6b",
"darkYellow": "#ffcb6b",
"darkGreen": "#c3e88d",
"darkCyan": "#89ddff",
"darkBlue": "#82aaff",
"darkPurple": "#c792ea",
"darkViolet": "#bb80b3",
"lightBg": "#fafafa",
"lightBgAlt": "#f5f5f5",
"lightBgPanel": "#e7e7e8",
"lightFg": "#263238",
"lightFgMuted": "#90a4ae",
"lightRed": "#e53935",
"lightPink": "#ec407a",
"lightOrange": "#f4511e",
"lightYellow": "#ffb300",
"lightGreen": "#91b859",
"lightCyan": "#39adb5",
"lightBlue": "#6182b8",
"lightPurple": "#7c4dff",
"lightViolet": "#945eb8"
},
"theme": {
"primary": {
"dark": "darkBlue",
"light": "lightBlue"
},
"secondary": {
"dark": "darkPurple",
"light": "lightPurple"
},
"accent": {
"dark": "darkCyan",
"light": "lightCyan"
},
"error": {
"dark": "darkRed",
"light": "lightRed"
},
"warning": {
"dark": "darkYellow",
"light": "lightYellow"
},
"success": {
"dark": "darkGreen",
"light": "lightGreen"
},
"info": {
"dark": "darkOrange",
"light": "lightOrange"
},
"text": {
"dark": "darkFg",
"light": "lightFg"
},
"textMuted": {
"dark": "darkFgMuted",
"light": "lightFgMuted"
},
"background": {
"dark": "darkBg",
"light": "lightBg"
},
"backgroundPanel": {
"dark": "darkBgAlt",
"light": "lightBgAlt"
},
"backgroundElement": {
"dark": "darkBgPanel",
"light": "lightBgPanel"
},
"border": {
"dark": "#37474f",
"light": "#e0e0e0"
},
"borderActive": {
"dark": "darkBlue",
"light": "lightBlue"
},
"borderSubtle": {
"dark": "#1e272c",
"light": "#eeeeee"
},
"diffAdded": {
"dark": "darkGreen",
"light": "lightGreen"
},
"diffRemoved": {
"dark": "darkRed",
"light": "lightRed"
},
"diffContext": {
"dark": "darkFgMuted",
"light": "lightFgMuted"
},
"diffHunkHeader": {
"dark": "darkCyan",
"light": "lightCyan"
},
"diffHighlightAdded": {
"dark": "darkGreen",
"light": "lightGreen"
},
"diffHighlightRemoved": {
"dark": "darkRed",
"light": "lightRed"
},
"diffAddedBg": {
"dark": "#2e3c2b",
"light": "#e8f5e9"
},
"diffRemovedBg": {
"dark": "#3c2b2b",
"light": "#ffebee"
},
"diffContextBg": {
"dark": "darkBgAlt",
"light": "lightBgAlt"
},
"diffLineNumber": {
"dark": "#37474f",
"light": "#cfd8dc"
},
"diffAddedLineNumberBg": {
"dark": "#2e3c2b",
"light": "#e8f5e9"
},
"diffRemovedLineNumberBg": {
"dark": "#3c2b2b",
"light": "#ffebee"
},
"markdownText": {
"dark": "darkFg",
"light": "lightFg"
},
"markdownHeading": {
"dark": "darkBlue",
"light": "lightBlue"
},
"markdownLink": {
"dark": "darkCyan",
"light": "lightCyan"
},
"markdownLinkText": {
"dark": "darkPurple",
"light": "lightPurple"
},
"markdownCode": {
"dark": "darkGreen",
"light": "lightGreen"
},
"markdownBlockQuote": {
"dark": "darkFgMuted",
"light": "lightFgMuted"
},
"markdownEmph": {
"dark": "darkYellow",
"light": "lightYellow"
},
"markdownStrong": {
"dark": "darkOrange",
"light": "lightOrange"
},
"markdownHorizontalRule": {
"dark": "#37474f",
"light": "#e0e0e0"
},
"markdownListItem": {
"dark": "darkBlue",
"light": "lightBlue"
},
"markdownListEnumeration": {
"dark": "darkCyan",
"light": "lightCyan"
},
"markdownImage": {
"dark": "darkCyan",
"light": "lightCyan"
},
"markdownImageText": {
"dark": "darkPurple",
"light": "lightPurple"
},
"markdownCodeBlock": {
"dark": "darkFg",
"light": "lightFg"
},
"syntaxComment": {
"dark": "darkFgMuted",
"light": "lightFgMuted"
},
"syntaxKeyword": {
"dark": "darkPurple",
"light": "lightPurple"
},
"syntaxFunction": {
"dark": "darkBlue",
"light": "lightBlue"
},
"syntaxVariable": {
"dark": "darkFg",
"light": "lightFg"
},
"syntaxString": {
"dark": "darkGreen",
"light": "lightGreen"
},
"syntaxNumber": {
"dark": "darkOrange",
"light": "lightOrange"
},
"syntaxType": {
"dark": "darkYellow",
"light": "lightYellow"
},
"syntaxOperator": {
"dark": "darkCyan",
"light": "lightCyan"
},
"syntaxPunctuation": {
"dark": "darkFg",
"light": "lightFg"
}
}
}

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