Compare commits

..
118 Commits
Author SHA1 Message Date
Dax Raad ca48a4f0fb better amazon bedrock caching with anthropic models
publish / publish (push) Has been cancelled
2025-06-29 19:27:07 -04:00
DaxandGitHub 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 ClasenandGitHub f0962e2d9c Add Option to Disable MCP Servers (#513) 2025-06-28 21:05:31 -04:00
Dax Raad 3a9584a419 fix context display
publish / publish (push) Has been cancelled
2025-06-28 21:01:53 -04:00
adamdottv 196f42cbff fix(tui): share command and error messages
publish / publish (push) Has been cancelled
2025-06-28 17:51:28 -05:00
Dax Raad 322385f6b1 patch for scroll dumping characters into input buffer
publish / publish (push) Has been cancelled
2025-06-28 11:56:47 -04:00
Dax Raad b7446cd7b9 ci: fix
publish / publish (push) Has been cancelled
2025-06-28 09:16:29 -04:00
f618e569ab optimize edit-tool rendering (#463)
publish / publish (push) Has been cancelled
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 RaadandJay V 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
adamdottvandJay V be0811ecc3 chore: rework openapi spec and use stainless sdk 2025-06-27 19:10:42 -04:00
Dax RaadandJay V 0676bcd4fd temporary patch for input lag on initial run 2025-06-27 19:10:42 -04:00
Polo123456789andJay V d076def561 feat: Add golang file formatting (#474) 2025-06-27 19:10:42 -04:00
Wendell MisiedjanandJay V 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 RaadandJay V 2f8cf9146b ci: ignore 2025-06-27 19:10:42 -04:00
Dax RaadandJay V 8e0ec6b037 ci: aur 2025-06-27 19:10:42 -04:00
Dax RaadandJay V 6dc434cb83 ignore: cleanup 2025-06-27 19:10:42 -04:00
Dax RaadandJay V d972c27f03 lazy load formatters 2025-06-27 19:10:42 -04:00
Ryan WinchesterandJay V 9e2bb63688 feat: add elixir file formatting (#458) 2025-06-27 19:10:42 -04:00
adamdottvandJay V 49053b66a9 fix(web): remove system prompts from share page 2025-06-27 19:10:42 -04:00
TheGoddessInariandJay V 47497aef07 scripts/hooks: Change shebang to universal /bin/sh (#453) 2025-06-27 19:10:41 -04:00
adamdottvandJay V 8455029de1 fix(tui): min width on user messages 2025-06-27 19:10:41 -04:00
Dax RaadandJay V 9f07f89384 fix formatting output going into tui 2025-06-27 19:10:41 -04:00
adamdottvandJay V d840d43e8f ignore: more metadata in app info 2025-06-27 19:10:41 -04:00
adamdottvandJay V 9ead2f3dfb fix: don't use prettier for langs it doesn't format 2025-06-27 19:10:41 -04:00
Dax RaadandJay V f3742ddbb8 ignore: run prettier 2025-06-27 19:10:41 -04:00
Dax RaadandJay V 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
publish / publish (push) Has been cancelled
2025-06-27 14:36:03 -04:00
Polo123456789andGitHub df3b424830 feat: Add golang file formatting (#474) 2025-06-27 14:11:09 -04:00
Wendell MisiedjanandGitHub 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
publish / publish (push) Has been cancelled
2025-06-27 12:37:57 -04:00
Dax Raad ce611963c3 ci: aur
publish / publish (push) Has been cancelled
2025-06-27 12:29:13 -04:00
Dax Raad f865cacfb8 ignore: cleanup
publish / publish (push) Has been cancelled
2025-06-27 11:35:57 -04:00
Dax Raad 2ec0611f42 lazy load formatters 2025-06-27 11:33:37 -04:00
Ryan WinchesterandGitHub 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
TheGoddessInariandGitHub 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
publish / publish (push) Has been cancelled
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
publish / publish (push) Has been cancelled
2025-06-27 05:47:14 -05:00
Dax Raad 51bf193889 ignore: run prettier
publish / publish (push) Has been cancelled
2025-06-26 22:30:44 -04:00
Dax Raad f8b78f08b4 add auto formatting and experimental hooks feature
publish / publish (push) Has been cancelled
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 OsihandGitHub b4c2fcccf5 Merge branch 'sst:dev' into feat/scroll-to-bottom-button 2025-06-27 00:41:20 +02:00
Jeremy Osihandopencode 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
publish / publish (push) Has been cancelled
2025-06-26 18:27:49 -04:00
Jay V 5b54554fd5 docs: edit theme doc 2025-06-26 17:56:31 -04:00
Dax Raadandopencode 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
adamdottvandJay V 97e19e9677 fix(tui): editor styles were off 2025-06-26 17:22:21 -04:00
adamdottvandJay V b27851461f feat(tui): more themes 2025-06-26 17:22:21 -04:00
adamdottvandJay V 209687377a feat(tui): more themes 2025-06-26 17:22:21 -04:00
adamdottvandJay V 90face1c09 fix(tui): editor width issues 2025-06-26 17:22:21 -04:00
adamdottvandJay V 936e2ce48b feat(tui): show lsp diagnostics for edit and write tools 2025-06-26 17:22:21 -04:00
adamdottvandJay V 16ee8ee379 fix(tui): chat editor aesthetics 2025-06-26 17:22:21 -04:00
adamdottvandJay V ac39308dad fix(tui): visual issue with modal selected items in system theme 2025-06-26 17:22:21 -04:00
adamdottvandJay V 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
adamdottvandJay V 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
adamdottvandJay V ab23167f80 docs: system theme 2025-06-26 17:21:53 -04:00
adamdottvandJay V b17ec46463 fix(tui): make opencode theme default 2025-06-26 17:21:53 -04:00
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 WallioandJay V 31b56e5a05 Fix undefined is not an object (evaluating 'G.title') (#395) 2025-06-26 17:21:53 -04:00
Juhani PelliandJay V 47c401cf25 fix: guard against large output limit causing infinite summarize loop (#399) 2025-06-26 17:21:53 -04:00
Dax RaadandJay V fab8dc9e6f more edit tool fixes 2025-06-26 17:21:53 -04:00
Dax RaadandJay V f39a2b1f16 integrate gemini-cli strategies for edit tool 2025-06-26 17:21:53 -04:00
Dax RaadandJay V 66830ced4e make edit tool more robust 2025-06-26 17:21:53 -04:00
Dax RaadandJay V 9d3fad754d ignore: typo 2025-06-26 17:21:53 -04:00
Dax RaadandJay V dcd3131f58 add output length errors 2025-06-26 17:21:53 -04:00
Dax RaadandJay V 3d02e07161 fix codex not working 2025-06-26 17:21:53 -04:00
Dax RaadandJay V 4dbc6a43a6 redirect uncaught errors to log file 2025-06-26 17:21:53 -04:00
adamdottv 5394b5188b fix(tui): editor styles were off
publish / publish (push) Has been cancelled
2025-06-26 15:12:26 -05:00
adamdottv 8e680b3957 feat(tui): more themes
publish / publish (push) Has been cancelled
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
publish / publish (push) Has been cancelled
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
publish / publish (push) Has been cancelled
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
7d13baadc8 feat: default system theme (#419)
publish / publish (push) Has been cancelled
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
Mike WallioandGitHub db24bf87c0 Fix undefined is not an object (evaluating 'G.title') (#395)
publish / publish (push) Has been cancelled
2025-06-25 19:40:09 -04:00
Juhani PelliandGitHub f4c0d2d2fd fix: guard against large output limit causing infinite summarize loop (#399) 2025-06-25 19:39:51 -04:00
Dax Raad d240f4c676 more edit tool fixes
publish / publish (push) Has been cancelled
2025-06-25 19:22:54 -04:00
Dax Raad 9c90cdbe08 integrate gemini-cli strategies for edit tool
publish / publish (push) Has been cancelled
2025-06-25 17:56:14 -04:00
Dax Raad fc7af31fe5 make edit tool more robust
publish / publish (push) Has been cancelled
2025-06-25 17:10:48 -04:00
Dax Raad 2f8d23ec66 ignore: typo 2025-06-25 11:02:57 -04:00
Dax Raad 77ae3fb9b9 add output length errors 2025-06-25 11:02:09 -04:00
Dax Raad 4e7f6c47fd fix codex not working
publish / publish (push) Has been cancelled
2025-06-25 10:01:35 -04:00
Dax Raad 50469ed750 redirect uncaught errors to log file
publish / publish (push) Has been cancelled
2025-06-25 08:41:10 -04:00
Dax Raad aaab785493 better error message when bad directory is specified to start in 2025-06-24 22:28:25 -04:00
129 changed files with 6962 additions and 7594 deletions
+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
+5 -12
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)
---
@@ -54,14 +54,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
+5
View File
@@ -0,0 +1,5 @@
# Download Stats
| Date | GitHub Downloads | npm Downloads | Total |
| ---------- | ---------------- | ---------------- | ---------------- |
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
+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#"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "0.0.0",
"version": "0.0.5",
"name": "opencode",
"type": "module",
"private": true,
+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 = [
+28 -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,24 @@ 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 () => {
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 function state<State>(
@@ -107,22 +129,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 +148,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>(
+17
View File
@@ -0,0 +1,17 @@
import { App } from "../app/app"
import { ConfigHooks } from "../config/hooks"
import { Format } from "../format"
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()
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`)
}
},
+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)
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()
})
},
})
+4 -7
View File
@@ -7,12 +7,9 @@ export const ScrapCommand = cmd({
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())
},
)
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",
})
}
}
})
}
}
+13
View File
@@ -0,0 +1,13 @@
import { z } from "zod"
import { Bus } from "../bus"
export namespace File {
export const Event = {
Edited: Bus.event(
"file.edited",
z.object({
file: z.string(),
}),
),
}
}
@@ -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]: {
+165
View File
@@ -0,0 +1,165 @@
import { App } from "../app/app"
import { BunProc } from "../bun"
import { Bus } from "../bus"
import { File } from "../file"
import { Log } from "../util/log"
import path from "path"
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: Definition) {
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 FORMATTERS) {
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,
})
}
})
}
interface Definition {
name: string
command: string[]
environment?: Record<string, string>
extensions: string[]
enabled(): Promise<boolean>
}
const FORMATTERS: Definition[] = [
{
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() {
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
}
},
},
{
name: "mix",
command: ["mix", "format", "$FILE"],
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
async enabled() {
try {
const proc = Bun.spawn({
cmd: ["mix", "--version"],
cwd: App.info().path.cwd,
stdout: "ignore",
stderr: "ignore",
})
const exit = await proc.exited
return exit === 0
} catch {
return false
}
},
},
{
name: "gofmt",
command: ["gofmt", "-w", "$FILE"],
extensions: [".go"],
async enabled() {
try {
const proc = Bun.spawn({
cmd: ["gofmt", "-h"],
cwd: App.info().path.cwd,
stdout: "ignore",
stderr: "ignore",
})
const exit = await proc.exited
return exit === 0
} catch {
return false
}
},
},
]
}
+30 -104
View File
@@ -1,31 +1,34 @@
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"
const cancel = new AbortController()
process.on("unhandledRejection", (e) => {
Log.Default.error("rejection", {
e: e instanceof Error ? e.message : e,
})
})
process.on("uncaughtException", (e) => {
Log.Default.error("exception", {
e: e instanceof Error ? e.message : e,
})
})
const cli = yargs(hideBin(process.argv))
.scriptName("opencode")
.help("help", "show help")
@@ -43,98 +46,7 @@ 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()
process.chdir(cwd)
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)
@@ -155,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,
@@ -169,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)
@@ -176,6 +101,7 @@ try {
UI.error(
"Unexpected error, check log file at " + Log.file() + " for more details",
)
process.exitCode = 1
}
cancel.abort()
+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({
+2 -2
View File
@@ -29,7 +29,7 @@ export namespace ModelsDev {
options: z.record(z.any()),
})
.openapi({
ref: "Model.Info",
ref: "Model",
})
export type Model = z.infer<typeof Model>
@@ -43,7 +43,7 @@ export namespace ModelsDev {
models: z.record(Model),
})
.openapi({
ref: "Provider.Info",
ref: "Provider",
})
export type Provider = z.infer<typeof Provider>
+33 -15
View File
@@ -23,25 +23,25 @@ import { AuthCopilot } from "../auth/copilot"
import { ModelsDev } from "./models"
import { NamedError } from "../util/error"
import { Auth } from "../auth"
import { TaskTool } from "../tool/task"
export namespace Provider {
const log = Log.create({ service: "provider" })
type CustomLoader = (provider: ModelsDev.Provider) => Promise<
| {
getModel?: (sdk: any, modelID: string) => Promise<any>
options: Record<string, any>
}
| false
>
type CustomLoader = (
provider: ModelsDev.Provider,
api?: string,
) => Promise<{
autoload: boolean
getModel?: (sdk: any, modelID: string) => Promise<any>
options?: Record<string, any>
}>
type Source = "env" | "config" | "custom" | "api"
const CUSTOM_LOADERS: Record<string, CustomLoader> = {
async anthropic(provider) {
const access = await AuthAnthropic.access()
if (!access) return false
if (!access) return { autoload: false }
for (const model of Object.values(provider.models)) {
model.cost = {
input: 0,
@@ -49,6 +49,7 @@ export namespace Provider {
}
}
return {
autoload: true,
options: {
apiKey: "",
async fetch(input: any, init: any) {
@@ -69,9 +70,9 @@ export namespace Provider {
},
"github-copilot": async (provider) => {
const copilot = await AuthCopilot()
if (!copilot) return false
if (!copilot) return { autoload: false }
let info = await Auth.get("github-copilot")
if (!info || info.type !== "oauth") return false
if (!info || info.type !== "oauth") return { autoload: false }
if (provider && provider.models) {
for (const model of Object.values(provider.models)) {
@@ -83,6 +84,7 @@ export namespace Provider {
}
return {
autoload: true,
options: {
apiKey: "",
async fetch(input: any, init: any) {
@@ -113,9 +115,18 @@ export namespace Provider {
},
}
},
openai: async () => {
return {
autoload: false,
async getModel(sdk: any, modelID: string) {
return sdk.responses(modelID)
},
options: {},
}
},
"amazon-bedrock": async () => {
if (!process.env["AWS_PROFILE"] && !process.env["AWS_ACCESS_KEY_ID"])
return false
return { autoload: false }
const region = process.env["AWS_REGION"] ?? "us-east-1"
@@ -123,6 +134,7 @@ export namespace Provider {
await BunProc.install("@aws-sdk/credential-providers")
)
return {
autoload: true,
options: {
region,
credentialProvider: fromNodeProviderChain(),
@@ -173,6 +185,7 @@ export namespace Provider {
source,
info,
options,
getModel,
}
return
}
@@ -248,8 +261,13 @@ export namespace Provider {
for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) {
if (disabled.has(providerID)) continue
const result = await fn(database[providerID])
if (result) {
mergeProvider(providerID, result.options, "custom", result.getModel)
if (result && (result.autoload || providers[providerID])) {
mergeProvider(
providerID,
result.options ?? {},
"custom",
result.getModel,
)
}
}
@@ -393,7 +411,7 @@ export namespace Provider {
// MultiEditTool,
WriteTool,
TodoWriteTool,
TaskTool,
// TaskTool,
TodoReadTool,
]
@@ -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
}
}
+197 -234
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 { 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
}
+47 -8
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[]
}) {
@@ -287,7 +297,10 @@ export namespace Session {
if (
model.info.limit.context &&
tokens >
(model.info.limit.context - (model.info.limit.output ?? 0)) * 0.9
Math.max(
(model.info.limit.context - (model.info.limit.output ?? 0)) * 0.9,
0,
)
) {
await summarize({
sessionID: input.sessionID,
@@ -534,6 +547,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,
@@ -657,6 +671,21 @@ export namespace Session {
}
break
case "finish":
log.info("message finish", {
reason: value.finishReason,
})
const assistant = next.metadata!.assistant!
const usage = getUsage(
model.info,
value.usage,
value.providerMetadata,
)
assistant.cost = usage.cost
await updateMessage(next)
if (value.finishReason === "length")
throw new Message.OutputLengthError({})
break
default:
l.info("unhandled", {
type: value.type,
@@ -670,6 +699,9 @@ export namespace Session {
error: e,
})
switch (true) {
case Message.OutputLengthError.isInstance(e):
next.metadata.error = e
break
case LoadAPIKeyError.isInstance(e):
next.metadata.error = new Provider.AuthError(
{
@@ -832,6 +864,9 @@ export namespace Session {
[Symbol.dispose]() {
log.info("unlocking", { sessionID })
state().pending.delete(sessionID)
Bus.publish(Event.Idle, {
sessionID,
})
},
}
}
@@ -847,8 +882,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,
},
}
@@ -920,7 +959,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) {
+27 -17
View File
@@ -4,6 +4,11 @@ import { Provider } from "../provider/provider"
import { NamedError } from "../util/error"
export namespace Message {
export const OutputLengthError = NamedError.create(
"MessageOutputLengthError",
z.object({}),
)
export const ToolCall = z
.object({
state: z.literal("call"),
@@ -13,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>
@@ -26,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>
@@ -40,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>
@@ -57,7 +62,7 @@ export namespace Message {
text: z.string(),
})
.openapi({
ref: "Message.Part.Text",
ref: "TextPart",
})
export type TextPart = z.infer<typeof TextPart>
@@ -68,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>
@@ -78,7 +83,7 @@ export namespace Message {
toolInvocation: ToolInvocation,
})
.openapi({
ref: "Message.Part.ToolInvocation",
ref: "ToolInvocationPart",
})
export type ToolInvocationPart = z.infer<typeof ToolInvocationPart>
@@ -91,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>
@@ -103,7 +108,7 @@ export namespace Message {
url: z.string(),
})
.openapi({
ref: "Message.Part.File",
ref: "FilePart",
})
export type FilePart = z.infer<typeof FilePart>
@@ -112,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,
@@ -126,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({
@@ -145,6 +150,7 @@ export namespace Message {
.discriminatedUnion("name", [
Provider.AuthError.Schema,
NamedError.Unknown.Schema,
OutputLengthError.Schema,
])
.optional(),
sessionID: z.string(),
@@ -183,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>
@@ -199,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(),
}),
),
}
}
+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 =
+387 -27
View File
@@ -1,12 +1,18 @@
// the approaches in this edit tool are sourced from
// https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-23-25.ts
// https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/utils/editCorrector.ts
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",
@@ -29,6 +35,10 @@ export const EditTool = Tool.define({
throw new Error("filePath is required")
}
if (params.oldString === params.newString) {
throw new Error("oldString and newString must be different")
}
const app = App.info()
const filepath = path.isAbsolute(params.filePath)
? params.filePath
@@ -51,47 +61,38 @@ 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
}
const file = Bun.file(filepath)
if (!(await file.exists())) throw new Error(`File ${filepath} not found`)
const stats = await file.stat()
const stats = await file.stat().catch(() => {})
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()
const index = contentOld.indexOf(params.oldString)
if (index === -1)
throw new Error(
`oldString not found in file. Make sure it matches exactly, including whitespace and line breaks`,
)
if (params.replaceAll) {
contentNew = contentOld.replaceAll(params.oldString, params.newString)
}
if (!params.replaceAll) {
const lastIndex = contentOld.lastIndexOf(params.oldString)
if (index !== lastIndex)
throw new Error(
`oldString appears multiple times in the file. Please provide more context to ensure a unique match`,
)
contentNew =
contentOld.substring(0, index) +
params.newString +
contentOld.substring(index + params.oldString.length)
}
contentNew = replace(
contentOld,
params.oldString,
params.newString,
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)
@@ -116,6 +117,326 @@ export const EditTool = Tool.define({
},
})
export type Replacer = (
content: string,
find: string,
) => Generator<string, void, unknown>
export const SimpleReplacer: Replacer = function* (_content, find) {
yield find
}
export const LineTrimmedReplacer: Replacer = function* (content, find) {
const originalLines = content.split("\n")
const searchLines = find.split("\n")
if (searchLines[searchLines.length - 1] === "") {
searchLines.pop()
}
for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
let matches = true
for (let j = 0; j < searchLines.length; j++) {
const originalTrimmed = originalLines[i + j].trim()
const searchTrimmed = searchLines[j].trim()
if (originalTrimmed !== searchTrimmed) {
matches = false
break
}
}
if (matches) {
let matchStartIndex = 0
for (let k = 0; k < i; k++) {
matchStartIndex += originalLines[k].length + 1
}
let matchEndIndex = matchStartIndex
for (let k = 0; k < searchLines.length; k++) {
matchEndIndex += originalLines[i + k].length + 1
}
yield content.substring(matchStartIndex, matchEndIndex)
}
}
}
export const BlockAnchorReplacer: Replacer = function* (content, find) {
const originalLines = content.split("\n")
const searchLines = find.split("\n")
if (searchLines.length < 3) {
return
}
if (searchLines[searchLines.length - 1] === "") {
searchLines.pop()
}
const firstLineSearch = searchLines[0].trim()
const lastLineSearch = searchLines[searchLines.length - 1].trim()
// Find blocks where first line matches the search first line
for (let i = 0; i < originalLines.length; i++) {
if (originalLines[i].trim() !== firstLineSearch) {
continue
}
// Look for the matching last line after this first line
for (let j = i + 2; j < originalLines.length; j++) {
if (originalLines[j].trim() === lastLineSearch) {
// Found a potential block from i to j
let matchStartIndex = 0
for (let k = 0; k < i; k++) {
matchStartIndex += originalLines[k].length + 1
}
let matchEndIndex = matchStartIndex
for (let k = 0; k <= j - i; k++) {
matchEndIndex += originalLines[i + k].length
if (k < j - i) {
matchEndIndex += 1 // Add newline character except for the last line
}
}
yield content.substring(matchStartIndex, matchEndIndex)
break // Only match the first occurrence of the last line
}
}
}
}
export const WhitespaceNormalizedReplacer: Replacer = function* (
content,
find,
) {
const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim()
const normalizedFind = normalizeWhitespace(find)
// Handle single line matches
const lines = content.split("\n")
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (normalizeWhitespace(line) === normalizedFind) {
yield line
}
// Also check for substring matches within lines
const normalizedLine = normalizeWhitespace(line)
if (normalizedLine.includes(normalizedFind)) {
// Find the actual substring in the original line that matches
const words = find.trim().split(/\s+/)
if (words.length > 0) {
const pattern = words
.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
.join("\\s+")
try {
const regex = new RegExp(pattern)
const match = line.match(regex)
if (match) {
yield match[0]
}
} catch (e) {
// Invalid regex pattern, skip
}
}
}
}
// Handle multi-line matches
const findLines = find.split("\n")
if (findLines.length > 1) {
for (let i = 0; i <= lines.length - findLines.length; i++) {
const block = lines.slice(i, i + findLines.length)
if (normalizeWhitespace(block.join("\n")) === normalizedFind) {
yield block.join("\n")
}
}
}
}
export const IndentationFlexibleReplacer: Replacer = function* (content, find) {
const removeIndentation = (text: string) => {
const lines = text.split("\n")
const nonEmptyLines = lines.filter((line) => line.trim().length > 0)
if (nonEmptyLines.length === 0) return text
const minIndent = Math.min(
...nonEmptyLines.map((line) => {
const match = line.match(/^(\s*)/)
return match ? match[1].length : 0
}),
)
return lines
.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent)))
.join("\n")
}
const normalizedFind = removeIndentation(find)
const contentLines = content.split("\n")
const findLines = find.split("\n")
for (let i = 0; i <= contentLines.length - findLines.length; i++) {
const block = contentLines.slice(i, i + findLines.length).join("\n")
if (removeIndentation(block) === normalizedFind) {
yield block
}
}
}
export const EscapeNormalizedReplacer: Replacer = function* (content, find) {
const unescapeString = (str: string): string => {
return str.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (match, capturedChar) => {
switch (capturedChar) {
case "n":
return "\n"
case "t":
return "\t"
case "r":
return "\r"
case "'":
return "'"
case '"':
return '"'
case "`":
return "`"
case "\\":
return "\\"
case "\n":
return "\n"
case "$":
return "$"
default:
return match
}
})
}
const unescapedFind = unescapeString(find)
// Try direct match with unescaped find string
if (content.includes(unescapedFind)) {
yield unescapedFind
}
// Also try finding escaped versions in content that match unescaped find
const lines = content.split("\n")
const findLines = unescapedFind.split("\n")
for (let i = 0; i <= lines.length - findLines.length; i++) {
const block = lines.slice(i, i + findLines.length).join("\n")
const unescapedBlock = unescapeString(block)
if (unescapedBlock === unescapedFind) {
yield block
}
}
}
export const MultiOccurrenceReplacer: Replacer = function* (content, find) {
// This replacer yields all exact matches, allowing the replace function
// to handle multiple occurrences based on replaceAll parameter
let startIndex = 0
while (true) {
const index = content.indexOf(find, startIndex)
if (index === -1) break
yield find
startIndex = index + find.length
}
}
export const TrimmedBoundaryReplacer: Replacer = function* (content, find) {
const trimmedFind = find.trim()
if (trimmedFind === find) {
// Already trimmed, no point in trying
return
}
// Try to find the trimmed version
if (content.includes(trimmedFind)) {
yield trimmedFind
}
// Also try finding blocks where trimmed content matches
const lines = content.split("\n")
const findLines = find.split("\n")
for (let i = 0; i <= lines.length - findLines.length; i++) {
const block = lines.slice(i, i + findLines.length).join("\n")
if (block.trim() === trimmedFind) {
yield block
}
}
}
export const ContextAwareReplacer: Replacer = function* (content, find) {
const findLines = find.split("\n")
if (findLines.length < 3) {
// Need at least 3 lines to have meaningful context
return
}
// Remove trailing empty line if present
if (findLines[findLines.length - 1] === "") {
findLines.pop()
}
const contentLines = content.split("\n")
// Extract first and last lines as context anchors
const firstLine = findLines[0].trim()
const lastLine = findLines[findLines.length - 1].trim()
// Find blocks that start and end with the context anchors
for (let i = 0; i < contentLines.length; i++) {
if (contentLines[i].trim() !== firstLine) continue
// Look for the matching last line
for (let j = i + 2; j < contentLines.length; j++) {
if (contentLines[j].trim() === lastLine) {
// Found a potential context block
const blockLines = contentLines.slice(i, j + 1)
const block = blockLines.join("\n")
// Check if the middle content has reasonable similarity
// (simple heuristic: at least 50% of non-empty lines should match when trimmed)
if (blockLines.length === findLines.length) {
let matchingLines = 0
let totalNonEmptyLines = 0
for (let k = 1; k < blockLines.length - 1; k++) {
const blockLine = blockLines[k].trim()
const findLine = findLines[k].trim()
if (blockLine.length > 0 || findLine.length > 0) {
totalNonEmptyLines++
if (blockLine === findLine) {
matchingLines++
}
}
}
if (
totalNonEmptyLines === 0 ||
matchingLines / totalNonEmptyLines >= 0.5
) {
yield block
break // Only match the first occurrence
}
}
break
}
}
}
}
function trimDiff(diff: string): string {
const lines = diff.split("\n")
const contentLines = lines.filter(
@@ -151,3 +472,42 @@ function trimDiff(diff: string): string {
return trimmedLines.join("\n")
}
export function replace(
content: string,
oldString: string,
newString: string,
replaceAll = false,
): string {
if (oldString === newString) {
throw new Error("oldString and newString must be different")
}
for (const replacer of [
SimpleReplacer,
LineTrimmedReplacer,
BlockAnchorReplacer,
WhitespaceNormalizedReplacer,
IndentationFlexibleReplacer,
EscapeNormalizedReplacer,
TrimmedBoundaryReplacer,
ContextAwareReplacer,
MultiOccurrenceReplacer,
]) {
for (const search of replacer(content, oldString)) {
const index = content.indexOf(search)
if (index === -1) continue
if (replaceAll) {
return content.replaceAll(search, newString)
}
const lastIndex = content.lastIndexOf(search)
if (index !== lastIndex) continue
return (
content.substring(0, index) +
newString +
content.substring(index + search.length)
)
}
}
throw new Error("oldString not found in content or was found multiple times")
}
+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`
+2 -2
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"
@@ -90,7 +90,7 @@ export const ReadTool = Tool.define({
// just warms the lsp client
await LSP.touchFile(filePath, true)
FileTimes.read(ctx.sessionID, filePath)
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()
+1 -1
View File
@@ -6,4 +6,4 @@
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
export {}
+413
View File
@@ -0,0 +1,413 @@
import { describe, expect, test } from "bun:test"
import { replace } from "../../src/tool/edit"
interface TestCase {
content: string
find: string
replace: string
all?: boolean
fail?: boolean
}
const testCases: TestCase[] = [
// SimpleReplacer cases
{
content: ["function hello() {", ' console.log("world");', "}"].join("\n"),
find: 'console.log("world");',
replace: 'console.log("universe");',
},
{
content: [
"if (condition) {",
" doSomething();",
" doSomethingElse();",
"}",
].join("\n"),
find: [" doSomething();", " doSomethingElse();"].join("\n"),
replace: [" doNewThing();", " doAnotherThing();"].join("\n"),
},
// LineTrimmedReplacer cases
{
content: ["function test() {", ' console.log("hello");', "}"].join("\n"),
find: 'console.log("hello");',
replace: 'console.log("goodbye");',
},
{
content: ["const x = 5; ", "const y = 10;"].join("\n"),
find: "const x = 5;",
replace: "const x = 15;",
},
{
content: [" if (true) {", " return false;", " }"].join("\n"),
find: ["if (true) {", "return false;", "}"].join("\n"),
replace: ["if (false) {", "return true;", "}"].join("\n"),
},
// BlockAnchorReplacer cases
{
content: [
"function calculate(a, b) {",
" const temp = a + b;",
" const result = temp * 2;",
" return result;",
"}",
].join("\n"),
find: [
"function calculate(a, b) {",
" // different middle content",
" return result;",
"}",
].join("\n"),
replace: ["function calculate(a, b) {", " return a * b * 2;", "}"].join(
"\n",
),
},
{
content: [
"class MyClass {",
" constructor() {",
" this.value = 0;",
" }",
" ",
" getValue() {",
" return this.value;",
" }",
"}",
].join("\n"),
find: ["class MyClass {", " // different implementation", "}"].join("\n"),
replace: [
"class MyClass {",
" constructor() {",
" this.value = 42;",
" }",
"}",
].join("\n"),
},
// WhitespaceNormalizedReplacer cases
{
content: ["function test() {", '\tconsole.log("hello");', "}"].join("\n"),
find: ' console.log("hello");',
replace: ' console.log("world");',
},
{
content: "const x = 5;",
find: "const x = 5;",
replace: "const x = 10;",
},
{
content: "if\t( condition\t) {",
find: "if ( condition ) {",
replace: "if (newCondition) {",
},
// IndentationFlexibleReplacer cases
{
content: [
" function nested() {",
' console.log("deeply nested");',
" return true;",
" }",
].join("\n"),
find: [
"function nested() {",
' console.log("deeply nested");',
" return true;",
"}",
].join("\n"),
replace: [
"function nested() {",
' console.log("updated");',
" return false;",
"}",
].join("\n"),
},
{
content: [
" if (true) {",
' console.log("level 1");',
' console.log("level 2");',
" }",
].join("\n"),
find: [
"if (true) {",
'console.log("level 1");',
' console.log("level 2");',
"}",
].join("\n"),
replace: ["if (true) {", 'console.log("updated");', "}"].join("\n"),
},
// replaceAll option cases
{
content: [
'console.log("test");',
'console.log("test");',
'console.log("test");',
].join("\n"),
find: 'console.log("test");',
replace: 'console.log("updated");',
all: true,
},
{
content: ['console.log("test");', 'console.log("test");'].join("\n"),
find: 'console.log("test");',
replace: 'console.log("updated");',
all: false,
},
// Error cases
{
content: 'console.log("hello");',
find: "nonexistent string",
replace: "updated",
fail: true,
},
{
content: ["test", "test", "different content", "test"].join("\n"),
find: "test",
replace: "updated",
all: false,
fail: true,
},
// Edge cases
{
content: "",
find: "",
replace: "new content",
},
{
content: "const regex = /[.*+?^${}()|[\\\\]\\\\\\\\]/g;",
find: "/[.*+?^${}()|[\\\\]\\\\\\\\]/g",
replace: "/\\\\w+/g",
},
{
content: 'const message = "Hello 世界! 🌍";',
find: "Hello 世界! 🌍",
replace: "Hello World! 🌎",
},
// EscapeNormalizedReplacer cases
{
content: 'console.log("Hello\nWorld");',
find: 'console.log("Hello\\nWorld");',
replace: 'console.log("Hello\nUniverse");',
},
{
content: "const str = 'It's working';",
find: "const str = 'It\\'s working';",
replace: "const str = 'It's fixed';",
},
{
content: "const template = `Hello ${name}`;",
find: "const template = `Hello \\${name}`;",
replace: "const template = `Hi ${name}`;",
},
{
content: "const path = 'C:\\Users\\test';",
find: "const path = 'C:\\\\Users\\\\test';",
replace: "const path = 'C:\\Users\\admin';",
},
// MultiOccurrenceReplacer cases (with replaceAll)
{
content: ["debug('start');", "debug('middle');", "debug('end');"].join(
"\n",
),
find: "debug",
replace: "log",
all: true,
},
{
content: "const x = 1; const y = 1; const z = 1;",
find: "1",
replace: "2",
all: true,
},
// TrimmedBoundaryReplacer cases
{
content: [" function test() {", " return true;", " }"].join("\n"),
find: ["function test() {", " return true;", "}"].join("\n"),
replace: ["function test() {", " return false;", "}"].join("\n"),
},
{
content: "\n const value = 42; \n",
find: "const value = 42;",
replace: "const value = 24;",
},
{
content: ["", " if (condition) {", " doSomething();", " }", ""].join(
"\n",
),
find: ["if (condition) {", " doSomething();", "}"].join("\n"),
replace: ["if (condition) {", " doNothing();", "}"].join("\n"),
},
// ContextAwareReplacer cases
{
content: [
"function calculate(a, b) {",
" const temp = a + b;",
" const result = temp * 2;",
" return result;",
"}",
].join("\n"),
find: [
"function calculate(a, b) {",
" // some different content here",
" // more different content",
" return result;",
"}",
].join("\n"),
replace: ["function calculate(a, b) {", " return (a + b) * 2;", "}"].join(
"\n",
),
},
{
content: [
"class TestClass {",
" constructor() {",
" this.value = 0;",
" }",
" ",
" method() {",
" return this.value;",
" }",
"}",
].join("\n"),
find: [
"class TestClass {",
" // different implementation",
" // with multiple lines",
"}",
].join("\n"),
replace: ["class TestClass {", " getValue() { return 42; }", "}"].join(
"\n",
),
},
// Combined edge cases for new replacers
{
content: '\tconsole.log("test");\t',
find: 'console.log("test");',
replace: 'console.log("updated");',
},
{
content: [" ", "function test() {", " return 'value';", "}", " "].join(
"\n",
),
find: ["function test() {", "return 'value';", "}"].join("\n"),
replace: ["function test() {", "return 'new value';", "}"].join("\n"),
},
// Test for same oldString and newString (should fail)
{
content: 'console.log("test");',
find: 'console.log("test");',
replace: 'console.log("test");',
fail: true,
},
// Additional tests for fixes made
// WhitespaceNormalizedReplacer - test regex special characters that could cause errors
{
content: 'const pattern = "test[123]";',
find: "test[123]",
replace: "test[456]",
},
{
content: 'const regex = "^start.*end$";',
find: "^start.*end$",
replace: "^begin.*finish$",
},
// EscapeNormalizedReplacer - test single backslash vs double backslash
{
content: 'const path = "C:\\Users";',
find: 'const path = "C:\\Users";',
replace: 'const path = "D:\\Users";',
},
{
content: 'console.log("Line1\\nLine2");',
find: 'console.log("Line1\\nLine2");',
replace: 'console.log("First\\nSecond");',
},
// BlockAnchorReplacer - test edge case with exact newline boundaries
{
content: ["function test() {", " return true;", "}"].join("\n"),
find: ["function test() {", " // middle", "}"].join("\n"),
replace: ["function test() {", " return false;", "}"].join("\n"),
},
// ContextAwareReplacer - test with trailing newline in find string
{
content: [
"class Test {",
" method1() {",
" return 1;",
" }",
"}",
].join("\n"),
find: [
"class Test {",
" // different content",
"}",
"", // trailing empty line
].join("\n"),
replace: ["class Test {", " method2() { return 2; }", "}"].join("\n"),
},
// Test validation for empty strings with same oldString and newString
{
content: "",
find: "",
replace: "",
fail: true,
},
// Test multiple occurrences with replaceAll=false (should fail)
{
content: ["const a = 1;", "const b = 1;", "const c = 1;"].join("\n"),
find: "= 1",
replace: "= 2",
all: false,
fail: true,
},
// Test whitespace normalization with multiple spaces and tabs mixed
{
content: "if\t \t( \tcondition\t )\t{",
find: "if ( condition ) {",
replace: "if (newCondition) {",
},
// Test escape sequences in template literals
{
content: "const msg = `Hello\\tWorld`;",
find: "const msg = `Hello\\tWorld`;",
replace: "const msg = `Hi\\tWorld`;",
},
]
describe("EditTool Replacers", () => {
test.each(testCases)("case %#", (testCase) => {
if (testCase.fail) {
expect(() => {
replace(testCase.content, testCase.find, testCase.replace, testCase.all)
}).toThrow()
} else {
const result = replace(
testCase.content,
testCase.find,
testCase.replace,
testCase.all,
)
expect(result).toContain(testCase.replace)
}
})
})
+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
+10 -9
View File
@@ -9,6 +9,8 @@ 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"
@@ -25,7 +27,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 +51,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,13 +78,7 @@ 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)
evts, err := client.Event(httpClient, url, ctx)
if err != nil {
slog.Error("Failed to subscribe to events", "error", err)
os.Exit(1)
+7 -2
View File
@@ -16,6 +16,8 @@ require (
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.5
github.com/tidwall/gjson v1.14.4
rsc.io/qr v0.2.0
)
@@ -48,6 +50,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 +73,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
View File
@@ -191,6 +191,8 @@ github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wx
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.5 h1:iZjdSHLo6jOMjUbDH5JWi+44v76yNbEktsRqG/Qxrco=
github.com/sst/opencode-sdk-go v0.1.0-alpha.5/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=
@@ -198,6 +200,16 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
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=
+95 -147
View File
@@ -11,34 +11,35 @@ 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
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 +51,24 @@ 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
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 +78,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 +97,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 +115,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 +125,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 +148,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 +160,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 +178,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 +185,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 +210,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 +233,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 +248,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 +287,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 +306,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 +330,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) {
+49 -28
View File
@@ -26,6 +26,9 @@ type EditorComponent interface {
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
@@ -156,6 +163,18 @@ func (m *editorComponent) View() string {
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 +182,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 +314,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(),
),
)
}
+170 -63
View File
@@ -12,12 +12,13 @@ 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"
)
@@ -129,15 +130,13 @@ func renderContentBlock(content string, options ...renderingOption) string {
option(renderer)
}
style := styles.BaseStyle().
style := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()).
// 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
@@ -179,13 +178,13 @@ func renderContentBlock(content string, options ...renderingOption) string {
layout.Current.Container.Width,
align,
content,
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
styles.WhitespaceStyle(t.Background()),
)
content = lipgloss.PlaceHorizontal(
layout.Current.Viewport.Width,
lipgloss.Center,
content,
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
styles.WhitespaceStyle(t.Background()),
)
if renderer.marginTop > 0 {
for range renderer.marginTop {
@@ -211,7 +210,7 @@ func calculatePadding() int {
}
}
func renderText(message client.MessageInfo, text string, author string) string {
func renderText(message opencode.Message, text string, author string) string {
t := theme.CurrentTheme()
width := layout.Current.Container.Width
padding := calculatePadding()
@@ -225,23 +224,30 @@ func renderText(message client.MessageInfo, text string, author string) string {
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
if message.Role == opencode.MessageRoleAssistant {
markdownWidth = width - padding - 4 - 3
}
if message.Role == client.User {
text = strings.ReplaceAll(text, "<", "\\<")
text = strings.ReplaceAll(text, ">", "\\>")
minWidth := max(markdownWidth, (width-4)/2)
messageStyle := styles.NewStyle().
Width(minWidth).
Background(t.BackgroundPanel()).
Foreground(t.Text())
if textWidth < minWidth {
messageStyle = messageStyle.AlignHorizontal(lipgloss.Right)
}
content := messageStyle.Render(text)
if message.Role == opencode.MessageRoleAssistant {
content = toMarkdown(text, markdownWidth, t.BackgroundPanel())
}
content := toMarkdown(text, markdownWidth, t.BackgroundPanel())
content = strings.Join([]string{content, info}, "\n")
switch message.Role {
case client.User:
case opencode.MessageRoleUser:
return renderContentBlock(content,
WithAlign(lipgloss.Right),
WithBorderColor(t.Secondary()),
)
case client.Assistant:
case opencode.MessageRoleAssistant:
return renderContentBlock(content,
WithAlign(lipgloss.Left),
WithBorderColor(t.Accent()),
@@ -251,15 +257,16 @@ func renderText(message client.MessageInfo, text string, author string) string {
}
func renderToolInvocation(
toolCall client.MessageToolInvocationToolCall,
toolCall opencode.ToolInvocationPart,
result *string,
metadata client.MessageMetadata_Tool_AdditionalProperties,
metadata opencode.MessageMetadataTool,
showDetails bool,
isLast bool,
contentOnly bool,
messageMetadata opencode.MessageMetadata,
) string {
ignoredTools := []string{"todoread"}
if slices.Contains(ignoredTools, toolCall.ToolName) {
if slices.Contains(ignoredTools, toolCall.ToolInvocation.ToolName) {
return ""
}
@@ -275,9 +282,10 @@ func renderToolInvocation(
}
t := theme.CurrentTheme()
style := styles.Muted().
Width(outerWidth).
style := styles.NewStyle().
Foreground(t.TextMuted()).
Background(t.BackgroundPanel()).
Width(outerWidth).
PaddingTop(paddingTop).
PaddingBottom(paddingBottom).
PaddingLeft(2).
@@ -288,12 +296,14 @@ func renderToolInvocation(
BorderForeground(t.BackgroundPanel()).
BorderStyle(lipgloss.ThickBorder())
if toolCall.State == "partial-call" {
title := renderToolAction(toolCall.ToolName)
if toolCall.ToolInvocation.State == "partial-call" {
title := renderToolAction(toolCall.ToolInvocation.ToolName)
if !showDetails {
title = "∟ " + title
padding := calculatePadding()
style := lipgloss.NewStyle().Width(outerWidth - padding - 4).Background(t.BackgroundPanel())
style := styles.NewStyle().
Background(t.BackgroundPanel()).
Width(outerWidth - padding - 4 - 3)
return renderContentBlock(style.Render(title),
WithAlign(lipgloss.Left),
WithBorderColor(t.Accent()),
@@ -308,8 +318,8 @@ func renderToolInvocation(
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
@@ -331,28 +341,35 @@ func renderToolInvocation(
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),
)
}
er := messageMetadata.Error.AsUnion()
switch er.(type) {
case nil:
default:
clientError := er.(opencode.UnknownError)
error = clientError.Data.Message
}
if error != "" {
style = style.BorderLeftForeground(t.Error())
error = styles.NewStyle().
Foreground(t.Error()).
Background(t.BackgroundPanel()).
Render(error)
error = renderContentBlock(
error,
WithFullWidth(),
WithBorderColor(t.Error()),
WithMarginBottom(1),
)
}
title := ""
switch toolCall.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 {
preview := metadata.ExtraFields["preview"]
if preview != nil && toolArgsMap["filePath"] != nil {
filename := toolArgsMap["filePath"].(string)
body = preview.(string)
body = renderFile(filename, body, WithTruncate(6))
@@ -360,8 +377,9 @@ func renderToolInvocation(
case "edit":
if filename, ok := toolArgsMap["filePath"].(string); ok {
title = fmt.Sprintf("EDIT %s", relative(filename))
if d, ok := metadata.Get("diff"); ok {
patch := d.(string)
diffField := metadata.ExtraFields["diff"]
if diffField != nil {
patch := diffField.(string)
var formattedDiff string
if layout.Current.Viewport.Width < 80 {
formattedDiff, _ = diff.FormatUnifiedDiff(
@@ -374,7 +392,7 @@ func renderToolInvocation(
formattedDiff, _ = diff.FormatDiff(filename, patch, diff.WithTotalWidth(diffWidth))
}
formattedDiff = strings.TrimSpace(formattedDiff)
formattedDiff = lipgloss.NewStyle().
formattedDiff = styles.NewStyle().
BorderStyle(lipgloss.ThickBorder()).
BorderBackground(t.Background()).
BorderForeground(t.BackgroundPanel()).
@@ -394,8 +412,13 @@ func renderToolInvocation(
lipgloss.Center,
lipgloss.Top,
body,
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
styles.WhitespaceStyle(t.Background()),
)
// Add diagnostics at the bottom if they exist
if diagnostics := renderDiagnostics(messageMetadata, filename); diagnostics != "" {
body += "\n" + renderContentBlock(diagnostics, WithFullWidth(), WithBorderColor(t.Error()))
}
}
}
case "write":
@@ -403,15 +426,21 @@ func renderToolInvocation(
title = fmt.Sprintf("WRITE %s", relative(filename))
if content, ok := toolArgsMap["content"].(string); ok {
body = renderFile(filename, content)
// Add diagnostics at the bottom if they exist
if diagnostics := renderDiagnostics(messageMetadata, filename); diagnostics != "" {
body += "\n" + renderContentBlock(diagnostics, WithFullWidth(), WithBorderColor(t.Error()))
}
}
}
case "bash":
if description, ok := toolArgsMap["description"].(string); ok {
title = fmt.Sprintf("SHELL %s", description)
}
if stdout, ok := metadata.Get("stdout"); ok {
stdout := metadata.JSON.ExtraFields["stdout"]
if !stdout.IsNull() {
command := toolArgsMap["command"].(string)
stdout := stdout.(string)
stdout := stdout.Raw()
body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
body = toMarkdown(body, innerWidth, t.BackgroundPanel())
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
@@ -432,12 +461,13 @@ func renderToolInvocation(
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) {
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":
@@ -452,21 +482,22 @@ func renderToolInvocation(
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 :=
summary := metadata.JSON.ExtraFields["summary"]
if !summary.IsNull() {
strValue := summary.Raw()
toolcalls := gjson.Parse(strValue).Array()
steps := []string{}
for _, toolcall := range toolcalls {
call := toolcall.(map[string]any)
call := toolcall.Value().(map[string]any)
if toolInvocation, ok := call["toolInvocation"].(map[string]any); ok {
data, _ := json.Marshal(toolInvocation)
var toolCall client.MessageToolInvocationToolCall
var toolCall opencode.ToolInvocationPart
_ = json.Unmarshal(data, &toolCall)
if metadata, ok := call["metadata"].(map[string]any); ok {
data, _ = json.Marshal(metadata)
var toolMetadata client.MessageMetadata_Tool_AdditionalProperties
var toolMetadata opencode.MessageMetadataTool
_ = json.Unmarshal(data, &toolMetadata)
step := renderToolInvocation(
@@ -476,6 +507,7 @@ func renderToolInvocation(
false,
false,
true,
messageMetadata,
)
steps = append(steps, step)
}
@@ -487,7 +519,7 @@ func renderToolInvocation(
}
default:
toolName := renderToolName(toolCall.ToolName)
toolName := renderToolName(toolCall.ToolInvocation.ToolName)
title = fmt.Sprintf("%s %s", toolName, toolArgs)
if result == nil {
empty := ""
@@ -506,7 +538,7 @@ func renderToolInvocation(
if !showDetails {
title = "∟ " + title
padding := calculatePadding()
style := lipgloss.NewStyle().Width(outerWidth - padding - 4).Background(t.BackgroundPanel())
style := styles.NewStyle().Background(t.BackgroundPanel()).Width(outerWidth - padding - 4 - 3)
paddingBottom := 0
if isLast {
paddingBottom = 1
@@ -519,7 +551,7 @@ func renderToolInvocation(
)
}
if body == "" && error == "" {
if body == "" && error == "" && result != nil {
body = *result
body = truncateHeight(body, 10)
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
@@ -530,7 +562,7 @@ func renderToolInvocation(
layout.Current.Viewport.Width,
lipgloss.Center,
content,
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
styles.WhitespaceStyle(t.Background()),
)
if showDetails && body != "" && error == "" {
content += "\n" + body
@@ -684,3 +716,78 @@ 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.MessageMetadata, filePath string) string {
diagnosticsData := metadata.JSON.ExtraFields["diagnostics"]
if diagnosticsData.IsNull() {
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 ""
}
var errorDiagnostics []string
for _, diagInterface := range diagnosticsList {
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()
}
@@ -9,13 +9,14 @@ 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 {
@@ -83,7 +84,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()
@@ -121,99 +122,110 @@ func (m *messagesComponent) renderView() {
return
}
measure := util.Measure("messages.renderView")
defer measure("messageCount", len(m.app.Messages))
t := theme.CurrentTheme()
blocks := make([]string, 0)
previousBlockType := none
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:
switch p.Type {
case opencode.MessagePartTypeText:
lastToolIndices = append(lastToolIndices, lastToolIndex)
case client.MessagePartToolInvocation:
case opencode.MessagePartTypeToolInvocation:
lastToolIndex = i
}
}
author := ""
switch message.Role {
case client.User:
case opencode.MessageRoleUser:
author = m.app.Info.User
case client.Assistant:
case opencode.MessageRoleAssistant:
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) {
switch part := p.AsUnion().(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)
case opencode.TextPart:
key := m.cache.GenerateKey(message.ID, p.Text, layout.Current.Viewport.Width)
content, cached = m.cache.Get(key)
if !cached {
content = renderText(message, text.Text, author)
content = renderText(message, p.Text, author)
m.cache.Set(key, content)
}
if previousBlockType != none {
blocks = append(blocks, "")
}
blocks = append(blocks, content)
if message.Role == client.User {
if message.Role == opencode.MessageRoleUser {
previousBlockType = userTextBlock
} else if message.Role == client.Assistant {
} else if message.Role == opencode.MessageRoleAssistant {
previousBlockType = assistantTextBlock
}
case client.MessagePartToolInvocation:
case opencode.ToolInvocationPart:
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
metadata := opencode.MessageMetadataTool{}
toolCallID := part.ToolInvocation.ToolCallID
// var toolCallID string
// var result *string
// switch toolCall := part.ToolInvocation.AsUnion().(type) {
// case opencode.ToolCall:
// toolCallID = toolCall.ToolCallID
// case opencode.ToolPartialCall:
// toolCallID = toolCall.ToolCallID
// case opencode.ToolResult:
// toolCallID = toolCall.ToolCallID
// result = &toolCall.Result
// }
if _, ok := message.Metadata.Tool[toolCallID]; ok {
metadata = message.Metadata.Tool[toolCallID]
}
if toolCall.State == "result" {
key := m.cache.GenerateKey(message.Id,
toolCall.ToolCallId,
var result *string
if part.ToolInvocation.Result != "" {
result = &part.ToolInvocation.Result
}
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 = renderToolInvocation(
toolCall,
part,
result,
metadata,
m.showToolDetails,
isLastToolInvocation,
false,
message.Metadata,
)
m.cache.Set(key, content)
}
} else {
// if the tool call isn't finished, don't cache
content = renderToolInvocation(
toolCall,
part,
result,
metadata,
m.showToolDetails,
isLastToolInvocation,
false,
message.Metadata,
)
}
@@ -226,16 +238,17 @@ func (m *messagesComponent) renderView() {
}
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:
default:
clientError := err.(opencode.UnknownError)
error = clientError.Data.Message
}
if error != "" {
error = renderContentBlock(error, WithBorderColor(t.Error()), WithFullWidth(), WithMarginTop(1), WithMarginBottom(1))
blocks = append(blocks, error)
previousBlockType = errorBlock
}
}
@@ -245,7 +258,7 @@ func (m *messagesComponent) renderView() {
m.width,
lipgloss.Center,
block,
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
styles.WhitespaceStyle(t.Background()),
))
}
@@ -254,28 +267,28 @@ func (m *messagesComponent) renderView() {
}
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()).
@@ -306,7 +319,7 @@ func (m *messagesComponent) View() string {
m.width,
lipgloss.Center,
m.header(),
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
styles.WhitespaceStyle(t.Background()),
),
m.viewport.View(),
)
@@ -314,9 +327,9 @@ func (m *messagesComponent) View() string {
func (m *messagesComponent) home() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle().Background(t.Background())
baseStyle := styles.NewStyle().Background(t.Background())
base := baseStyle.Render
muted := styles.Muted().Background(t.Background()).Render
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
open := `
█▀▀█ █▀▀█ █▀▀ █▀▀▄
@@ -335,9 +348,9 @@ func (m *messagesComponent) home() string {
// cwd := app.Info.Path.Cwd
// config := app.Info.Path.Config
versionStyle := lipgloss.NewStyle().
Background(t.Background()).
versionStyle := styles.NewStyle().
Foreground(t.TextMuted()).
Background(t.Background()).
Width(lipgloss.Width(logo)).
Align(lipgloss.Right)
version := versionStyle.Render(m.app.Version)
@@ -347,14 +360,14 @@ func (m *messagesComponent) home() string {
m.width,
lipgloss.Center,
logoAndVersion,
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
styles.WhitespaceStyle(t.Background()),
)
m.commands.SetBackgroundColor(t.Background())
commands := lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
m.commands.View(),
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
styles.WhitespaceStyle(t.Background()),
)
lines := []string{}
@@ -372,7 +385,7 @@ func (m *messagesComponent) home() string {
lipgloss.Center,
lipgloss.Center,
baseStyle.Render(strings.Join(lines, "\n")),
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
styles.WhitespaceStyle(t.Background()),
)
}
@@ -60,15 +60,9 @@ func (c *commandsComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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 +93,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 +128,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 +183,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())
}
@@ -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 {
+2 -3
View File
@@ -3,6 +3,7 @@ package layout
import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
@@ -57,7 +58,7 @@ func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (c *container) View() string {
t := theme.CurrentTheme()
style := lipgloss.NewStyle()
style := styles.NewStyle().Background(t.Background())
width := c.width
height := c.height
@@ -66,8 +67,6 @@ func (c *container) View() string {
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
+3 -2
View File
@@ -3,6 +3,7 @@ package layout
import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
@@ -66,7 +67,7 @@ func (f *flexLayout) View() string {
alignment,
child.View(),
// TODO: make configurable WithBackgroundStyle
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
lipgloss.WithWhitespaceStyle(styles.NewStyle().Background(t.Background()).Lipgloss()),
)
views = append(views, view)
} else {
@@ -78,7 +79,7 @@ func (f *flexLayout) View() string {
alignment,
child.View(),
// TODO: make configurable WithBackgroundStyle
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
lipgloss.WithWhitespaceStyle(styles.NewStyle().Background(t.Background()).Lipgloss()),
)
views = append(views, view)
}
@@ -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"
}
}
}
@@ -0,0 +1,221 @@
{
"$schema": "https://opencode.ai/theme.json",
"defs": {
"background": "#272822",
"backgroundAlt": "#1e1f1c",
"backgroundPanel": "#3e3d32",
"foreground": "#f8f8f2",
"comment": "#75715e",
"red": "#f92672",
"orange": "#fd971f",
"lightOrange": "#e69f66",
"yellow": "#e6db74",
"green": "#a6e22e",
"cyan": "#66d9ef",
"blue": "#66d9ef",
"purple": "#ae81ff",
"pink": "#f92672"
},
"theme": {
"primary": {
"dark": "cyan",
"light": "blue"
},
"secondary": {
"dark": "purple",
"light": "purple"
},
"accent": {
"dark": "green",
"light": "green"
},
"error": {
"dark": "red",
"light": "red"
},
"warning": {
"dark": "yellow",
"light": "orange"
},
"success": {
"dark": "green",
"light": "green"
},
"info": {
"dark": "orange",
"light": "orange"
},
"text": {
"dark": "foreground",
"light": "#272822"
},
"textMuted": {
"dark": "comment",
"light": "#75715e"
},
"background": {
"dark": "#272822",
"light": "#fafafa"
},
"backgroundPanel": {
"dark": "#1e1f1c",
"light": "#f0f0f0"
},
"backgroundElement": {
"dark": "#3e3d32",
"light": "#e0e0e0"
},
"border": {
"dark": "#3e3d32",
"light": "#d0d0d0"
},
"borderActive": {
"dark": "cyan",
"light": "blue"
},
"borderSubtle": {
"dark": "#1e1f1c",
"light": "#e8e8e8"
},
"diffAdded": {
"dark": "green",
"light": "green"
},
"diffRemoved": {
"dark": "red",
"light": "red"
},
"diffContext": {
"dark": "comment",
"light": "#75715e"
},
"diffHunkHeader": {
"dark": "comment",
"light": "#75715e"
},
"diffHighlightAdded": {
"dark": "green",
"light": "green"
},
"diffHighlightRemoved": {
"dark": "red",
"light": "red"
},
"diffAddedBg": {
"dark": "#1a3a1a",
"light": "#e0ffe0"
},
"diffRemovedBg": {
"dark": "#3a1a1a",
"light": "#ffe0e0"
},
"diffContextBg": {
"dark": "#1e1f1c",
"light": "#f0f0f0"
},
"diffLineNumber": {
"dark": "#3e3d32",
"light": "#d0d0d0"
},
"diffAddedLineNumberBg": {
"dark": "#1a3a1a",
"light": "#e0ffe0"
},
"diffRemovedLineNumberBg": {
"dark": "#3a1a1a",
"light": "#ffe0e0"
},
"markdownText": {
"dark": "foreground",
"light": "#272822"
},
"markdownHeading": {
"dark": "pink",
"light": "pink"
},
"markdownLink": {
"dark": "cyan",
"light": "blue"
},
"markdownLinkText": {
"dark": "purple",
"light": "purple"
},
"markdownCode": {
"dark": "green",
"light": "green"
},
"markdownBlockQuote": {
"dark": "comment",
"light": "#75715e"
},
"markdownEmph": {
"dark": "yellow",
"light": "orange"
},
"markdownStrong": {
"dark": "orange",
"light": "orange"
},
"markdownHorizontalRule": {
"dark": "comment",
"light": "#75715e"
},
"markdownListItem": {
"dark": "cyan",
"light": "blue"
},
"markdownListEnumeration": {
"dark": "purple",
"light": "purple"
},
"markdownImage": {
"dark": "cyan",
"light": "blue"
},
"markdownImageText": {
"dark": "purple",
"light": "purple"
},
"markdownCodeBlock": {
"dark": "foreground",
"light": "#272822"
},
"syntaxComment": {
"dark": "comment",
"light": "#75715e"
},
"syntaxKeyword": {
"dark": "pink",
"light": "pink"
},
"syntaxFunction": {
"dark": "green",
"light": "green"
},
"syntaxVariable": {
"dark": "foreground",
"light": "#272822"
},
"syntaxString": {
"dark": "yellow",
"light": "orange"
},
"syntaxNumber": {
"dark": "purple",
"light": "purple"
},
"syntaxType": {
"dark": "cyan",
"light": "blue"
},
"syntaxOperator": {
"dark": "pink",
"light": "pink"
},
"syntaxPunctuation": {
"dark": "foreground",
"light": "#272822"
}
}
}
@@ -243,4 +243,3 @@
}
}
}
@@ -0,0 +1,222 @@
{
"$schema": "https://opencode.ai/theme.json",
"defs": {
"background": "#292d3e",
"backgroundAlt": "#1e2132",
"backgroundPanel": "#32364a",
"foreground": "#a6accd",
"foregroundBright": "#bfc7d5",
"comment": "#676e95",
"red": "#f07178",
"orange": "#f78c6c",
"yellow": "#ffcb6b",
"green": "#c3e88d",
"cyan": "#89ddff",
"blue": "#82aaff",
"purple": "#c792ea",
"magenta": "#ff5370",
"pink": "#f07178"
},
"theme": {
"primary": {
"dark": "blue",
"light": "#4976eb"
},
"secondary": {
"dark": "purple",
"light": "#a854f2"
},
"accent": {
"dark": "cyan",
"light": "#00acc1"
},
"error": {
"dark": "red",
"light": "#e53935"
},
"warning": {
"dark": "yellow",
"light": "#ffb300"
},
"success": {
"dark": "green",
"light": "#91b859"
},
"info": {
"dark": "orange",
"light": "#f4511e"
},
"text": {
"dark": "foreground",
"light": "#292d3e"
},
"textMuted": {
"dark": "comment",
"light": "#8796b0"
},
"background": {
"dark": "#292d3e",
"light": "#fafafa"
},
"backgroundPanel": {
"dark": "#1e2132",
"light": "#f5f5f5"
},
"backgroundElement": {
"dark": "#32364a",
"light": "#e7e7e8"
},
"border": {
"dark": "#32364a",
"light": "#e0e0e0"
},
"borderActive": {
"dark": "blue",
"light": "#4976eb"
},
"borderSubtle": {
"dark": "#1e2132",
"light": "#eeeeee"
},
"diffAdded": {
"dark": "green",
"light": "#91b859"
},
"diffRemoved": {
"dark": "red",
"light": "#e53935"
},
"diffContext": {
"dark": "comment",
"light": "#8796b0"
},
"diffHunkHeader": {
"dark": "cyan",
"light": "#00acc1"
},
"diffHighlightAdded": {
"dark": "green",
"light": "#91b859"
},
"diffHighlightRemoved": {
"dark": "red",
"light": "#e53935"
},
"diffAddedBg": {
"dark": "#2e3c2b",
"light": "#e8f5e9"
},
"diffRemovedBg": {
"dark": "#3c2b2b",
"light": "#ffebee"
},
"diffContextBg": {
"dark": "#1e2132",
"light": "#f5f5f5"
},
"diffLineNumber": {
"dark": "#444760",
"light": "#cfd8dc"
},
"diffAddedLineNumberBg": {
"dark": "#2e3c2b",
"light": "#e8f5e9"
},
"diffRemovedLineNumberBg": {
"dark": "#3c2b2b",
"light": "#ffebee"
},
"markdownText": {
"dark": "foreground",
"light": "#292d3e"
},
"markdownHeading": {
"dark": "purple",
"light": "#a854f2"
},
"markdownLink": {
"dark": "blue",
"light": "#4976eb"
},
"markdownLinkText": {
"dark": "cyan",
"light": "#00acc1"
},
"markdownCode": {
"dark": "green",
"light": "#91b859"
},
"markdownBlockQuote": {
"dark": "comment",
"light": "#8796b0"
},
"markdownEmph": {
"dark": "yellow",
"light": "#ffb300"
},
"markdownStrong": {
"dark": "orange",
"light": "#f4511e"
},
"markdownHorizontalRule": {
"dark": "comment",
"light": "#8796b0"
},
"markdownListItem": {
"dark": "blue",
"light": "#4976eb"
},
"markdownListEnumeration": {
"dark": "cyan",
"light": "#00acc1"
},
"markdownImage": {
"dark": "blue",
"light": "#4976eb"
},
"markdownImageText": {
"dark": "cyan",
"light": "#00acc1"
},
"markdownCodeBlock": {
"dark": "foreground",
"light": "#292d3e"
},
"syntaxComment": {
"dark": "comment",
"light": "#8796b0"
},
"syntaxKeyword": {
"dark": "purple",
"light": "#a854f2"
},
"syntaxFunction": {
"dark": "blue",
"light": "#4976eb"
},
"syntaxVariable": {
"dark": "foreground",
"light": "#292d3e"
},
"syntaxString": {
"dark": "green",
"light": "#91b859"
},
"syntaxNumber": {
"dark": "orange",
"light": "#f4511e"
},
"syntaxType": {
"dark": "yellow",
"light": "#ffb300"
},
"syntaxOperator": {
"dark": "cyan",
"light": "#00acc1"
},
"syntaxPunctuation": {
"dark": "foreground",
"light": "#292d3e"
}
}
}
@@ -0,0 +1,234 @@
{
"$schema": "https://opencode.ai/theme.json",
"defs": {
"base": "#191724",
"surface": "#1f1d2e",
"overlay": "#26233a",
"muted": "#6e6a86",
"subtle": "#908caa",
"text": "#e0def4",
"love": "#eb6f92",
"gold": "#f6c177",
"rose": "#ebbcba",
"pine": "#31748f",
"foam": "#9ccfd8",
"iris": "#c4a7e7",
"highlightLow": "#21202e",
"highlightMed": "#403d52",
"highlightHigh": "#524f67",
"moonBase": "#232136",
"moonSurface": "#2a273f",
"moonOverlay": "#393552",
"moonMuted": "#6e6a86",
"moonSubtle": "#908caa",
"moonText": "#e0def4",
"dawnBase": "#faf4ed",
"dawnSurface": "#fffaf3",
"dawnOverlay": "#f2e9e1",
"dawnMuted": "#9893a5",
"dawnSubtle": "#797593",
"dawnText": "#575279"
},
"theme": {
"primary": {
"dark": "foam",
"light": "pine"
},
"secondary": {
"dark": "iris",
"light": "#907aa9"
},
"accent": {
"dark": "rose",
"light": "#d7827e"
},
"error": {
"dark": "love",
"light": "#b4637a"
},
"warning": {
"dark": "gold",
"light": "#ea9d34"
},
"success": {
"dark": "pine",
"light": "#286983"
},
"info": {
"dark": "foam",
"light": "#56949f"
},
"text": {
"dark": "#e0def4",
"light": "#575279"
},
"textMuted": {
"dark": "muted",
"light": "dawnMuted"
},
"background": {
"dark": "base",
"light": "dawnBase"
},
"backgroundPanel": {
"dark": "surface",
"light": "dawnSurface"
},
"backgroundElement": {
"dark": "overlay",
"light": "dawnOverlay"
},
"border": {
"dark": "highlightMed",
"light": "#dfdad9"
},
"borderActive": {
"dark": "foam",
"light": "pine"
},
"borderSubtle": {
"dark": "highlightLow",
"light": "#f4ede8"
},
"diffAdded": {
"dark": "pine",
"light": "#286983"
},
"diffRemoved": {
"dark": "love",
"light": "#b4637a"
},
"diffContext": {
"dark": "muted",
"light": "dawnMuted"
},
"diffHunkHeader": {
"dark": "iris",
"light": "#907aa9"
},
"diffHighlightAdded": {
"dark": "pine",
"light": "#286983"
},
"diffHighlightRemoved": {
"dark": "love",
"light": "#b4637a"
},
"diffAddedBg": {
"dark": "#1f2d3a",
"light": "#e5f2f3"
},
"diffRemovedBg": {
"dark": "#3a1f2d",
"light": "#fce5e8"
},
"diffContextBg": {
"dark": "surface",
"light": "dawnSurface"
},
"diffLineNumber": {
"dark": "muted",
"light": "dawnMuted"
},
"diffAddedLineNumberBg": {
"dark": "#1f2d3a",
"light": "#e5f2f3"
},
"diffRemovedLineNumberBg": {
"dark": "#3a1f2d",
"light": "#fce5e8"
},
"markdownText": {
"dark": "#e0def4",
"light": "#575279"
},
"markdownHeading": {
"dark": "iris",
"light": "#907aa9"
},
"markdownLink": {
"dark": "foam",
"light": "pine"
},
"markdownLinkText": {
"dark": "rose",
"light": "#d7827e"
},
"markdownCode": {
"dark": "pine",
"light": "#286983"
},
"markdownBlockQuote": {
"dark": "muted",
"light": "dawnMuted"
},
"markdownEmph": {
"dark": "gold",
"light": "#ea9d34"
},
"markdownStrong": {
"dark": "love",
"light": "#b4637a"
},
"markdownHorizontalRule": {
"dark": "highlightMed",
"light": "#dfdad9"
},
"markdownListItem": {
"dark": "foam",
"light": "pine"
},
"markdownListEnumeration": {
"dark": "rose",
"light": "#d7827e"
},
"markdownImage": {
"dark": "foam",
"light": "pine"
},
"markdownImageText": {
"dark": "rose",
"light": "#d7827e"
},
"markdownCodeBlock": {
"dark": "#e0def4",
"light": "#575279"
},
"syntaxComment": {
"dark": "muted",
"light": "dawnMuted"
},
"syntaxKeyword": {
"dark": "pine",
"light": "#286983"
},
"syntaxFunction": {
"dark": "rose",
"light": "#d7827e"
},
"syntaxVariable": {
"dark": "#e0def4",
"light": "#575279"
},
"syntaxString": {
"dark": "gold",
"light": "#ea9d34"
},
"syntaxNumber": {
"dark": "iris",
"light": "#907aa9"
},
"syntaxType": {
"dark": "foam",
"light": "#56949f"
},
"syntaxOperator": {
"dark": "subtle",
"light": "dawnSubtle"
},
"syntaxPunctuation": {
"dark": "subtle",
"light": "dawnSubtle"
}
}
}
@@ -0,0 +1,223 @@
{
"$schema": "https://opencode.ai/theme.json",
"defs": {
"base03": "#002b36",
"base02": "#073642",
"base01": "#586e75",
"base00": "#657b83",
"base0": "#839496",
"base1": "#93a1a1",
"base2": "#eee8d5",
"base3": "#fdf6e3",
"yellow": "#b58900",
"orange": "#cb4b16",
"red": "#dc322f",
"magenta": "#d33682",
"violet": "#6c71c4",
"blue": "#268bd2",
"cyan": "#2aa198",
"green": "#859900"
},
"theme": {
"primary": {
"dark": "blue",
"light": "blue"
},
"secondary": {
"dark": "violet",
"light": "violet"
},
"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": "base0",
"light": "base00"
},
"textMuted": {
"dark": "base01",
"light": "base1"
},
"background": {
"dark": "base03",
"light": "base3"
},
"backgroundPanel": {
"dark": "base02",
"light": "base2"
},
"backgroundElement": {
"dark": "#073642",
"light": "#eee8d5"
},
"border": {
"dark": "base02",
"light": "base2"
},
"borderActive": {
"dark": "base01",
"light": "base1"
},
"borderSubtle": {
"dark": "#073642",
"light": "#eee8d5"
},
"diffAdded": {
"dark": "green",
"light": "green"
},
"diffRemoved": {
"dark": "red",
"light": "red"
},
"diffContext": {
"dark": "base01",
"light": "base1"
},
"diffHunkHeader": {
"dark": "base01",
"light": "base1"
},
"diffHighlightAdded": {
"dark": "green",
"light": "green"
},
"diffHighlightRemoved": {
"dark": "red",
"light": "red"
},
"diffAddedBg": {
"dark": "#073642",
"light": "#eee8d5"
},
"diffRemovedBg": {
"dark": "#073642",
"light": "#eee8d5"
},
"diffContextBg": {
"dark": "base02",
"light": "base2"
},
"diffLineNumber": {
"dark": "base01",
"light": "base1"
},
"diffAddedLineNumberBg": {
"dark": "#073642",
"light": "#eee8d5"
},
"diffRemovedLineNumberBg": {
"dark": "#073642",
"light": "#eee8d5"
},
"markdownText": {
"dark": "base0",
"light": "base00"
},
"markdownHeading": {
"dark": "blue",
"light": "blue"
},
"markdownLink": {
"dark": "cyan",
"light": "cyan"
},
"markdownLinkText": {
"dark": "violet",
"light": "violet"
},
"markdownCode": {
"dark": "green",
"light": "green"
},
"markdownBlockQuote": {
"dark": "base01",
"light": "base1"
},
"markdownEmph": {
"dark": "yellow",
"light": "yellow"
},
"markdownStrong": {
"dark": "orange",
"light": "orange"
},
"markdownHorizontalRule": {
"dark": "base01",
"light": "base1"
},
"markdownListItem": {
"dark": "blue",
"light": "blue"
},
"markdownListEnumeration": {
"dark": "cyan",
"light": "cyan"
},
"markdownImage": {
"dark": "cyan",
"light": "cyan"
},
"markdownImageText": {
"dark": "violet",
"light": "violet"
},
"markdownCodeBlock": {
"dark": "base0",
"light": "base00"
},
"syntaxComment": {
"dark": "base01",
"light": "base1"
},
"syntaxKeyword": {
"dark": "green",
"light": "green"
},
"syntaxFunction": {
"dark": "blue",
"light": "blue"
},
"syntaxVariable": {
"dark": "cyan",
"light": "cyan"
},
"syntaxString": {
"dark": "cyan",
"light": "cyan"
},
"syntaxNumber": {
"dark": "magenta",
"light": "magenta"
},
"syntaxType": {
"dark": "yellow",
"light": "yellow"
},
"syntaxOperator": {
"dark": "green",
"light": "green"
},
"syntaxPunctuation": {
"dark": "base0",
"light": "base00"
}
}
}
@@ -0,0 +1,226 @@
{
"$schema": "https://opencode.ai/theme.json",
"defs": {
"background": "#262335",
"backgroundAlt": "#1e1a29",
"backgroundPanel": "#2a2139",
"foreground": "#ffffff",
"foregroundMuted": "#848bbd",
"pink": "#ff7edb",
"pinkBright": "#ff92df",
"cyan": "#36f9f6",
"cyanBright": "#72f1f8",
"yellow": "#fede5d",
"yellowBright": "#fff95d",
"orange": "#ff8b39",
"orangeBright": "#ff9f43",
"purple": "#b084eb",
"purpleBright": "#c792ea",
"red": "#fe4450",
"redBright": "#ff5e5b",
"green": "#72f1b8",
"greenBright": "#97f1d8"
},
"theme": {
"primary": {
"dark": "cyan",
"light": "#00bcd4"
},
"secondary": {
"dark": "pink",
"light": "#e91e63"
},
"accent": {
"dark": "purple",
"light": "#9c27b0"
},
"error": {
"dark": "red",
"light": "#f44336"
},
"warning": {
"dark": "yellow",
"light": "#ff9800"
},
"success": {
"dark": "green",
"light": "#4caf50"
},
"info": {
"dark": "orange",
"light": "#ff5722"
},
"text": {
"dark": "foreground",
"light": "#262335"
},
"textMuted": {
"dark": "foregroundMuted",
"light": "#5c5c8a"
},
"background": {
"dark": "#262335",
"light": "#fafafa"
},
"backgroundPanel": {
"dark": "#1e1a29",
"light": "#f5f5f5"
},
"backgroundElement": {
"dark": "#2a2139",
"light": "#eeeeee"
},
"border": {
"dark": "#495495",
"light": "#e0e0e0"
},
"borderActive": {
"dark": "cyan",
"light": "#00bcd4"
},
"borderSubtle": {
"dark": "#241b2f",
"light": "#f0f0f0"
},
"diffAdded": {
"dark": "green",
"light": "#4caf50"
},
"diffRemoved": {
"dark": "red",
"light": "#f44336"
},
"diffContext": {
"dark": "foregroundMuted",
"light": "#5c5c8a"
},
"diffHunkHeader": {
"dark": "purple",
"light": "#9c27b0"
},
"diffHighlightAdded": {
"dark": "greenBright",
"light": "#4caf50"
},
"diffHighlightRemoved": {
"dark": "redBright",
"light": "#f44336"
},
"diffAddedBg": {
"dark": "#1a3a2a",
"light": "#e8f5e9"
},
"diffRemovedBg": {
"dark": "#3a1a2a",
"light": "#ffebee"
},
"diffContextBg": {
"dark": "#1e1a29",
"light": "#f5f5f5"
},
"diffLineNumber": {
"dark": "#495495",
"light": "#b0b0b0"
},
"diffAddedLineNumberBg": {
"dark": "#1a3a2a",
"light": "#e8f5e9"
},
"diffRemovedLineNumberBg": {
"dark": "#3a1a2a",
"light": "#ffebee"
},
"markdownText": {
"dark": "foreground",
"light": "#262335"
},
"markdownHeading": {
"dark": "pink",
"light": "#e91e63"
},
"markdownLink": {
"dark": "cyan",
"light": "#00bcd4"
},
"markdownLinkText": {
"dark": "purple",
"light": "#9c27b0"
},
"markdownCode": {
"dark": "green",
"light": "#4caf50"
},
"markdownBlockQuote": {
"dark": "foregroundMuted",
"light": "#5c5c8a"
},
"markdownEmph": {
"dark": "yellow",
"light": "#ff9800"
},
"markdownStrong": {
"dark": "orange",
"light": "#ff5722"
},
"markdownHorizontalRule": {
"dark": "#495495",
"light": "#e0e0e0"
},
"markdownListItem": {
"dark": "cyan",
"light": "#00bcd4"
},
"markdownListEnumeration": {
"dark": "purple",
"light": "#9c27b0"
},
"markdownImage": {
"dark": "cyan",
"light": "#00bcd4"
},
"markdownImageText": {
"dark": "purple",
"light": "#9c27b0"
},
"markdownCodeBlock": {
"dark": "foreground",
"light": "#262335"
},
"syntaxComment": {
"dark": "foregroundMuted",
"light": "#5c5c8a"
},
"syntaxKeyword": {
"dark": "pink",
"light": "#e91e63"
},
"syntaxFunction": {
"dark": "orange",
"light": "#ff5722"
},
"syntaxVariable": {
"dark": "foreground",
"light": "#262335"
},
"syntaxString": {
"dark": "yellow",
"light": "#ff9800"
},
"syntaxNumber": {
"dark": "purple",
"light": "#9c27b0"
},
"syntaxType": {
"dark": "cyan",
"light": "#00bcd4"
},
"syntaxOperator": {
"dark": "pink",
"light": "#e91e63"
},
"syntaxPunctuation": {
"dark": "foreground",
"light": "#262335"
}
}
}
@@ -241,4 +241,3 @@
}
}
}
@@ -0,0 +1,223 @@
{
"$schema": "https://opencode.ai/theme.json",
"defs": {
"bg": "#3f3f3f",
"bgAlt": "#4f4f4f",
"bgPanel": "#5f5f5f",
"fg": "#dcdccc",
"fgMuted": "#9f9f9f",
"red": "#cc9393",
"redBright": "#dca3a3",
"green": "#7f9f7f",
"greenBright": "#8fb28f",
"yellow": "#f0dfaf",
"yellowDim": "#e0cf9f",
"blue": "#8cd0d3",
"blueDim": "#7cb8bb",
"magenta": "#dc8cc3",
"cyan": "#93e0e3",
"orange": "#dfaf8f"
},
"theme": {
"primary": {
"dark": "blue",
"light": "#5f7f8f"
},
"secondary": {
"dark": "magenta",
"light": "#8f5f8f"
},
"accent": {
"dark": "cyan",
"light": "#5f8f8f"
},
"error": {
"dark": "red",
"light": "#8f5f5f"
},
"warning": {
"dark": "yellow",
"light": "#8f8f5f"
},
"success": {
"dark": "green",
"light": "#5f8f5f"
},
"info": {
"dark": "orange",
"light": "#8f7f5f"
},
"text": {
"dark": "fg",
"light": "#3f3f3f"
},
"textMuted": {
"dark": "fgMuted",
"light": "#6f6f6f"
},
"background": {
"dark": "bg",
"light": "#ffffef"
},
"backgroundPanel": {
"dark": "bgAlt",
"light": "#f5f5e5"
},
"backgroundElement": {
"dark": "bgPanel",
"light": "#ebebdb"
},
"border": {
"dark": "#5f5f5f",
"light": "#d0d0c0"
},
"borderActive": {
"dark": "blue",
"light": "#5f7f8f"
},
"borderSubtle": {
"dark": "#4f4f4f",
"light": "#e0e0d0"
},
"diffAdded": {
"dark": "green",
"light": "#5f8f5f"
},
"diffRemoved": {
"dark": "red",
"light": "#8f5f5f"
},
"diffContext": {
"dark": "fgMuted",
"light": "#6f6f6f"
},
"diffHunkHeader": {
"dark": "cyan",
"light": "#5f8f8f"
},
"diffHighlightAdded": {
"dark": "greenBright",
"light": "#5f8f5f"
},
"diffHighlightRemoved": {
"dark": "redBright",
"light": "#8f5f5f"
},
"diffAddedBg": {
"dark": "#4f5f4f",
"light": "#efffef"
},
"diffRemovedBg": {
"dark": "#5f4f4f",
"light": "#ffefef"
},
"diffContextBg": {
"dark": "bgAlt",
"light": "#f5f5e5"
},
"diffLineNumber": {
"dark": "#6f6f6f",
"light": "#b0b0a0"
},
"diffAddedLineNumberBg": {
"dark": "#4f5f4f",
"light": "#efffef"
},
"diffRemovedLineNumberBg": {
"dark": "#5f4f4f",
"light": "#ffefef"
},
"markdownText": {
"dark": "fg",
"light": "#3f3f3f"
},
"markdownHeading": {
"dark": "yellow",
"light": "#8f8f5f"
},
"markdownLink": {
"dark": "blue",
"light": "#5f7f8f"
},
"markdownLinkText": {
"dark": "cyan",
"light": "#5f8f8f"
},
"markdownCode": {
"dark": "green",
"light": "#5f8f5f"
},
"markdownBlockQuote": {
"dark": "fgMuted",
"light": "#6f6f6f"
},
"markdownEmph": {
"dark": "yellowDim",
"light": "#8f8f5f"
},
"markdownStrong": {
"dark": "orange",
"light": "#8f7f5f"
},
"markdownHorizontalRule": {
"dark": "fgMuted",
"light": "#6f6f6f"
},
"markdownListItem": {
"dark": "blue",
"light": "#5f7f8f"
},
"markdownListEnumeration": {
"dark": "cyan",
"light": "#5f8f8f"
},
"markdownImage": {
"dark": "blue",
"light": "#5f7f8f"
},
"markdownImageText": {
"dark": "cyan",
"light": "#5f8f8f"
},
"markdownCodeBlock": {
"dark": "fg",
"light": "#3f3f3f"
},
"syntaxComment": {
"dark": "#7f9f7f",
"light": "#5f7f5f"
},
"syntaxKeyword": {
"dark": "yellow",
"light": "#8f8f5f"
},
"syntaxFunction": {
"dark": "blue",
"light": "#5f7f8f"
},
"syntaxVariable": {
"dark": "fg",
"light": "#3f3f3f"
},
"syntaxString": {
"dark": "red",
"light": "#8f5f5f"
},
"syntaxNumber": {
"dark": "greenBright",
"light": "#5f8f5f"
},
"syntaxType": {
"dark": "cyan",
"light": "#5f8f8f"
},
"syntaxOperator": {
"dark": "yellow",
"light": "#8f8f5f"
},
"syntaxPunctuation": {
"dark": "fg",
"light": "#3f3f3f"
}
}
}
+76 -43
View File
@@ -12,6 +12,7 @@ import (
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/commands"
"github.com/sst/opencode/internal/completions"
@@ -22,8 +23,8 @@ import (
"github.com/sst/opencode/internal/components/toast"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
"github.com/sst/opencode/pkg/client"
)
// InterruptDebounceTimeoutMsg is sent when the interrupt key debounce timeout expires
@@ -55,6 +56,7 @@ type appModel struct {
isLeaderSequence bool
toastManager *toast.ToastManager
interruptKeyState InterruptKeyState
lastScroll time.Time
}
func (a appModel) Init() tea.Cmd {
@@ -73,19 +75,39 @@ func (a appModel) Init() tea.Cmd {
// Check if we should show the init dialog
cmds = append(cmds, func() tea.Msg {
shouldShow := a.app.Info.Git && a.app.Info.Time.Initialized == nil
shouldShow := a.app.Info.Git && a.app.Info.Time.Initialized > 0
return dialog.ShowInitDialogMsg{Show: shouldShow}
})
return tea.Batch(cmds...)
}
var BUGGED_SCROLL_KEYS = map[string]bool{
"0": true,
"1": true,
"2": true,
"3": true,
"4": true,
"5": true,
"6": true,
"7": true,
"8": true,
"9": true,
"M": true,
"m": true,
"[": true,
";": true,
}
func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyPressMsg:
keyString := msg.String()
if time.Since(a.lastScroll) < time.Millisecond*100 && BUGGED_SCROLL_KEYS[keyString] {
return a, nil
}
// 1. Handle active modal
if a.modal != nil {
@@ -221,6 +243,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.editor = updatedEditor.(chat.EditorComponent)
return a, cmd
case tea.MouseWheelMsg:
a.lastScroll = time.Now()
if a.modal != nil {
return a, nil
}
@@ -230,9 +253,19 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, tea.Batch(cmds...)
case tea.BackgroundColorMsg:
styles.Terminal = &styles.TerminalInfo{
Background: msg.Color,
BackgroundIsDark: msg.IsDark(),
}
slog.Debug("Background color", "isDark", msg.IsDark())
slog.Debug("Background color", "color", msg.String(), "isDark", msg.IsDark())
return a, func() tea.Msg {
theme.UpdateSystemTheme(
styles.Terminal.Background,
styles.Terminal.BackgroundIsDark,
)
return dialog.ThemeSelectedMsg{
ThemeName: theme.CurrentThemeName(),
}
}
case modal.CloseModalMsg:
var cmd tea.Cmd
if a.modal != nil {
@@ -256,31 +289,31 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd)
case dialog.CompletionDialogCloseMsg:
a.showCompletionDialog = false
case client.EventInstallationUpdated:
case opencode.EventListResponseEventInstallationUpdated:
return a, toast.NewSuccessToast(
"opencode updated to "+msg.Properties.Version+", restart to apply.",
toast.WithTitle("New version installed"),
)
case client.EventSessionDeleted:
if a.app.Session != nil && msg.Properties.Info.Id == a.app.Session.Id {
a.app.Session = &client.SessionInfo{}
a.app.Messages = []client.MessageInfo{}
case opencode.EventListResponseEventSessionDeleted:
if a.app.Session != nil && msg.Properties.Info.ID == a.app.Session.ID {
a.app.Session = &opencode.Session{}
a.app.Messages = []opencode.Message{}
}
return a, toast.NewSuccessToast("Session deleted successfully")
case client.EventSessionUpdated:
if msg.Properties.Info.Id == a.app.Session.Id {
case opencode.EventListResponseEventSessionUpdated:
if msg.Properties.Info.ID == a.app.Session.ID {
a.app.Session = &msg.Properties.Info
}
case client.EventMessageUpdated:
if msg.Properties.Info.Metadata.SessionID == a.app.Session.Id {
case opencode.EventListResponseEventMessageUpdated:
if msg.Properties.Info.Metadata.SessionID == a.app.Session.ID {
exists := false
optimisticReplaced := false
// First check if this is replacing an optimistic message
if msg.Properties.Info.Role == client.User {
if msg.Properties.Info.Role == opencode.MessageRoleUser {
// Look for optimistic messages to replace
for i, m := range a.app.Messages {
if strings.HasPrefix(m.Id, "optimistic-") && m.Role == client.User {
if strings.HasPrefix(m.ID, "optimistic-") && m.Role == opencode.MessageRoleUser {
// Replace the optimistic message with the real one
a.app.Messages[i] = msg.Properties.Info
exists = true
@@ -293,7 +326,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// If not replacing optimistic, check for existing message with same ID
if !optimisticReplaced {
for i, m := range a.app.Messages {
if m.Id == msg.Properties.Info.Id {
if m.ID == msg.Properties.Info.ID {
a.app.Messages[i] = msg.Properties.Info
exists = true
break
@@ -305,11 +338,15 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.app.Messages = append(a.app.Messages, msg.Properties.Info)
}
}
case client.EventSessionError:
unknownError, err := msg.Properties.Error.AsUnknownError()
if err == nil {
slog.Error("Server error", "name", unknownError.Name, "message", unknownError.Data.Message)
return a, toast.NewErrorToast(unknownError.Data.Message, toast.WithTitle(unknownError.Name))
case opencode.EventListResponseEventSessionError:
switch err := msg.Properties.Error.AsUnion().(type) {
case nil:
case opencode.ProviderAuthError:
slog.Error("Failed to authenticate with provider", "error", err.Data.Message)
return a, toast.NewErrorToast("Provider error: " + err.Data.Message)
case opencode.UnknownError:
slog.Error("Server error", "name", err.Name, "message", err.Data.Message)
return a, toast.NewErrorToast(err.Data.Message, toast.WithTitle(string(err.Name)))
}
case tea.WindowSizeMsg:
msg.Height -= 2 // Make space for the status bar
@@ -325,7 +362,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
a.layout.SetSize(a.width, a.height)
case app.SessionSelectedMsg:
messages, err := a.app.ListMessages(context.Background(), msg.Id)
messages, err := a.app.ListMessages(context.Background(), msg.ID)
if err != nil {
slog.Error("Failed to list messages", "error", err)
return a, toast.NewErrorToast("Failed to open session")
@@ -335,8 +372,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case app.ModelSelectedMsg:
a.app.Provider = &msg.Provider
a.app.Model = &msg.Model
a.app.State.Provider = msg.Provider.Id
a.app.State.Model = msg.Model.Id
a.app.State.Provider = msg.Provider.ID
a.app.State.Model = msg.Model.ID
a.app.SaveState()
case dialog.ThemeSelectedMsg:
a.app.State.Theme = msg.ThemeName
@@ -424,6 +461,9 @@ func (a appModel) View() string {
appView = a.toastManager.RenderOverlay(appView)
if theme.CurrentThemeUsesAnsiColors() {
appView = util.ConvertRGBToAnsi16Colors(appView)
}
return appView
}
@@ -485,42 +525,35 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
})
cmds = append(cmds, cmd)
case commands.SessionNewCommand:
if a.app.Session.Id == "" {
if a.app.Session.ID == "" {
return a, nil
}
a.app.Session = &client.SessionInfo{}
a.app.Messages = []client.MessageInfo{}
a.app.Session = &opencode.Session{}
a.app.Messages = []opencode.Message{}
cmds = append(cmds, util.CmdHandler(app.SessionClearedMsg{}))
case commands.SessionListCommand:
sessionDialog := dialog.NewSessionDialog(a.app)
a.modal = sessionDialog
case commands.SessionShareCommand:
if a.app.Session.Id == "" {
if a.app.Session.ID == "" {
return a, nil
}
response, err := a.app.Client.PostSessionShareWithResponse(
context.Background(),
client.PostSessionShareJSONRequestBody{
SessionID: a.app.Session.Id,
},
)
response, err := a.app.Client.Session.Share(context.Background(), a.app.Session.ID)
if err != nil {
slog.Error("Failed to share session", "error", err)
return a, toast.NewErrorToast("Failed to share session")
}
if response.JSON200 != nil && response.JSON200.Share != nil {
shareUrl := response.JSON200.Share.Url
cmds = append(cmds, tea.SetClipboard(shareUrl))
cmds = append(cmds, toast.NewSuccessToast("Share URL copied to clipboard!"))
}
shareUrl := response.Share.URL
cmds = append(cmds, tea.SetClipboard(shareUrl))
cmds = append(cmds, toast.NewSuccessToast("Share URL copied to clipboard!"))
case commands.SessionInterruptCommand:
if a.app.Session.Id == "" {
if a.app.Session.ID == "" {
return a, nil
}
a.app.Cancel(context.Background(), a.app.Session.Id)
a.app.Cancel(context.Background(), a.app.Session.ID)
return a, nil
case commands.SessionCompactCommand:
if a.app.Session.Id == "" {
if a.app.Session.ID == "" {
return a, nil
}
// TODO: block until compaction is complete
@@ -628,8 +661,8 @@ func NewModel(app *app.App) tea.Model {
messagesContainer := layout.NewContainer(messages)
var leaderBinding *key.Binding
if (*app.Config.Keybinds).Leader != nil {
binding := key.NewBinding(key.WithKeys(*app.Config.Keybinds.Leader))
if app.Config.Keybinds.Leader != "" {
binding := key.NewBinding(key.WithKeys(app.Config.Keybinds.Leader))
leaderBinding = &binding
}
+93
View File
@@ -0,0 +1,93 @@
package util
import (
"regexp"
"strings"
)
var csiRE *regexp.Regexp
func init() {
csiRE = regexp.MustCompile(`\x1b\[([0-9;]+)m`)
}
var targetFGMap = map[string]string{
"0;0;0": "\x1b[30m", // Black
"128;0;0": "\x1b[31m", // Red
"0;128;0": "\x1b[32m", // Green
"128;128;0": "\x1b[33m", // Yellow
"0;0;128": "\x1b[34m", // Blue
"128;0;128": "\x1b[35m", // Magenta
"0;128;128": "\x1b[36m", // Cyan
"192;192;192": "\x1b[37m", // White (light grey)
"128;128;128": "\x1b[90m", // Bright Black (dark grey)
"255;0;0": "\x1b[91m", // Bright Red
"0;255;0": "\x1b[92m", // Bright Green
"255;255;0": "\x1b[93m", // Bright Yellow
"0;0;255": "\x1b[94m", // Bright Blue
"255;0;255": "\x1b[95m", // Bright Magenta
"0;255;255": "\x1b[96m", // Bright Cyan
"255;255;255": "\x1b[97m", // Bright White
}
var targetBGMap = map[string]string{
"0;0;0": "\x1b[40m",
"128;0;0": "\x1b[41m",
"0;128;0": "\x1b[42m",
"128;128;0": "\x1b[43m",
"0;0;128": "\x1b[44m",
"128;0;128": "\x1b[45m",
"0;128;128": "\x1b[46m",
"192;192;192": "\x1b[47m",
"128;128;128": "\x1b[100m",
"255;0;0": "\x1b[101m",
"0;255;0": "\x1b[102m",
"255;255;0": "\x1b[103m",
"0;0;255": "\x1b[104m",
"255;0;255": "\x1b[105m",
"0;255;255": "\x1b[106m",
"255;255;255": "\x1b[107m",
}
func ConvertRGBToAnsi16Colors(s string) string {
return csiRE.ReplaceAllStringFunc(s, func(seq string) string {
params := strings.Split(csiRE.FindStringSubmatch(seq)[1], ";")
out := make([]string, 0, len(params))
for i := 0; i < len(params); {
// Detect “38 | 48 ; 2 ; r ; g ; b ( ; alpha? )”
if (params[i] == "38" || params[i] == "48") &&
i+4 < len(params) &&
params[i+1] == "2" {
key := strings.Join(params[i+2:i+5], ";")
var repl string
if params[i] == "38" {
repl = targetFGMap[key]
} else {
repl = targetBGMap[key]
}
if repl != "" { // exact RGB hit
out = append(out, repl[2:len(repl)-1])
i += 5 // skip 38/48;2;r;g;b
// if i == len(params)-1 && looksLikeByte(params[i]) {
// i++ // swallow the alpha byte
// }
continue
}
}
// Normal token — keep verbatim.
out = append(out, params[i])
i++
}
return "\x1b[" + strings.Join(out, ";") + "m"
})
}
// func looksLikeByte(tok string) bool {
// v, err := strconv.Atoi(tok)
// return err == nil && v >= 0 && v <= 255
// }
+50
View File
@@ -0,0 +1,50 @@
package util
import (
"strings"
"sync"
)
// MapReducePar performs a parallel map-reduce operation on a slice of items.
// It applies a function to each item in the slice concurrently,
// and combines the results serially using a reducer returned from
// each one of the functions, allowing the use of closures.
func MapReducePar[a, b any](items []a, init b, fn func(a) func(b) b) b {
itemCount := len(items)
locks := make([]*sync.Mutex, itemCount)
mapped := make([]func(b) b, itemCount)
for i, value := range items {
lock := &sync.Mutex{}
lock.Lock()
locks[i] = lock
go func() {
defer lock.Unlock()
mapped[i] = fn(value)
}()
}
result := init
for i := range itemCount {
locks[i].Lock()
defer locks[i].Unlock()
f := mapped[i]
if f != nil {
result = f(result)
}
}
return result
}
// WriteStringsPar allows to iterate over a list and compute strings in parallel,
// yet write them in order.
func WriteStringsPar[a any](sb *strings.Builder, items []a, fn func(a) string) {
MapReducePar(items, sb, func(item a) func(*strings.Builder) *strings.Builder {
str := fn(item)
return func(sbdr *strings.Builder) *strings.Builder {
sbdr.WriteString(str)
return sbdr
}
})
}
+10
View File
@@ -1,8 +1,10 @@
package util
import (
"log/slog"
"os"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea/v2"
)
@@ -35,3 +37,11 @@ func IsWsl() bool {
return false
}
func Measure(tag string) func(...any) {
startTime := time.Now()
return func(tags ...any) {
args := append([]any{"timeTakenMs", time.Since(startTime).Milliseconds()}, tags...)
slog.Info(tag, args...)
}
}
-4
View File
@@ -1,4 +0,0 @@
package client
//go:generate bun run ../../../opencode/src/index.ts generate
//go:generate go tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --package=client --generate=types,client,models -o generated-client.go ./gen/openapi.json
+6 -7
View File
@@ -6,11 +6,13 @@ import (
"encoding/json"
"net/http"
"strings"
"github.com/sst/opencode-sdk-go"
)
func (c *Client) Event(ctx context.Context) (<-chan any, error) {
func Event(c *opencode.Client, url string, ctx context.Context) (<-chan any, error) {
events := make(chan any)
req, err := http.NewRequestWithContext(ctx, "GET", c.Server+"event", nil)
req, err := http.NewRequestWithContext(ctx, "GET", url+"event", nil)
if err != nil {
return nil, err
}
@@ -31,15 +33,12 @@ func (c *Client) Event(ctx context.Context) (<-chan any, error) {
if strings.HasPrefix(line, "data: ") {
data := strings.TrimPrefix(line, "data: ")
var event Event
var event opencode.EventListResponse
if err := json.Unmarshal([]byte(data), &event); err != nil {
continue
}
val, err := event.ValueByDiscriminator()
if err != nil {
continue
}
val := event.AsUnion()
select {
case events <- val:
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+12 -6
View File
@@ -9,13 +9,10 @@ import { rehypeHeadingIds } from "@astrojs/markdown-remark"
import rehypeAutolinkHeadings from "rehype-autolink-headings"
const github = "https://github.com/sst/opencode"
const headerLinks = [
{ name: "Docs", url: "/docs/" },
{ name: "GitHub", url: github },
]
// https://astro.build/config
export default defineConfig({
site: config.url,
output: "server",
adapter: cloudflare({
imageService: "passthrough",
@@ -34,8 +31,15 @@ export default defineConfig({
starlight({
title: "opencode",
expressiveCode: { themes: ["github-light", "github-dark"] },
social: [
{ icon: "github", label: "GitHub", href: config.github },
social: [{ icon: "github", label: "GitHub", href: config.github }],
head: [
{
tag: "link",
attrs: {
rel: "icon",
href: "/favicon.svg",
},
},
],
editLink: {
baseUrl: `${github}/edit/master/www/`,
@@ -52,6 +56,7 @@ export default defineConfig({
sidebar: [
"docs",
"docs/cli",
"docs/rules",
"docs/config",
"docs/models",
"docs/themes",
@@ -60,6 +65,7 @@ export default defineConfig({
],
components: {
Hero: "./src/components/Hero.astro",
Head: "./src/components/Head.astro",
Header: "./src/components/Header.astro",
},
plugins: [

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