Compare commits

..

133 Commits

Author SHA1 Message Date
Dax Raad
ce611963c3 ci: aur 2025-06-27 12:29:13 -04:00
Dax Raad
f865cacfb8 ignore: cleanup 2025-06-27 11:35:57 -04:00
Dax Raad
2ec0611f42 lazy load formatters 2025-06-27 11:33:37 -04:00
Ryan Winchester
334161a30e feat: add elixir file formatting (#458) 2025-06-27 10:15:11 -04:00
adamdottv
dbb6e55226 fix(web): remove system prompts from share page 2025-06-27 06:48:44 -05:00
TheGoddessInari
d0f9260559 scripts/hooks: Change shebang to universal /bin/sh (#453) 2025-06-27 07:40:22 -04:00
adamdottv
d2176064e1 fix(tui): min width on user messages 2025-06-27 06:31:13 -05:00
Dax Raad
ed8d277e49 fix formatting output going into tui 2025-06-27 07:29:41 -04:00
adamdottv
59b3268c64 ignore: more metadata in app info 2025-06-27 06:19:27 -05:00
adamdottv
d043f67761 fix: don't use prettier for langs it doesn't format 2025-06-27 05:47:14 -05:00
Dax Raad
51bf193889 ignore: run prettier 2025-06-26 22:30:44 -04:00
Dax Raad
f8b78f08b4 add auto formatting and experimental hooks feature 2025-06-26 22:17:08 -04:00
Jay V
a4f32d602b docs: lander tweak 2025-06-26 19:47:58 -04:00
Jay V
dc3dd21cf3 docs: tweak lander 2025-06-26 19:02:44 -04:00
Dax Raad
8ca713b737 disable task tool temporarily 2025-06-26 18:27:49 -04:00
Jay V
5b54554fd5 docs: edit theme doc 2025-06-26 17:56:31 -04:00
Dax Raad
4bc651f958 fix: improve JSON formatting and add piped output support for run command
🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-26 17:32:00 -04:00
Jay V
3b6976a9c8 Merge branch 'rekram1-node-chore/update-config-docs' into dev 2025-06-26 17:24:03 -04:00
Jay V
863d5c1e8e docs: editing rules 2025-06-26 17:23:52 -04:00
adamdottv
97e19e9677 fix(tui): editor styles were off 2025-06-26 17:22:21 -04:00
adamdottv
b27851461f feat(tui): more themes 2025-06-26 17:22:21 -04:00
adamdottv
209687377a feat(tui): more themes 2025-06-26 17:22:21 -04:00
adamdottv
90face1c09 fix(tui): editor width issues 2025-06-26 17:22:21 -04:00
adamdottv
936e2ce48b feat(tui): show lsp diagnostics for edit and write tools 2025-06-26 17:22:21 -04:00
adamdottv
16ee8ee379 fix(tui): chat editor aesthetics 2025-06-26 17:22:21 -04:00
adamdottv
ac39308dad fix(tui): visual issue with modal selected items in system theme 2025-06-26 17:22:21 -04:00
adamdottv
346b49219d chore: tui agents.md 2025-06-26 17:22:21 -04:00
Jay V
d84c1f20c7 docs: social share 2025-06-26 17:22:17 -04:00
adamdottv
dfb8777555 fix(tui): editor spinner colors 2025-06-26 17:21:53 -04:00
Jay V
008af18156 docs: share page responsive diff 2025-06-26 17:21:53 -04:00
adamdottv
ab23167f80 docs: system theme 2025-06-26 17:21:53 -04:00
adamdottv
b17ec46463 fix(tui): make opencode theme default 2025-06-26 17:21:53 -04:00
Adam
2e26b58d16 feat: default system theme (#419)
Co-authored-by: adamdottv <2363879+adamdottv@users.noreply.github.com>
2025-06-26 17:21:53 -04:00
Mike Wallio
31b56e5a05 Fix undefined is not an object (evaluating 'G.title') (#395) 2025-06-26 17:21:53 -04:00
Juhani Pelli
47c401cf25 fix: guard against large output limit causing infinite summarize loop (#399) 2025-06-26 17:21:53 -04:00
Dax Raad
fab8dc9e6f more edit tool fixes 2025-06-26 17:21:53 -04:00
Dax Raad
f39a2b1f16 integrate gemini-cli strategies for edit tool 2025-06-26 17:21:53 -04:00
Dax Raad
66830ced4e make edit tool more robust 2025-06-26 17:21:53 -04:00
Dax Raad
9d3fad754d ignore: typo 2025-06-26 17:21:53 -04:00
Dax Raad
dcd3131f58 add output length errors 2025-06-26 17:21:53 -04:00
Dax Raad
3d02e07161 fix codex not working 2025-06-26 17:21:53 -04:00
Dax Raad
4dbc6a43a6 redirect uncaught errors to log file 2025-06-26 17:21:53 -04:00
adamdottv
5394b5188b fix(tui): editor styles were off 2025-06-26 15:12:26 -05:00
adamdottv
8e680b3957 feat(tui): more themes 2025-06-26 15:03:30 -05:00
adamdottv
1b8cd796d6 feat(tui): more themes 2025-06-26 14:54:32 -05:00
adamdottv
35fba793d0 fix(tui): editor width issues 2025-06-26 12:57:11 -05:00
adamdottv
5358d43b74 feat(tui): show lsp diagnostics for edit and write tools 2025-06-26 12:47:17 -05:00
adamdottv
f777347bac fix(tui): chat editor aesthetics 2025-06-26 12:44:44 -05:00
adamdottv
17c8b914df fix(tui): visual issue with modal selected items in system theme 2025-06-26 12:33:06 -05:00
adamdottv
43b467dd12 chore: tui agents.md 2025-06-26 12:28:29 -05:00
Jay V
0e0770921e docs: social share 2025-06-26 13:21:42 -04:00
adamdottv
8edbb74352 fix(tui): editor spinner colors 2025-06-26 12:21:20 -05:00
Jay V
e6bfa95758 docs: share page responsive diff 2025-06-26 13:06:41 -04:00
adamdottv
e4120b6287 docs: system theme 2025-06-26 11:33:02 -05:00
adamdottv
ccbc9e00f2 fix(tui): make opencode theme default 2025-06-26 11:32:25 -05:00
Adam
7d13baadc8 feat: default system theme (#419)
Co-authored-by: adamdottv <2363879+adamdottv@users.noreply.github.com>
2025-06-26 10:16:07 -05:00
rekram1-node
9acc83697f chore: document AGENTS.md 2025-06-26 08:28:06 -05:00
Mike Wallio
db24bf87c0 Fix undefined is not an object (evaluating 'G.title') (#395) 2025-06-25 19:40:09 -04:00
Juhani Pelli
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 2025-06-25 19:22:54 -04:00
Dax Raad
9c90cdbe08 integrate gemini-cli strategies for edit tool 2025-06-25 17:56:14 -04:00
Dax Raad
fc7af31fe5 make edit tool more robust 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 2025-06-25 10:01:35 -04:00
Dax Raad
50469ed750 redirect uncaught errors to log file 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
Dax Raad
9751937894 Enhance auth command with environment variable display and add models command
🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-24 22:24:55 -04:00
Dax Raad
0fc8dfc77e do not print error on ctrl+c during prompts 2025-06-24 22:09:43 -04:00
Dax Raad
81b7df61ec ci: bun lock 2025-06-24 21:14:32 -04:00
Dax Raad
8217b96d4a ci: fix type issue 2025-06-24 21:12:32 -04:00
Dax Raad
7dd0918d32 remove accidental opanai autoloader 2025-06-24 21:11:11 -04:00
Dax Raad
4b26b43855 added opencode serve command 2025-06-24 20:52:09 -04:00
Jay V
9d7cfda9fe docs: share page styles 2025-06-24 19:34:35 -04:00
Jay V
a3cf18c905 docs: share page bash tool output 2025-06-24 19:28:51 -04:00
Aiden Cline
0b1a8ae699 fix: file completions replaced wrong text when paths overlap (#378) 2025-06-24 18:13:15 -05:00
Dax Raad
eb70b1e5c8 docs: windows instructions 2025-06-24 18:54:59 -04:00
Dax Raad
00a3d818b6 ci: windows 2025-06-24 18:46:43 -04:00
Dax Raad
2384c7e734 ci: windows 2025-06-24 18:40:36 -04:00
Dax Raad
1bad3d9894 ci: windows 2025-06-24 18:27:57 -04:00
Dax Raad
4f715e66dc ci: windows 2025-06-24 18:13:15 -04:00
Dax
ec001ca02f windows fixes (#374)
Co-authored-by: Matthew Glazar <strager.nds@gmail.com>
2025-06-24 18:05:04 -04:00
Jay
a2d3b9f0c8 docs: Share page diff view improvements (#373) 2025-06-24 17:11:43 -04:00
Dax Raad
9cfb6ff964 ignore: revert 2025-06-24 14:59:27 -04:00
Dax Raad
6ed661c140 ci: upgrade bun 2025-06-24 14:42:25 -04:00
Dax Raad
9dc00edfc9 potential fix for failing to install provider package on first run 2025-06-24 14:33:35 -04:00
Jay V
e063bf888e docs: share code blocks in markdown 2025-06-24 13:53:59 -04:00
Adam
6f18475428 feat: delete sessions (#362)
Co-authored-by: adamdottv <2363879+adamdottv@users.noreply.github.com>
2025-06-24 11:07:41 -05:00
Dax Raad
3664b09812 remove debug code writing to /tmp/message.json 2025-06-24 11:16:17 -04:00
Dax Raad
7050cc0ac3 ignore: fix type errors 2025-06-24 11:09:36 -04:00
Dax Raad
4d3d63294d externalize github copilot code 2025-06-24 10:42:19 -04:00
Tom
6bc61cbc2d feat(tui): add debounce logic to escape key interrupt (#169)
Co-authored-by: opencode <noreply@opencode.ai>
Co-authored-by: adamdottv <2363879+adamdottv@users.noreply.github.com>
2025-06-24 06:31:02 -05:00
Dax Raad
01d351bebe add HOMEBREW_NO_AUTO_UPDATE to brew upgrades 2025-06-23 20:36:08 -04:00
Dax Raad
dbba4a97aa force use npm registry 2025-06-23 20:23:37 -04:00
GitMurf
0dc586faef fix: typescript error (any) from models (#347) 2025-06-23 18:44:57 -04:00
Dax Raad
f19c6b05f2 glob tool should respect .gitignore 2025-06-23 17:37:32 -04:00
Dax Raad
bc34f08333 bundle models.dev at build time and ignore refresh errors 2025-06-23 14:50:19 -04:00
Dax Raad
b7ee16aabd ignore: remove opencode.json 2025-06-23 14:32:57 -04:00
Lucas Grzegorczyk
ed1b0d97bf Fix project folder name starting with "-" in data (#323). Note old session data will still be in the old format in ~/.local/share/opencode/projects - you can remove the leading dash to recover the, 2025-06-23 14:31:51 -04:00
adamdottv
8d3b2fb821 feat(tui): optimistically render user messages 2025-06-23 12:30:20 -05:00
Jay V
fa991920bc fix help copy 2025-06-23 13:00:24 -04:00
adamdottv
5e79e3d7a5 fix(tui): less incorrect escapingn of < and > 2025-06-23 11:32:32 -05:00
adamdottv
966015c9ae fix: overlay border color issues 2025-06-23 11:21:49 -05:00
adamdottv
61f057337a fix: markdown wrapping issue 2025-06-23 11:20:44 -05:00
adamdottv
0b261054a2 chore: unused import 2025-06-23 10:21:57 -05:00
adamdottv
e2e481cbb5 docs: disabled_providers 2025-06-23 10:21:25 -05:00
GitMurf
5140e83012 feat(copilot): edit headers for better rate limit avoidance (#321) 2025-06-23 10:44:19 -04:00
Dax Raad
100d6212be more graceful mcp failures 2025-06-22 21:10:05 -04:00
Dax Raad
f0e19a6542 aws autoload include more env vars 2025-06-22 20:16:10 -04:00
Dax Raad
00c4d4f9f8 fix double entry of github copilot in auth login 2025-06-22 19:13:25 -04:00
Martin Palma
6e6fe6e013 Add Github Copilot OAuth authentication flow (#305) 2025-06-22 19:11:37 -04:00
Dax Raad
d05b60291e docs: contributing 2025-06-22 17:55:10 -04:00
adamdottv
5162361372 fix(tui): color contrast fixes for nord 2025-06-22 15:17:18 -05:00
adamdottv
d271b9f75b fix(tui): help dialog visuals 2025-06-22 14:28:16 -05:00
Márk Magyar
333569bed3 ignore: fix typos and formatting (#294) 2025-06-22 14:26:46 -04:00
Tom
09b89fdb23 fix: resolve test failures by adding missing zod-openapi import (#301)
Co-authored-by: opencode <noreply@opencode.ai>
2025-06-22 14:25:02 -04:00
Tom
0e8c3359d1 combine stdout and stderr in bash tool output (#300)
Co-authored-by: opencode <noreply@opencode.ai>
Co-authored-by: Dax Raad <d@ironbay.co>
2025-06-22 14:24:35 -04:00
Adam
37e0a7050f fix(tui): mouse wheel escape codes leaking into input 2025-06-22 10:26:44 -05:00
adamdottv
774dcb6980 fix(tui): cleanup help dialog 2025-06-22 06:44:23 -05:00
phantomreactor
28bc49ad17 fix: invisible html tags and compact long delay (#304) 2025-06-22 06:29:04 -05:00
adamdottv
dc1947838c fix(tui): cleanup modal visuals 2025-06-22 06:09:23 -05:00
adamdottv
3ea2daaa4c fix(tui): theme dialog visuals 2025-06-22 05:34:22 -05:00
Márk Magyar
137e964131 fix: session title generation (#293) 2025-06-21 14:32:11 -05:00
tyrellshawn
8efbe497fd Created a Theme inspired by the matrix (#285) 2025-06-21 07:29:49 -05:00
Thomas Meire
119d2d966c Add error handling on the calls to the server to debug issue #132 (#137) 2025-06-21 07:24:39 -05:00
Dax Raad
194415e785 footer clarifies it's showing context usage, not input token usage 2025-06-20 22:52:51 -04:00
Dax Raad
1684042fb6 huge optimization for token usage with anthropic 2025-06-20 22:43:04 -04:00
Dax Raad
59f0004d34 Add --method option to upgrade command for manual installation method selection
🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-20 20:48:23 -04:00
Dax Raad
da35a64fa1 handle brew upgrades better 2025-06-20 20:27:23 -04:00
Dax Raad
460338ca53 make IDs more random 2025-06-20 17:39:59 -04:00
Saatvik Arya
53c18a64b4 docs: add API client generation instructions to README and AGENTS.md (#273) 2025-06-20 17:27:58 -04:00
Saatvik Arya
b8144c5654 fix: return false for missing AWS_PROFILE in amazon-bedrock provider (#277) 2025-06-20 17:27:27 -04:00
adamdottv
9081e17fcc fix(tui): visual tweaks to themes 2025-06-20 15:49:51 -05:00
129 changed files with 8030 additions and 1752 deletions

View File

@@ -17,7 +17,7 @@ jobs:
- uses: oven-sh/setup-bun@v1
with:
bun-version: latest
bun-version: 1.2.17
- run: bun install

View File

@@ -32,7 +32,7 @@ jobs:
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.2.16
bun-version: 1.2.17
- name: Install makepkg
run: |

View File

@@ -38,6 +38,8 @@ For more info on how to configure opencode [**head over to our docs**](https://o
### Contributing
For any new features we'd appreciate it if you could open an issue first to discuss what you'd like to implement. We're pretty responsive there and it'll save you from working on something that we don't end up using. No need to do this for simpler fixes.
To run opencode locally you need.
- Bun
@@ -50,6 +52,17 @@ $ bun install
$ 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.
### FAQ
#### How is this different than Claude Code?

View File

@@ -19,7 +19,10 @@
},
"packages/opencode": {
"name": "opencode",
"version": "0.0.0",
"version": "0.0.5",
"bin": {
"opencode": "./bin/opencode",
},
"dependencies": {
"@clack/prompts": "0.11.0",
"@flystorage/file-storage": "1.1.0",
@@ -458,7 +461,7 @@
"@types/babel__traverse": ["@types/babel__traverse@7.20.7", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng=="],
"@types/bun": ["@types/bun@1.2.16", "", { "dependencies": { "bun-types": "1.2.16" } }, "sha512-1aCZJ/6nSiViw339RsaNhkNoEloLaPzZhxMOYEa7OzRzO41IGg5n/7I43/ZIAW/c+Q6cT12Vf7fOZOoVIzb5BQ=="],
"@types/bun": ["@types/bun@1.2.17", "", { "dependencies": { "bun-types": "1.2.17" } }, "sha512-l/BYs/JYt+cXA/0+wUhulYJB6a6p//GTPiJ7nV+QHa8iiId4HZmnu/3J/SowP5g0rTiERY2kfGKXEK5Ehltx4Q=="],
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
@@ -596,7 +599,7 @@
"buffer": ["buffer@4.9.2", "", { "dependencies": { "base64-js": "^1.0.2", "ieee754": "^1.1.4", "isarray": "^1.0.0" } }, "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg=="],
"bun-types": ["bun-types@1.2.16", "", { "dependencies": { "@types/node": "*" } }, "sha512-ciXLrHV4PXax9vHvUrkvun9VPVGOVwbbbBF/Ev1cXz12lyEZMoJpIJABOfPcN9gDJRaiKF9MVbSygLg4NXu3/A=="],
"bun-types": ["bun-types@1.2.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-ElC7ItwT3SCQwYZDYoAH+q6KT4Fxjl8DtZ6qDulUFBmXA8YB4xo+l54J9ZJN+k2pphfn9vk7kfubeSd5QfTVJQ=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],

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"]
}
]
}
}
}

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 {}

View File

@@ -7,6 +7,7 @@
- **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
@@ -37,3 +38,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.

View File

@@ -0,0 +1,56 @@
@echo off
setlocal enabledelayedexpansion
if defined OPENCODE_BIN_PATH (
set "resolved=%OPENCODE_BIN_PATH%"
goto :execute
)
rem Get the directory of this script
set "script_dir=%~dp0"
set "script_dir=%script_dir:~0,-1%"
rem Detect platform and architecture
set "platform=win32"
rem Detect architecture
if "%PROCESSOR_ARCHITECTURE%"=="AMD64" (
set "arch=x64"
) else if "%PROCESSOR_ARCHITECTURE%"=="ARM64" (
set "arch=arm64"
) else if "%PROCESSOR_ARCHITECTURE%"=="x86" (
set "arch=x86"
) else (
set "arch=x64"
)
set "name=opencode-!platform!-!arch!"
set "binary=opencode.exe"
rem Search for the binary starting from script location
set "resolved="
set "current_dir=%script_dir%"
:search_loop
set "candidate=%current_dir%\node_modules\%name%\bin\%binary%"
if exist "%candidate%" (
set "resolved=%candidate%"
goto :execute
)
rem Move up one directory
for %%i in ("%current_dir%") do set "parent_dir=%%~dpi"
set "parent_dir=%parent_dir:~0,-1%"
rem Check if we've reached the root
if "%current_dir%"=="%parent_dir%" goto :not_found
set "current_dir=%parent_dir%"
goto :search_loop
:not_found
echo It seems that your package manager failed to install the right version of the OpenCode CLI for your platform. You can try manually installing the "%name%" package >&2
exit /b 1
:execute
rem Execute the binary with all arguments
"%resolved%" %*

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"
@@ -267,10 +266,7 @@
"description": "Environment variables to set when running the MCP server"
}
},
"required": [
"type",
"command"
],
"required": ["type", "command"],
"additionalProperties": false
},
{
@@ -286,17 +282,73 @@
"description": "URL of the remote MCP server"
}
},
"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#"
}
}

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,
@@ -8,6 +8,9 @@
"typecheck": "tsc --noEmit",
"dev": "bun run ./src/index.ts"
},
"bin": {
"opencode": "./bin/opencode"
},
"exports": {
"./*": "./src/*.ts"
},

View File

@@ -29,7 +29,7 @@ const targets = [
["linux", "x64"],
["darwin", "x64"],
["darwin", "arm64"],
// ["windows", "x64"],
["windows", "x64"],
]
await $`rm -rf dist`
@@ -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/opencode-bin.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 = [

View File

@@ -1,3 +1,4 @@
import "zod-openapi/extend"
import { Log } from "../util/log"
import { Context } from "../util/context"
import { Filesystem } from "../util/filesystem"
@@ -12,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(),
@@ -29,11 +31,21 @@ export namespace 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,
})
@@ -45,7 +57,7 @@ export namespace App {
const data = path.join(
Global.Path.data,
"project",
git ? git.split(path.sep).join("-") : "global",
git ? directory(git) : "global",
)
const stateFile = Bun.file(path.join(data, APP_JSON))
const state = (await stateFile.json().catch(() => ({}))) as {
@@ -61,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,
},
@@ -71,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>(
@@ -106,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()
@@ -132,4 +139,12 @@ export namespace App {
}),
)
}
function directory(input: string): string {
return input
.split(path.sep)
.filter(Boolean)
.join("-")
.replace(/[^A-Za-z0-9_]/g, "-")
}
}

View File

@@ -0,0 +1,20 @@
import { Global } from "../global"
import { lazy } from "../util/lazy"
import path from "path"
export const AuthCopilot = lazy(async () => {
const file = Bun.file(path.join(Global.Path.state, "plugin", "copilot.ts"))
const response = fetch(
"https://raw.githubusercontent.com/sst/opencode-github-copilot/refs/heads/main/auth.ts",
)
.then((x) => Bun.write(file, x))
.catch(() => {})
if (!file.exists()) {
const worked = await response
if (!worked) return
}
const result = await import(file.name!).catch(() => {})
if (!result) return
return result.AuthCopilot
})

View File

@@ -0,0 +1,150 @@
import { z } from "zod"
import { Auth } from "./index"
import { NamedError } from "../util/error"
export namespace AuthGithubCopilot {
const CLIENT_ID = "Iv1.b507a08c87ecfe98"
const DEVICE_CODE_URL = "https://github.com/login/device/code"
const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"
const COPILOT_API_KEY_URL = "https://api.github.com/copilot_internal/v2/token"
interface DeviceCodeResponse {
device_code: string
user_code: string
verification_uri: string
expires_in: number
interval: number
}
interface AccessTokenResponse {
access_token?: string
error?: string
error_description?: string
}
interface CopilotTokenResponse {
token: string
expires_at: number
refresh_in: number
endpoints: {
api: string
}
}
export async function authorize() {
const deviceResponse = await fetch(DEVICE_CODE_URL, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": "GitHubCopilotChat/0.26.7",
},
body: JSON.stringify({
client_id: CLIENT_ID,
scope: "read:user",
}),
})
const deviceData: DeviceCodeResponse = await deviceResponse.json()
return {
device: deviceData.device_code,
user: deviceData.user_code,
verification: deviceData.verification_uri,
interval: deviceData.interval || 5,
expiry: deviceData.expires_in,
}
}
export async function poll(device_code: string) {
const response = await fetch(ACCESS_TOKEN_URL, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": "GitHubCopilotChat/0.26.7",
},
body: JSON.stringify({
client_id: CLIENT_ID,
device_code,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
}),
})
if (!response.ok) return "failed"
const data: AccessTokenResponse = await response.json()
if (data.access_token) {
// Store the GitHub OAuth token
await Auth.set("github-copilot", {
type: "oauth",
refresh: data.access_token,
access: "",
expires: 0,
})
return "complete"
}
if (data.error === "authorization_pending") return "pending"
if (data.error) return "failed"
return "pending"
}
export async function access() {
const info = await Auth.get("github-copilot")
if (!info || info.type !== "oauth") return
if (info.access && info.expires > Date.now()) return info.access
// Get new Copilot API token
const response = await fetch(COPILOT_API_KEY_URL, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${info.refresh}`,
"User-Agent": "GitHubCopilotChat/0.26.7",
"Editor-Version": "vscode/1.99.3",
"Editor-Plugin-Version": "copilot-chat/0.26.7",
},
})
if (!response.ok) return
const tokenData: CopilotTokenResponse = await response.json()
// Store the Copilot API token
await Auth.set("github-copilot", {
type: "oauth",
refresh: info.refresh,
access: tokenData.token,
expires: tokenData.expires_at * 1000,
})
return tokenData.token
}
export const DeviceCodeError = NamedError.create(
"DeviceCodeError",
z.object({}),
)
export const TokenExchangeError = NamedError.create(
"TokenExchangeError",
z.object({
message: z.string(),
}),
)
export const AuthenticationError = NamedError.create(
"AuthenticationError",
z.object({
message: z.string(),
}),
)
export const CopilotTokenError = NamedError.create(
"CopilotTokenError",
z.object({
message: z.string(),
}),
)
}

View File

@@ -13,7 +13,7 @@ export namespace BunProc {
) {
log.info("running", {
cmd: [which(), ...cmd],
options,
...options,
})
const result = Bun.spawn([which(), ...cmd], {
...options,
@@ -26,6 +26,15 @@ export namespace BunProc {
},
})
const code = await result.exited
// @ts-ignore
const stdout = await result.stdout.text()
// @ts-ignore
const stderr = await result.stderr.text()
log.info("done", {
code,
stdout,
stderr,
})
if (code !== 0) {
throw new Error(`Command failed with exit code ${result.exitCode}`)
}
@@ -53,7 +62,7 @@ export namespace BunProc {
if (parsed.dependencies[pkg] === version) return mod
parsed.dependencies[pkg] = version
await Bun.write(pkgjson, JSON.stringify(parsed, null, 2))
await BunProc.run(["install"], {
await BunProc.run(["install", "--registry=https://registry.npmjs.org"], {
cwd: Global.Path.cache,
}).catch((e) => {
new InstallFailedError(

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>(

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)
})
}

View File

@@ -1,4 +1,5 @@
import { AuthAnthropic } from "../../auth/anthropic"
import { AuthCopilot } from "../../auth/copilot"
import { Auth } from "../../auth"
import { cmd } from "./cmd"
import * as prompts from "@clack/prompts"
@@ -6,17 +7,20 @@ import open from "open"
import { UI } from "../ui"
import { ModelsDev } from "../../provider/models"
import { map, pipe, sortBy, values } from "remeda"
import path from "path"
import os from "os"
import { Global } from "../../global"
export const AuthCommand = cmd({
command: "auth",
describe: "Manage credentials",
describe: "manage credentials",
builder: (yargs) =>
yargs
.command(AuthLoginCommand)
.command(AuthLogoutCommand)
.command(AuthListCommand)
.demandCommand(),
async handler() { },
async handler() {},
})
export const AuthListCommand = cmd({
@@ -25,30 +29,61 @@ export const AuthListCommand = cmd({
describe: "list providers",
async handler() {
UI.empty()
prompts.intro("Credentials")
const authPath = path.join(Global.Path.data, "auth.json")
const homedir = os.homedir()
const displayPath = authPath.startsWith(homedir)
? authPath.replace(homedir, "~")
: authPath
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
const results = await Auth.all().then((x) => Object.entries(x))
const database = await ModelsDev.get()
for (const [providerID, result] of results) {
const name = database[providerID]?.name || providerID
prompts.log.info(`${name} ${UI.Style.TEXT_DIM}(${result.type})`)
prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
}
prompts.outro(`${results.length} credentials`)
// Environment variables section
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,
})
}
}
}
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`)
}
},
})
export const AuthLoginCommand = cmd({
command: "login",
describe: "login to a provider",
describe: "log in to a provider",
async handler() {
UI.empty()
prompts.intro("Add credential")
const providers = await ModelsDev.get()
const priority: Record<string, number> = {
anthropic: 0,
openai: 1,
google: 2,
"github-copilot": 1,
openai: 2,
google: 3,
}
let provider = await prompts.select({
message: "Select provider",
@@ -146,6 +181,44 @@ export const AuthLoginCommand = cmd({
}
}
const copilot = await AuthCopilot()
if (provider === "github-copilot" && copilot) {
await new Promise((resolve) => setTimeout(resolve, 10))
const deviceInfo = await copilot.authorize()
prompts.note(
`Please visit: ${deviceInfo.verification}\nEnter code: ${deviceInfo.user}`,
)
const spinner = prompts.spinner()
spinner.start("Waiting for authorization...")
while (true) {
await new Promise((resolve) =>
setTimeout(resolve, deviceInfo.interval * 1000),
)
const response = await copilot.poll(deviceInfo.device)
if (response.status === "pending") continue
if (response.status === "success") {
await Auth.set("github-copilot", {
type: "oauth",
refresh: response.refresh,
access: response.access,
expires: response.expires,
})
spinner.stop("Login successful")
break
}
if (response.status === "failed") {
spinner.stop("Failed to authorize", 1)
break
}
}
prompts.outro("Done")
return
}
const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x.length > 0 ? undefined : "Required"),
@@ -162,7 +235,7 @@ export const AuthLoginCommand = cmd({
export const AuthLogoutCommand = cmd({
command: "logout",
describe: "logout from a configured provider",
describe: "log out from a configured provider",
async handler() {
UI.empty()
const credentials = await Auth.all().then((x) => Object.entries(x))

View File

@@ -1,20 +0,0 @@
import { AuthAnthropic } from "../../auth/anthropic"
import { UI } from "../ui"
// Example: https://claude.ai/oauth/authorize?code=true&client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e&response_type=code&redirect_uri=https%3A%2F%2Fconsole.anthropic.com%2Foauth%2Fcode%2Fcallback&scope=org%3Acreate_api_key+user%3Aprofile+user%3Ainference&code_challenge=MdFtFgFap23AWDSN0oa3-eaKjQRFE4CaEhXx8M9fHZg&code_challenge_method=S256&state=rKLtaDzm88GSwekyEqdi0wXX-YqIr13tSzYymSzpvfs
export const LoginAnthropicCommand = {
command: "anthropic",
describe: "Login to Anthropic",
handler: async () => {
const { url, verifier } = await AuthAnthropic.authorize()
UI.println("Login to Anthropic")
UI.println("Open the following URL in your browser:")
UI.println(url)
UI.println("")
const code = await UI.input("Paste the authorization code here: ")
await AuthAnthropic.exchange(code, verifier)
},
}

View File

@@ -0,0 +1,19 @@
import { App } from "../../app/app"
import { Provider } from "../../provider/provider"
import { cmd } from "./cmd"
export const ModelsCommand = cmd({
command: "models",
describe: "list all available models",
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}`)
}
}
})
},
})

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],
@@ -25,7 +24,7 @@ const TOOL: Record<string, [string, string]> = {
export const RunCommand = cmd({
command: "run [message..]",
describe: "Run opencode with a message",
describe: "run opencode with a message",
builder: (yargs: Argv) => {
return yargs
.positional("message", {
@@ -36,12 +35,12 @@ export const RunCommand = cmd({
})
.option("continue", {
alias: ["c"],
describe: "Continue the last session",
describe: "continue the last session",
type: "boolean",
})
.option("session", {
alias: ["s"],
describe: "Session ID to continue",
describe: "session id to continue",
type: "string",
})
.option("share", {
@@ -51,115 +50,114 @@ export const RunCommand = cmd({
.option("model", {
type: "string",
alias: ["m"],
describe: "Model to use in the format of provider/model",
describe: "model to use in the format of provider/model",
})
},
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()
})
},
})

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())
})
},
})

View File

@@ -0,0 +1,50 @@
import { App } from "../../app/app"
import { Provider } from "../../provider/provider"
import { Server } from "../../server/server"
import { Share } from "../../share/share"
import { cmd } from "./cmd"
export const ServeCommand = cmd({
command: "serve",
builder: (yargs) =>
yargs
.option("port", {
alias: ["p"],
type: "number",
describe: "port to listen on",
default: 4096,
})
.option("hostname", {
alias: ["h"],
type: "string",
describe: "hostname to listen on",
default: "127.0.0.1",
}),
describe: "starts a headless opencode server",
handler: async (args) => {
const cwd = process.cwd()
await App.provide({ cwd }, async () => {
const providers = await Provider.list()
if (Object.keys(providers).length === 0) {
return "needs_provider"
}
const hostname = args.hostname
const port = args.port
await Share.init()
const server = Server.listen({
port,
hostname,
})
console.log(
`opencode server listening on http://${server.hostname}:${server.port}`,
)
await new Promise(() => {})
server.stop()
})
},
})

View File

@@ -0,0 +1,108 @@
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"
import { AuthLoginCommand } from "./auth"
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(" "))
UI.empty()
await AuthLoginCommand.handler(args)
}
}
},
})

View File

@@ -5,19 +5,27 @@ import { Installation } from "../../installation"
export const UpgradeCommand = {
command: "upgrade [target]",
describe: "upgrade opencode to the latest version or a specific version",
describe: "upgrade opencode to the latest or a specific version",
builder: (yargs: Argv) => {
return yargs.positional("target", {
describe: "specific version to upgrade to (e.g., '0.1.48' or 'v0.1.48')",
type: "string",
})
return yargs
.positional("target", {
describe: "version to upgrade to, for ex '0.1.48' or 'v0.1.48'",
type: "string",
})
.option("method", {
alias: "m",
describe: "installation method to use",
type: "string",
choices: ["curl", "npm", "pnpm", "bun", "brew"],
})
},
handler: async (args: { target?: string }) => {
handler: async (args: { target?: string; method?: string }) => {
UI.empty()
UI.println(UI.logo(" "))
UI.empty()
prompts.intro("Upgrade")
const method = await Installation.method()
const detectedMethod = await Installation.method()
const method = (args.method as Installation.Method) ?? detectedMethod
if (method === "unknown") {
prompts.log.error(
`opencode is installed to ${process.execPath} and seems to be managed by a package manager`,
@@ -25,6 +33,7 @@ export const UpgradeCommand = {
prompts.outro("Done")
return
}
prompts.log.info("Using method: " + method)
const target = args.target ?? (await Installation.latest())
prompts.log.info(`From ${Installation.VERSION}${target}`)
const spinner = prompts.spinner()

View File

@@ -1,6 +1,10 @@
import { Config } from "../config/config"
import { MCP } from "../mcp"
import { UI } from "./ui"
export function FormatError(input: unknown) {
if (MCP.Failed.isInstance(input))
return `MCP server "${input.data.name}" failed. Note, opencode does not support MCP authentication yet.`
if (Config.JsonError.isInstance(input))
return `Config file at ${input.data.path} is not valid JSON`
if (Config.InvalidError.isInstance(input))
@@ -10,4 +14,6 @@ export function FormatError(input: unknown) {
(issue) => "↳ " + issue.message + " " + issue.path.join("."),
) ?? []),
].join("\n")
if (UI.CancelledError.isInstance(input)) return ""
}

View File

@@ -1,4 +1,5 @@
import { z } from "zod"
import { EOL } from "os"
import { NamedError } from "../util/error"
export namespace UI {
@@ -29,7 +30,7 @@ export namespace UI {
export function println(...message: string[]) {
print(...message)
Bun.stderr.write("\n")
Bun.stderr.write(EOL)
}
export function print(...message: string[]) {
@@ -52,7 +53,7 @@ export namespace UI {
result.push(row[0])
result.push("\x1b[0m")
result.push(row[1])
result.push("\n")
result.push(EOL)
}
return result.join("").trimEnd()
}

View File

@@ -22,6 +22,7 @@ export namespace Config {
}
}
log.info("loaded", result)
return result
})
@@ -167,6 +168,32 @@ 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({

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",
})
}
}
})
}
}

View File

@@ -116,14 +116,17 @@ export namespace Ripgrep {
export async function files(input: {
cwd: string
query?: string
glob?: string
limit?: number
}) {
const commands = [`${await filepath()} --files --hidden --glob='!.git/*'`]
const commands = [
`${await filepath()} --files --hidden --glob='!.git/*' ${input.glob ? `--glob='${input.glob}'` : ``}`,
]
if (input.query)
commands.push(`${await Fzf.filepath()} --filter=${input.query}`)
if (input.limit) commands.push(`head -n ${input.limit}`)
const joined = commands.join(" | ")
const result = await $`${{ raw: joined }}`.cwd(input.cwd).text()
const result = await $`${{ raw: joined }}`.cwd(input.cwd).nothrow().text()
return result.split("\n").filter(Boolean)
}
}

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(),
}),
),
}
}

View File

@@ -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]: {

View File

@@ -0,0 +1,146 @@
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
}
},
},
]
}

View File

@@ -41,6 +41,17 @@ export namespace Identifier {
return given
}
function randomBase62(length: number): string {
const chars =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
let result = ""
const bytes = randomBytes(length)
for (let i = 0; i < length; i++) {
result += chars[bytes[i] % 62]
}
return result
}
function generateNewID(
prefix: keyof typeof prefixes,
descending: boolean,
@@ -62,14 +73,11 @@ export namespace Identifier {
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
}
const randLength = (LENGTH - 12) / 2
const random = randomBytes(randLength)
return (
prefixes[prefix] +
"_" +
timeBytes.toString("hex") +
random.toString("hex")
randomBase62(LENGTH - 12)
)
}
}

View File

@@ -1,31 +1,41 @@
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 { 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 { Provider } from "./provider/provider"
import { ModelsCommand } from "./cli/cmd/models"
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")
.version(Installation.VERSION)
.help("help", "show help")
.version("version", "show version number", Installation.VERSION)
.alias("version", "v")
.option("print-logs", {
describe: "Print logs to stderr",
describe: "print logs to stderr",
type: "boolean",
})
.middleware(async () => {
@@ -36,92 +46,14 @@ 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()
let cmd = ["go", "run", "./main.go"]
let cwd = new URL("../../tui/cmd/opencode", import.meta.url).pathname
if (Bun.embeddedFiles.length > 0) {
const blob = Bun.embeddedFiles[0] as File
const binary = path.join(Global.Path.cache, "tui", blob.name)
const file = Bun.file(binary)
if (!(await file.exists())) {
await Bun.write(file, blob, { mode: 0o755 })
await fs.chmod(binary, 0o755)
}
cwd = process.cwd()
cmd = [binary]
}
const proc = Bun.spawn({
cmd: [...cmd, ...process.argv.slice(2)],
cwd,
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
env: {
...process.env,
OPENCODE_SERVER: server.url.toString(),
OPENCODE_APP_INFO: JSON.stringify(app),
},
onExit: () => {
server.stop()
},
})
;(async () => {
if (Installation.VERSION === "dev") return
if (Installation.isSnapshot()) return
const config = await Config.global()
if (config.autoupdate === false) return
const latest = await Installation.latest()
if (Installation.VERSION === latest) return
const method = await Installation.method()
if (method === "unknown") return
await Installation.upgrade(method, latest)
.then(() => {
Bus.publish(Installation.Event.Updated, { version: latest })
})
.catch(() => {})
})()
await proc.exited
server.stop()
return "done"
})
if (result === "done") break
if (result === "needs_provider") {
UI.empty()
UI.println(UI.logo(" "))
UI.empty()
await AuthLoginCommand.handler(args)
}
}
},
})
.command(TuiCommand)
.command(RunCommand)
.command(GenerateCommand)
.command(ScrapCommand)
.command(AuthCommand)
.command(UpgradeCommand)
.command(ServeCommand)
.command(ModelsCommand)
.fail((msg) => {
if (
msg.startsWith("Unknown argument") ||
@@ -152,8 +84,10 @@ try {
Log.Default.error("fatal", data)
const formatted = FormatError(e)
if (formatted) UI.error(formatted)
if (!formatted)
if (formatted === undefined)
UI.error(
"Unexpected error, check log file at " + Log.file() + " for more details",
)
}
cancel.abort()

View File

@@ -3,12 +3,15 @@ import { $ } from "bun"
import { z } from "zod"
import { NamedError } from "../util/error"
import { Bus } from "../bus"
import { Log } from "../util/log"
declare global {
const OPENCODE_VERSION: string
}
export namespace Installation {
const log = Log.create({ service: "installation" })
export type Method = Awaited<ReturnType<typeof method>>
export const Event = {
@@ -66,6 +69,10 @@ export namespace Installation {
name: "bun" as const,
command: () => $`bun pm ls -g`.throws(false).text(),
},
{
name: "brew" as const,
command: () => $`brew list --formula opencode-ai`.throws(false).text(),
},
]
checks.sort((a, b) => {
@@ -97,18 +104,31 @@ export namespace Installation {
const cmd = (() => {
switch (method) {
case "curl":
return $`curl -fsSL https://opencode.ai/install | bash`
return $`curl -fsSL https://opencode.ai/install | bash`.env({
...process.env,
VERSION: target,
})
case "npm":
return $`npm install -g opencode-ai@${target}`
case "pnpm":
return $`pnpm install -g opencode-ai@${target}`
case "bun":
return $`bun install -g opencode-ai@${target}`
case "brew":
return $`brew install sst/tap/opencode`.env({
HOMEBREW_NO_AUTO_UPDATE: "1",
})
default:
throw new Error(`Unknown method: ${method}`)
}
})()
const result = await cmd.quiet().throws(false)
log.info("upgraded", {
method,
target,
stdout: result.stdout.toString(),
stderr: result.stderr.toString(),
})
if (result.exitCode !== 0)
throw new UpgradeFailedError({
stderr: result.stderr.toString("utf8"),

View File

@@ -2,8 +2,22 @@ import { experimental_createMCPClient, type Tool } from "ai"
import { Experimental_StdioMCPTransport } from "ai/mcp-stdio"
import { App } from "../app/app"
import { Config } from "../config/config"
import { Log } from "../util/log"
import { NamedError } from "../util/error"
import { z } from "zod"
import { Session } from "../session"
import { Bus } from "../bus"
export namespace MCP {
const log = Log.create({ service: "mcp" })
export const Failed = NamedError.create(
"MCPFailed",
z.object({
name: z.string(),
}),
)
const state = App.state(
"mcp",
async () => {
@@ -12,27 +26,56 @@ export namespace MCP {
[name: string]: Awaited<ReturnType<typeof experimental_createMCPClient>>
} = {}
for (const [key, mcp] of Object.entries(cfg.mcp ?? {})) {
log.info("found", { key, type: mcp.type })
if (mcp.type === "remote") {
clients[key] = await experimental_createMCPClient({
const client = await experimental_createMCPClient({
name: key,
transport: {
type: "sse",
url: mcp.url,
},
})
}).catch(() => {})
if (!client) {
Bus.publish(Session.Event.Error, {
error: {
name: "UnknownError",
data: {
message: `MCP server ${key} failed to start`,
},
},
})
continue
}
clients[key] = client
}
if (mcp.type === "local") {
const [cmd, ...args] = mcp.command
clients[key] = await experimental_createMCPClient({
const client = await experimental_createMCPClient({
name: key,
transport: new Experimental_StdioMCPTransport({
stderr: "ignore",
command: cmd,
args,
env: mcp.environment,
env: {
...process.env,
...(cmd === "opencode" ? { BUN_BE_BUN: "1" } : {}),
...mcp.environment,
},
}),
})
}).catch(() => {})
if (!client) {
Bus.publish(Session.Event.Error, {
error: {
name: "UnknownError",
data: {
message: `MCP server ${key} failed to start`,
},
},
})
continue
}
clients[key] = client
}
}

View File

@@ -0,0 +1,4 @@
export async function data() {
const json = await fetch("https://models.dev/api.json").then((x) => x.text())
return json
}

View File

@@ -2,6 +2,7 @@ import { Global } from "../global"
import { Log } from "../util/log"
import path from "path"
import { z } from "zod"
import { data } from "./models-macro" with { type: "macro" }
export namespace ModelsDev {
const log = Log.create({ service: "models.dev" })
@@ -54,16 +55,15 @@ export namespace ModelsDev {
refresh()
return result as Record<string, Provider>
}
await refresh()
return get()
refresh()
const json = await data()
return JSON.parse(json) as Record<string, Provider>
}
async function refresh() {
const file = Bun.file(filepath)
log.info("refreshing")
const result = await fetch("https://models.dev/api.json")
if (!result.ok)
throw new Error(`Failed to fetch models.dev: ${result.statusText}`)
await Bun.write(file, result)
const result = await fetch("https://models.dev/api.json").catch(() => {})
if (result && result.ok) await Bun.write(file, result)
}
}

View File

@@ -19,28 +19,29 @@ import type { Tool } from "../tool/tool"
import { WriteTool } from "../tool/write"
import { TodoReadTool, TodoWriteTool } from "../tool/todo"
import { AuthAnthropic } from "../auth/anthropic"
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,
@@ -48,6 +49,7 @@ export namespace Provider {
}
}
return {
autoload: true,
options: {
apiKey: "",
async fetch(input: any, init: any) {
@@ -66,8 +68,56 @@ export namespace Provider {
},
}
},
"github-copilot": async (provider) => {
const copilot = await AuthCopilot()
if (!copilot) return { autoload: false }
let info = await Auth.get("github-copilot")
if (!info || info.type !== "oauth") return { autoload: false }
if (provider && provider.models) {
for (const model of Object.values(provider.models)) {
model.cost = {
input: 0,
output: 0,
}
}
}
return {
autoload: true,
options: {
apiKey: "",
async fetch(input: any, init: any) {
const info = await Auth.get("github-copilot")
if (!info || info.type !== "oauth") return
if (!info.access || info.expires < Date.now()) {
const tokens = await copilot.access(info.refresh)
if (!tokens)
throw new Error("GitHub Copilot authentication expired")
await Auth.set("github-copilot", {
type: "oauth",
...tokens,
})
info.access = tokens.access
}
const headers = {
...init.headers,
...copilot.HEADERS,
Authorization: `Bearer ${info.access}`,
"Openai-Intent": "conversation-edits",
}
delete headers["x-api-key"]
return fetch(input, {
...init,
headers,
})
},
},
}
},
openai: async () => {
return {
autoload: false,
async getModel(sdk: any, modelID: string) {
return sdk.responses(modelID)
},
@@ -75,7 +125,8 @@ export namespace Provider {
}
},
"amazon-bedrock": async () => {
if (!process.env["AWS_PROFILE"]) false
if (!process.env["AWS_PROFILE"] && !process.env["AWS_ACCESS_KEY_ID"])
return { autoload: false }
const region = process.env["AWS_REGION"] ?? "us-east-1"
@@ -83,6 +134,7 @@ export namespace Provider {
await BunProc.install("@aws-sdk/credential-providers")
)
return {
autoload: true,
options: {
region,
credentialProvider: fromNodeProviderChain(),
@@ -208,8 +260,14 @@ 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,
)
}
}
// load config
@@ -352,7 +410,7 @@ export namespace Provider {
// MultiEditTool,
WriteTool,
TodoWriteTool,
TaskTool,
// TaskTool,
TodoReadTool,
]

View File

@@ -1,24 +1,25 @@
import type { CoreMessage } from "ai"
import type { LanguageModelV1Prompt } from "ai"
import { unique } from "remeda"
export namespace ProviderTransform {
export function message(
msg: CoreMessage,
index: number,
msgs: LanguageModelV1Prompt,
providerID: string,
modelID: string,
) {
if (
(providerID === "anthropic" || modelID.includes("anthropic")) &&
index < 4
) {
msg.providerOptions = {
...msg.providerOptions,
anthropic: {
cacheControl: { type: "ephemeral" },
},
if (providerID === "anthropic" || 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,
anthropic: {
cacheControl: { type: "ephemeral" },
},
}
}
}
return msg
return msgs
}
}

View File

@@ -390,6 +390,33 @@ export namespace Server {
return c.json(Session.abort(body.sessionID))
},
)
.post(
"/session_delete",
describeRoute({
description: "Delete a session and all its data",
responses: {
200: {
description: "Successfully deleted session",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
zValidator(
"json",
z.object({
sessionID: z.string(),
}),
),
async (c) => {
const body = c.req.valid("json")
await Session.remove(body.sessionID)
return c.json(true)
},
)
.post(
"/session_summarize",
describeRoute({
@@ -552,10 +579,10 @@ export namespace Server {
return result
}
export function listen() {
export function listen(opts: { port: number; hostname: string }) {
const server = Bun.serve({
port: 0,
hostname: "0.0.0.0",
port: opts.port,
hostname: opts.hostname,
idleTimeout: 0,
fetch: app().fetch,
})

View File

@@ -14,6 +14,7 @@ import {
type CoreMessage,
type UIMessage,
type ProviderMetadata,
wrapLanguageModel,
} from "ai"
import { z, ZodSchema } from "zod"
import { Decimal } from "decimal.js"
@@ -71,6 +72,18 @@ export namespace Session {
info: Info,
}),
),
Deleted: Bus.event(
"session.deleted",
z.object({
info: Info,
}),
),
Idle: Bus.event(
"session.idle",
z.object({
sessionID: z.string(),
}),
),
Error: Bus.event(
"session.error",
z.object({
@@ -205,6 +218,17 @@ export namespace Session {
}
}
export async function children(parentID: string) {
const result = [] as Session.Info[]
for await (const item of Storage.list("session/info")) {
const sessionID = path.basename(item, ".json")
const session = await get(sessionID)
if (session.parentID !== parentID) continue
result.push(session)
}
return result
}
export function abort(sessionID: string) {
const controller = state().pending.get(sessionID)
if (!controller) return false
@@ -213,6 +237,28 @@ export namespace Session {
return true
}
export async function remove(sessionID: string, emitEvent = true) {
try {
abort(sessionID)
const session = await get(sessionID)
for (const child of await children(sessionID)) {
await remove(child.id, false)
}
await unshare(sessionID).catch(() => {})
await Storage.remove(`session/info/${sessionID}`).catch(() => {})
await Storage.removeDir(`session/message/${sessionID}/`).catch(() => {})
state().sessions.delete(sessionID)
state().messages.delete(sessionID)
if (emitEvent) {
Bus.publish(Event.Deleted, {
info: session,
})
}
} catch (e) {
log.error(e)
}
}
async function updateMessage(msg: Message.Info) {
await Storage.writeJSON(
"session/message/" + msg.metadata.sessionID + "/" + msg.id,
@@ -247,7 +293,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,
@@ -285,9 +334,7 @@ export namespace Session {
parts: toParts(input.parts),
},
]),
].map((msg, i) =>
ProviderTransform.message(msg, i, input.providerID, input.modelID),
),
],
model: model.language,
})
.then((result) => {
@@ -434,24 +481,6 @@ export namespace Session {
}
let text: Message.TextPart | undefined
await Bun.write(
"/tmp/message.json",
JSON.stringify(
[
...system.map(
(x): CoreMessage => ({
role: "system",
content: x,
}),
),
...convertToCoreMessages(
msgs.map(toUIMessage).filter((x) => x.parts.length > 0),
),
],
null,
2,
),
)
const result = streamText({
onStepFinish: async (step) => {
log.info("step finish", { finishReason: step.finishReason })
@@ -514,6 +543,7 @@ export namespace Session {
// return step
// },
toolCallStreaming: true,
maxTokens: model.info.limit.output || undefined,
abortSignal: abort.signal,
maxSteps: 1000,
providerOptions: model.info.options,
@@ -527,12 +557,26 @@ export namespace Session {
...convertToCoreMessages(
msgs.map(toUIMessage).filter((x) => x.parts.length > 0),
),
].map((msg, i) =>
ProviderTransform.message(msg, i, input.providerID, input.modelID),
),
],
temperature: model.info.temperature ? 0 : undefined,
tools: model.info.tool_call === false ? undefined : tools,
model: model.language,
model: wrapLanguageModel({
model: model.language,
middleware: [
{
async transformParams(args) {
if (args.type === "stream") {
args.params.prompt = ProviderTransform.message(
args.params.prompt,
input.providerID,
input.modelID,
)
}
return args.params
},
},
],
}),
})
try {
for await (const value of result.fullStream) {
@@ -623,6 +667,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,
@@ -636,6 +695,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(
{
@@ -723,7 +785,9 @@ export namespace Session {
},
}
await updateMessage(next)
const result = await generateText({
let text: Message.TextPart | undefined
const result = streamText({
abortSignal: abort.signal,
model: model.language,
messages: [
@@ -744,16 +808,46 @@ export namespace Session {
],
},
],
onStepFinish: async (step) => {
const assistant = next.metadata!.assistant!
const usage = getUsage(model.info, step.usage, step.providerMetadata)
assistant.cost += usage.cost
assistant.tokens = usage.tokens
await updateMessage(next)
if (text) {
Bus.publish(Message.Event.PartUpdated, {
part: text,
messageID: next.id,
sessionID: next.metadata.sessionID,
})
}
text = undefined
},
async onFinish(input) {
const assistant = next.metadata!.assistant!
const usage = getUsage(model.info, input.usage, input.providerMetadata)
assistant.cost = usage.cost
assistant.tokens = usage.tokens
next.metadata!.time.completed = Date.now()
await updateMessage(next)
},
})
next.parts.push({
type: "text",
text: result.text,
})
const assistant = next.metadata!.assistant!
const usage = getUsage(model.info, result.usage, result.providerMetadata)
assistant.cost = usage.cost
assistant.tokens = usage.tokens
await updateMessage(next)
for await (const value of result.fullStream) {
switch (value.type) {
case "text-delta":
if (!text) {
text = {
type: "text",
text: value.textDelta,
}
next.parts.push(text)
} else text.text += value.textDelta
await updateMessage(next)
break
}
}
}
function lock(sessionID: string) {
@@ -766,6 +860,9 @@ export namespace Session {
[Symbol.dispose]() {
log.info("unlocking", { sessionID })
state().pending.delete(sessionID)
Bus.publish(Event.Idle, {
sessionID,
})
},
}
}

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"),
@@ -135,53 +140,56 @@ export namespace Message {
id: z.string(),
role: z.enum(["user", "assistant"]),
parts: z.array(Part),
metadata: z.object({
time: z.object({
created: z.number(),
completed: z.number().optional(),
}),
error: z
.discriminatedUnion("name", [
Provider.AuthError.Schema,
NamedError.Unknown.Schema,
])
.optional(),
sessionID: z.string(),
tool: z.record(
z.string(),
z
metadata: z
.object({
time: z.object({
created: z.number(),
completed: z.number().optional(),
}),
error: z
.discriminatedUnion("name", [
Provider.AuthError.Schema,
NamedError.Unknown.Schema,
OutputLengthError.Schema,
])
.optional(),
sessionID: z.string(),
tool: z.record(
z.string(),
z
.object({
title: z.string(),
time: z.object({
start: z.number(),
end: z.number(),
}),
})
.catchall(z.any()),
),
assistant: z
.object({
title: z.string(),
time: z.object({
start: z.number(),
end: z.number(),
system: z.string().array(),
modelID: z.string(),
providerID: z.string(),
path: z.object({
cwd: z.string(),
root: z.string(),
}),
cost: z.number(),
summary: z.boolean().optional(),
tokens: z.object({
input: z.number(),
output: z.number(),
reasoning: z.number(),
cache: z.object({
read: z.number(),
write: z.number(),
}),
}),
})
.catchall(z.any()),
),
assistant: z
.object({
system: z.string().array(),
modelID: z.string(),
providerID: z.string(),
path: z.object({
cwd: z.string(),
root: z.string(),
}),
cost: z.number(),
summary: z.boolean().optional(),
tokens: z.object({
input: z.number(),
output: z.number(),
reasoning: z.number(),
cache: z.object({
read: z.number(),
write: z.number(),
}),
}),
})
.optional(),
}),
.optional(),
})
.openapi({ ref: "Message.Metadata" }),
})
.openapi({
ref: "Message.Info",

View File

@@ -1,7 +1,11 @@
You will generate a short title based on the first message a user begins a conversation with
- ensure it is not more than 50 characters long
- the title should be a summary of the user's message
- it should be one line long
- do not use quotes or colons
- the entire text you return will be used as the title
- never return anything that is more than one sentence (one line) long
Generate a short title based on the first message a user begins a conversation with. CRITICAL: Your response must be EXACTLY one line with NO line breaks, newlines, or multiple sentences.
Requirements:
- Maximum 50 characters
- Single line only - NO newlines or line breaks
- Summary of the user's message
- No quotes, colons, or special formatting
- Do not include explanatory text like "summary:" or similar
- Your entire response becomes the title
IMPORTANT: Return only the title text on a single line. Do not add any explanations, formatting, or additional text.

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 =

View File

@@ -29,6 +29,11 @@ export namespace Storage {
await fs.unlink(target).catch(() => {})
}
export async function removeDir(key: string) {
const target = path.join(state().dir, key)
await fs.rm(target, { recursive: true, force: true }).catch(() => {})
}
export async function readJSON<T>(key: string) {
return Bun.file(path.join(state().dir, key + ".json")).json() as Promise<T>
}

View File

@@ -63,10 +63,18 @@ export const BashTool = Tool.define({
metadata: {
stderr,
stdout,
exit: process.exitCode,
description: params.description,
title: params.command,
},
output: stdout.replaceAll(/\x1b\[[0-9;]*m/g, ""),
output: [
`<stdout>`,
stdout ?? "",
`</stdout>`,
`<stderr>`,
stderr ?? "",
`</stderr>`,
].join("\n"),
}
},
})

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",
@@ -22,13 +28,17 @@ export const EditTool = Tool.define({
replaceAll: z
.boolean()
.optional()
.describe("Replace all occurences of old_string (default false)"),
.describe("Replace all occurrences of old_string (default false)"),
}),
async execute(params, ctx) {
if (!params.filePath) {
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")
}

View File

@@ -3,6 +3,7 @@ import path from "path"
import { Tool } from "./tool"
import { App } from "../app/app"
import DESCRIPTION from "./glob.txt"
import { Ripgrep } from "../external/ripgrep"
export const GlobTool = Tool.define({
id: "glob",
@@ -24,10 +25,12 @@ export const GlobTool = Tool.define({
: path.resolve(app.path.cwd, search)
const limit = 100
const glob = new Bun.Glob(params.pattern)
const files = []
let truncated = false
for await (const file of glob.scan({ cwd: search, dot: true })) {
for (const file of await Ripgrep.files({
cwd: search,
glob: params.pattern,
})) {
if (files.length >= limit) {
truncated = true
break

View File

@@ -10,7 +10,7 @@ To make multiple file edits, provide the following:
2. edits: An array of edit operations to perform, where each edit contains:
- old_string: The text to replace (must match the file contents exactly, including all whitespace and indentation)
- new_string: The edited text to replace the old_string
- replace_all: Replace all occurences of old_string. This parameter is optional and defaults to false.
- replace_all: Replace all occurrences of old_string. This parameter is optional and defaults to false.
IMPORTANT:
- All edits are applied in sequence, in the order they are provided

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`

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,

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)

View File

@@ -8,4 +8,3 @@ export function lazy<T>(fn: () => T) {
return value as T
}
}

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] + ".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()

View File

@@ -6,4 +6,4 @@
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
export {}

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)
}
})
})

View File

@@ -14,7 +14,7 @@ describe("tool.glob", () => {
await App.provide({ cwd: process.cwd() }, async () => {
let result = await GlobTool.execute(
{
pattern: "./node_modules/**/*",
pattern: "../../node_modules/**/*",
path: undefined,
},
ctx,
@@ -33,7 +33,7 @@ describe("tool.glob", () => {
)
expect(result.metadata).toMatchObject({
truncated: false,
count: 2,
count: 3,
})
})
})

1
packages/tui/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
opencode-test

26
packages/tui/AGENTS.md Normal file
View File

@@ -0,0 +1,26 @@
# 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)
- **Generate client**: `go generate ./pkg/client/` (after server endpoint changes)
- **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

View File

@@ -26,7 +26,11 @@ func main() {
appInfoStr := os.Getenv("OPENCODE_APP_INFO")
var appInfo client.AppInfo
json.Unmarshal([]byte(appInfoStr), &appInfo)
err := json.Unmarshal([]byte(appInfoStr), &appInfo)
if err != nil {
slog.Error("Failed to unmarshal app info", "error", err)
os.Exit(1)
}
logfile := filepath.Join(appInfo.Path.Data, "log", "tui.log")
if _, err := os.Stat(filepath.Dir(logfile)); os.IsNotExist(err) {
@@ -45,6 +49,8 @@ func main() {
logger := slog.New(slog.NewTextHandler(file, &slog.HandlerOptions{Level: slog.LevelDebug}))
slog.SetDefault(logger)
slog.Debug("TUI launched", "app", appInfo)
httpClient, err := client.NewClientWithResponses(url)
if err != nil {
slog.Error("Failed to create client", "error", err)

View File

@@ -6,12 +6,15 @@ import (
"path/filepath"
"sort"
"strings"
"time"
"log/slog"
tea "github.com/charmbracelet/bubbletea/v2"
"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"
@@ -44,9 +47,12 @@ type SendMsg struct {
Text string
Attachments []Attachment
}
type CompletionDialogTriggerdMsg struct {
type CompletionDialogTriggeredMsg struct {
InitialValue string
}
type OptimisticMessageAddedMsg struct {
Message client.MessageInfo
}
func New(
ctx context.Context,
@@ -98,6 +104,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)
}
@@ -126,6 +138,10 @@ func (a *App) InitializeProvider() tea.Cmd {
// 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
@@ -248,17 +264,19 @@ func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
}
func (a *App) CompactSession(ctx context.Context) tea.Cmd {
response, err := a.Client.PostSessionSummarizeWithResponse(ctx, client.PostSessionSummarizeJSONRequestBody{
SessionID: a.Session.Id,
ProviderID: a.Provider.Id,
ModelID: a.Model.Id,
})
if err != nil {
slog.Error("Failed to compact session", "error", err)
}
if response != nil && response.StatusCode() != 200 {
slog.Error("Failed to compact session", "error", response.StatusCode)
}
go func() {
response, err := a.Client.PostSessionSummarizeWithResponse(ctx, client.PostSessionSummarizeJSONRequestBody{
SessionID: a.Session.Id,
ProviderID: a.Provider.Id,
ModelID: a.Model.Id,
})
if err != nil {
slog.Error("Failed to compact session", "error", err)
}
if response != nil && response.StatusCode() != 200 {
slog.Error("Failed to compact session", "error", response.StatusCode)
}
}()
return nil
}
@@ -291,19 +309,12 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
if a.Session.Id == "" {
session, err := a.CreateSession(ctx)
if err != nil {
// status.Error(err.Error())
return nil
return toast.NewErrorToast(err.Error())
}
a.Session = session
cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
}
// TODO: Handle attachments when API supports them
if len(attachments) > 0 {
// For now, ignore attachments
// return "", fmt.Errorf("attachments not supported yet")
}
part := client.MessagePart{}
part.FromMessagePartText(client.MessagePartText{
Type: "text",
@@ -311,7 +322,26 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
})
parts := []client.MessagePart{part}
go func() {
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()),
},
Tool: make(map[string]client.MessageMetadata_Tool_AdditionalProperties),
},
}
a.Messages = append(a.Messages, optimisticMessage)
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,
@@ -319,14 +349,17 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
ModelID: a.Model.Id,
})
if err != nil {
slog.Error("Failed to send message", "error", err)
// status.Error(err.Error())
errormsg := fmt.Sprintf("failed to send message: %v", err)
slog.Error(errormsg)
return toast.NewErrorToast(errormsg)()
}
if response != nil && response.StatusCode != 200 {
slog.Error("Failed to send message", "error", fmt.Sprintf("failed to send message: %d", response.StatusCode))
// status.Error(fmt.Sprintf("failed to send message: %d", response.StatusCode))
errormsg := fmt.Sprintf("failed to send message: %d", response.StatusCode)
slog.Error(errormsg)
return toast.NewErrorToast(errormsg)()
}
}()
return nil
})
// The actual response will come through SSE
// For now, just return success
@@ -370,6 +403,19 @@ func (a *App) ListSessions(ctx context.Context) ([]client.SessionInfo, error) {
return sessions, nil
}
func (a *App) DeleteSession(ctx context.Context, sessionID string) error {
resp, err := a.Client.PostSessionDeleteWithResponse(ctx, client.PostSessionDeleteJSONRequestBody{
SessionID: sessionID,
})
if err != nil {
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})
if err != nil {

View File

@@ -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,

View File

@@ -26,27 +26,32 @@ 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)
Newline() (tea.Model, tea.Cmd)
Previous() (tea.Model, tea.Cmd)
Next() (tea.Model, tea.Cmd)
SetInterruptKeyInDebounce(inDebounce bool)
}
type editorComponent struct {
app *app.App
width, height int
textarea textarea.Model
attachments []app.Attachment
history []string
historyIndex int
currentMessage string
spinner spinner.Model
app *app.App
width, height int
textarea textarea.Model
attachments []app.Attachment
history []string
historyIndex int
currentMessage string
spinner spinner.Model
interruptKeyInDebounce bool
}
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) {
@@ -67,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, "/")
@@ -78,8 +83,15 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
} else {
existingValue := m.textarea.Value()
modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
m.textarea.SetValue(modifiedValue + " ")
// Replace the current token (after last space)
lastSpaceIndex := strings.LastIndex(existingValue, " ")
if lastSpaceIndex == -1 {
m.textarea.SetValue(msg.CompletionValue + " ")
} else {
modifiedValue := existingValue[:lastSpaceIndex+1] + msg.CompletionValue
m.textarea.SetValue(modifiedValue + " ")
}
return m, nil
}
}
@@ -95,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(
@@ -108,16 +119,26 @@ 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("enter") + muted(" send ")
hint := base(m.getSubmitKeyText()) + muted(" send ")
if m.app.IsBusy() {
hint = muted("working") + m.spinner.View() + muted(" ") + base("esc") + muted(" interrupt")
keyText := m.getInterruptKeyText()
if m.interruptKeyInDebounce {
hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText+" again") + muted(" interrupt")
} else {
hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText) + muted(" interrupt")
}
}
model := ""
@@ -126,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
@@ -142,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
}
@@ -149,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
}
@@ -263,6 +294,18 @@ func (m *editorComponent) Next() (tea.Model, tea.Cmd) {
return m, nil
}
func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
m.interruptKeyInDebounce = inDebounce
}
func (m *editorComponent) getInterruptKeyText() string {
return m.app.Commands[commands.SessionInterruptCommand].Keys()[0]
}
func (m *editorComponent) getSubmitKeyText() string {
return m.app.Commands[commands.InputSubmitCommand].Keys()[0]
}
func createTextArea(existing *textarea.Model) textarea.Model {
t := theme.CurrentTheme()
bgColor := t.BackgroundElement()
@@ -271,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(),
),
)
}
@@ -311,11 +358,12 @@ func NewEditorComponent(app *app.App) EditorComponent {
ta := createTextArea(nil)
return &editorComponent{
app: app,
textarea: ta,
history: []string{},
historyIndex: 0,
currentMessage: "",
spinner: s,
app: app,
textarea: ta,
history: []string{},
historyIndex: 0,
currentMessage: "",
spinner: s,
interruptKeyInDebounce: false,
}
}

View File

@@ -44,7 +44,6 @@ func toMarkdown(content string, width int, backgroundColor compat.AdaptiveColor)
}
}
}
content = strings.Join(lines, "\n")
return strings.TrimSuffix(content, "\n")
}
@@ -130,15 +129,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
@@ -180,13 +177,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 {
@@ -227,9 +224,20 @@ 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
markdownWidth = width - padding - 4 - 3
}
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 == client.Assistant {
content = toMarkdown(text, markdownWidth, t.BackgroundPanel())
}
content := toMarkdown(text, markdownWidth, t.BackgroundPanel())
content = strings.Join([]string{content, info}, "\n")
switch message.Role {
@@ -250,7 +258,7 @@ func renderText(message client.MessageInfo, text string, author string) string {
func renderToolInvocation(
toolCall client.MessageToolInvocationToolCall,
result *string,
metadata client.MessageInfo_Metadata_Tool_AdditionalProperties,
metadata client.MessageMetadata_Tool_AdditionalProperties,
showDetails bool,
isLast bool,
contentOnly bool,
@@ -272,9 +280,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).
@@ -290,7 +299,9 @@ 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)
return renderContentBlock(style.Render(title),
WithAlign(lipgloss.Left),
WithBorderColor(t.Accent()),
@@ -331,9 +342,9 @@ func renderToolInvocation(
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()).
error = styles.NewStyle().
Foreground(t.Error()).
Background(t.BackgroundPanel()).
Render(m.(string))
error = renderContentBlock(
error,
@@ -371,7 +382,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()).
@@ -391,8 +402,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(metadata, filename); diagnostics != "" {
body += "\n" + renderContentBlock(diagnostics, WithFullWidth(), WithBorderColor(t.Error()))
}
}
}
case "write":
@@ -400,6 +416,11 @@ 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(metadata, filename); diagnostics != "" {
body += "\n" + renderContentBlock(diagnostics, WithFullWidth(), WithBorderColor(t.Error()))
}
}
}
case "bash":
@@ -463,7 +484,7 @@ func renderToolInvocation(
if metadata, ok := call["metadata"].(map[string]any); ok {
data, _ = json.Marshal(metadata)
var toolMetadata client.MessageInfo_Metadata_Tool_AdditionalProperties
var toolMetadata client.MessageMetadata_Tool_AdditionalProperties
_ = json.Unmarshal(data, &toolMetadata)
step := renderToolInvocation(
@@ -503,7 +524,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
@@ -527,7 +548,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
@@ -681,3 +702,81 @@ 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 client.MessageMetadata_Tool_AdditionalProperties, filePath string) string {
diagnosticsData, ok := metadata.Get("diagnostics")
if !ok {
return ""
}
// diagnosticsData should be a map[string][]Diagnostic
diagnosticsMap, ok := diagnosticsData.(map[string]interface{})
if !ok {
return ""
}
fileDiagnostics, ok := diagnosticsMap[filePath]
if !ok {
return ""
}
diagnosticsList, ok := fileDiagnostics.([]interface{})
if !ok {
return ""
}
var errorDiagnostics []string
for _, diagInterface := range diagnosticsList {
diagMap, ok := diagInterface.(map[string]interface{})
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()
}

View File

@@ -58,6 +58,12 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.viewport.GotoBottom()
m.tail = true
return m, nil
case app.OptimisticMessageAddedMsg:
m.renderView()
if m.tail {
m.viewport.GotoBottom()
}
return m, nil
case dialog.ThemeSelectedMsg:
m.cache.Clear()
return m, m.Reload()
@@ -171,7 +177,7 @@ func (m *messagesComponent) renderView() {
isLastToolInvocation := slices.Contains(lastToolIndices, i)
toolInvocationPart := part.(client.MessagePartToolInvocation)
toolCall, _ := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolCall()
metadata := client.MessageInfo_Metadata_Tool_AdditionalProperties{}
metadata := client.MessageMetadata_Tool_AdditionalProperties{}
if _, ok := message.Metadata.Tool[toolCall.ToolCallId]; ok {
metadata = message.Metadata.Tool[toolCall.ToolCallId]
}
@@ -239,7 +245,7 @@ func (m *messagesComponent) renderView() {
m.width,
lipgloss.Center,
block,
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
styles.WhitespaceStyle(t.Background()),
))
}
@@ -254,8 +260,8 @@ func (m *messagesComponent) header() string {
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 != "" {
@@ -265,11 +271,11 @@ func (m *messagesComponent) header() string {
}
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()).
@@ -300,7 +306,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(),
)
@@ -308,9 +314,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 := `
█▀▀█ █▀▀█ █▀▀ █▀▀▄
@@ -329,9 +335,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)
@@ -341,14 +347,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{}
@@ -366,7 +372,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()),
)
}

View File

@@ -25,6 +25,7 @@ type commandsComponent struct {
app *app.App
width, height int
showKeybinds bool
showAll bool
background *compat.AdaptiveColor
limit *int
}
@@ -59,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)
@@ -75,18 +70,34 @@ func (c *commandsComponent) View() string {
keybindStyle = keybindStyle.Background(*c.background)
}
var commandsWithTriggers []commands.Command
var commandsToShow []commands.Command
var triggeredCommands []commands.Command
var untriggeredCommands []commands.Command
for _, cmd := range c.app.Commands.Sorted() {
if cmd.Trigger != "" {
commandsWithTriggers = append(commandsWithTriggers, cmd)
if c.showAll || cmd.Trigger != "" {
if cmd.Trigger != "" {
triggeredCommands = append(triggeredCommands, cmd)
} else if c.showAll {
untriggeredCommands = append(untriggeredCommands, cmd)
}
}
}
if c.limit != nil && len(commandsWithTriggers) > *c.limit {
commandsWithTriggers = commandsWithTriggers[:*c.limit]
// Combine triggered commands first, then untriggered
commandsToShow = append(commandsToShow, triggeredCommands...)
commandsToShow = append(commandsToShow, untriggeredCommands...)
if c.limit != nil && len(commandsToShow) > *c.limit {
commandsToShow = commandsToShow[:*c.limit]
}
if len(commandsWithTriggers) == 0 {
return styles.Muted().Render("No commands with triggers available")
if len(commandsToShow) == 0 {
muted := styles.NewStyle().Foreground(theme.CurrentTheme().TextMuted())
if c.showAll {
return muted.Render("No commands available")
}
return muted.Render("No commands with triggers available")
}
// Calculate column widths
@@ -101,10 +112,15 @@ func (c *commandsComponent) View() string {
keybinds string
}
rows := make([]commandRow, 0, len(commandsWithTriggers))
rows := make([]commandRow, 0, len(commandsToShow))
for _, cmd := range commandsWithTriggers {
trigger := "/" + cmd.Trigger
for _, cmd := range commandsToShow {
trigger := ""
if cmd.Trigger != "" {
trigger = "/" + cmd.Trigger
} else {
trigger = string(cmd.Name)
}
description := cmd.Description
// Format keybindings
@@ -144,6 +160,7 @@ func (c *commandsComponent) View() string {
// Build the output
var output strings.Builder
maxWidth := 0
for _, row := range rows {
// Pad each column to align properly
trigger := fmt.Sprintf("%-*s", maxTriggerWidth, row.trigger)
@@ -160,10 +177,14 @@ func (c *commandsComponent) View() string {
}
output.WriteString(line + "\n")
maxWidth = max(maxWidth, lipgloss.Width(line))
}
// Remove trailing newline
result := strings.TrimSuffix(output.String(), "\n")
if c.background != nil {
result = styles.NewStyle().Background(*c.background).Width(maxWidth).Render(result)
}
return result
}
@@ -188,11 +209,18 @@ func WithLimit(limit int) Option {
}
}
func WithShowAll(showAll bool) Option {
return func(c *commandsComponent) {
c.showAll = showAll
}
}
func New(app *app.App, opts ...Option) CommandsComponent {
c := &commandsComponent{
app: app,
background: nil,
showKeybinds: true,
showAll: false,
}
for _, opt := range opts {
opt(c)

View File

@@ -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(
@@ -116,7 +116,7 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case []CompletionItemI:
c.list.SetItems(msg)
case app.CompletionDialogTriggerdMsg:
case app.CompletionDialogTriggeredMsg:
c.pseudoSearchTextArea.SetValue(msg.InitialValue)
case tea.KeyMsg:
if c.pseudoSearchTextArea.Focused() {
@@ -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())
}

View File

@@ -1,71 +1,62 @@
package dialog
import (
"strings"
"github.com/charmbracelet/bubbles/v2/viewport"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/app"
commandsComponent "github.com/sst/opencode/internal/components/commands"
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/theme"
)
type helpDialog struct {
width int
height int
modal *modal.Modal
commands []commands.Command
width int
height int
modal *modal.Modal
app *app.App
commandsComponent commandsComponent.CommandsComponent
viewport viewport.Model
}
func (h *helpDialog) Init() tea.Cmd {
return nil
return tea.Batch(
h.commandsComponent.Init(),
h.viewport.Init(),
)
}
func (h *helpDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
h.width = msg.Width
h.height = msg.Height
// Set viewport size with some padding for the modal
h.viewport = viewport.New(viewport.WithWidth(msg.Width-4), viewport.WithHeight(msg.Height-6))
h.commandsComponent.SetSize(msg.Width-4, msg.Height-6)
}
return h, nil
// Update commands component first to get the latest content
_, cmdCmd := h.commandsComponent.Update(msg)
cmds = append(cmds, cmdCmd)
// Update viewport content
h.viewport.SetContent(h.commandsComponent.View())
// Update viewport
var vpCmd tea.Cmd
h.viewport, vpCmd = h.viewport.Update(msg)
cmds = append(cmds, vpCmd)
return h, tea.Batch(cmds...)
}
func (h *helpDialog) View() string {
t := theme.CurrentTheme()
keyStyle := lipgloss.NewStyle().
Background(t.BackgroundElement()).
Foreground(t.Text()).
Bold(true)
descStyle := lipgloss.NewStyle().
Background(t.BackgroundElement()).
Foreground(t.TextMuted())
contentStyle := lipgloss.NewStyle().
PaddingLeft(1).Background(t.BackgroundElement())
lines := []string{}
for _, b := range h.commands {
// Only interested in slash commands
if b.Trigger == "" {
continue
}
content := keyStyle.Render("/" + b.Trigger)
content += descStyle.Render(" " + b.Description)
// for i, key := range b.Keybindings {
// if i == 0 {
// keyString := " (" + key.Key + ")"
// space := max(h.width-lipgloss.Width(content)-lipgloss.Width(keyString), 0)
// spacer := strings.Repeat(" ", space)
// content += descStyle.Render(spacer)
// content += descStyle.Render(keyString)
// }
// }
lines = append(lines, contentStyle.Render(content))
}
return strings.Join(lines, "\n")
h.commandsComponent.SetBackgroundColor(t.BackgroundElement())
return h.viewport.View()
}
func (h *helpDialog) Render(background string) string {
@@ -80,9 +71,16 @@ type HelpDialog interface {
layout.Modal
}
func NewHelpDialog(commands []commands.Command) HelpDialog {
func NewHelpDialog(app *app.App) HelpDialog {
vp := viewport.New(viewport.WithHeight(12))
return &helpDialog{
commands: commands,
app: app,
commandsComponent: commandsComponent.New(app,
commandsComponent.WithBackground(theme.CurrentTheme().BackgroundElement()),
commandsComponent.WithShowAll(true),
commandsComponent.WithKeybinds(true),
),
modal: modal.New(modal.WithTitle("Help")),
viewport: vp,
}
}

View File

@@ -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

View File

@@ -11,6 +11,7 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
@@ -33,20 +34,15 @@ type modelDialog struct {
app *app.App
availableProviders []client.ProviderInfo
provider client.ProviderInfo
selectedIdx int
width int
height int
scrollOffset int
hScrollOffset int
hScrollPossible bool
modal *modal.Modal
width int
height int
hScrollOffset int
hScrollPossible bool
modal *modal.Modal
modelList list.List[list.StringItem]
}
type modelKeyMap struct {
Up key.Binding
Down key.Binding
Left key.Binding
Right key.Binding
Enter key.Binding
@@ -54,14 +50,6 @@ type modelKeyMap struct {
}
var modelKeys = modelKeyMap{
Up: key.NewBinding(
key.WithKeys("up", "k"),
key.WithHelp("↑", "previous model"),
),
Down: key.NewBinding(
key.WithKeys("down", "j"),
key.WithHelp("↓", "next model"),
),
Left: key.NewBinding(
key.WithKeys("left", "h"),
key.WithHelp("←", "scroll left"),
@@ -81,15 +69,7 @@ var modelKeys = modelKeyMap{
}
func (m *modelDialog) Init() tea.Cmd {
// cfg := config.Get()
// modelInfo := GetSelectedModel(cfg)
// m.availableProviders = getEnabledProviders(cfg)
// m.hScrollPossible = len(m.availableProviders) > 1
// m.provider = modelInfo.Provider
// m.hScrollOffset = findProviderIndex(m.availableProviders, m.provider)
// m.setupModelsForProvider(m.provider)
m.setupModelsForProvider(m.provider.Id)
return nil
}
@@ -97,26 +77,32 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, modelKeys.Up):
m.moveSelectionUp()
case key.Matches(msg, modelKeys.Down):
m.moveSelectionDown()
case key.Matches(msg, modelKeys.Left):
if m.hScrollPossible {
m.switchProvider(-1)
}
return m, nil
case key.Matches(msg, modelKeys.Right):
if m.hScrollPossible {
m.switchProvider(1)
}
return m, nil
case key.Matches(msg, modelKeys.Enter):
selectedItem, _ := m.modelList.GetSelectedItem()
models := m.models()
var selectedModel client.ModelInfo
for _, model := range models {
if model.Name == string(selectedItem) {
selectedModel = model
break
}
}
return m, tea.Sequence(
util.CmdHandler(modal.CloseModalMsg{}),
util.CmdHandler(
app.ModelSelectedMsg{
Provider: m.provider,
Model: models[m.selectedIdx],
Model: selectedModel,
}),
)
case key.Matches(msg, modelKeys.Escape):
@@ -127,7 +113,10 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.height = msg.Height
}
return m, nil
// Update the list component
updatedList, cmd := m.modelList.Update(msg)
m.modelList = updatedList.(list.List[list.StringItem])
return m, cmd
}
func (m *modelDialog) models() []client.ModelInfo {
@@ -137,40 +126,9 @@ func (m *modelDialog) models() []client.ModelInfo {
return models
}
// moveSelectionUp moves the selection up or wraps to bottom
func (m *modelDialog) moveSelectionUp() {
if m.selectedIdx > 0 {
m.selectedIdx--
} else {
m.selectedIdx = len(m.provider.Models) - 1
m.scrollOffset = max(0, len(m.provider.Models)-numVisibleModels)
}
// Keep selection visible
if m.selectedIdx < m.scrollOffset {
m.scrollOffset = m.selectedIdx
}
}
// moveSelectionDown moves the selection down or wraps to top
func (m *modelDialog) moveSelectionDown() {
if m.selectedIdx < len(m.provider.Models)-1 {
m.selectedIdx++
} else {
m.selectedIdx = 0
m.scrollOffset = 0
}
// Keep selection visible
if m.selectedIdx >= m.scrollOffset+numVisibleModels {
m.scrollOffset = m.selectedIdx - (numVisibleModels - 1)
}
}
func (m *modelDialog) switchProvider(offset int) {
newOffset := m.hScrollOffset + offset
// Ensure we stay within bounds
if newOffset < 0 {
newOffset = len(m.availableProviders) - 1
}
@@ -185,105 +143,46 @@ func (m *modelDialog) switchProvider(offset int) {
}
func (m *modelDialog) View() string {
t := theme.CurrentTheme()
baseStyle := lipgloss.NewStyle().
Background(t.BackgroundElement()).
Foreground(t.Text())
// Render visible models
endIdx := min(m.scrollOffset+numVisibleModels, len(m.provider.Models))
modelItems := make([]string, 0, endIdx-m.scrollOffset)
models := m.models()
for i := m.scrollOffset; i < endIdx; i++ {
itemStyle := baseStyle.Width(maxDialogWidth)
if i == m.selectedIdx {
itemStyle = itemStyle.
Background(t.Primary()).
Foreground(t.BackgroundElement()).
Bold(true)
}
modelItems = append(modelItems, itemStyle.Render(models[i].Name))
}
listView := m.modelList.View()
scrollIndicator := m.getScrollIndicators(maxDialogWidth)
content := lipgloss.JoinVertical(
lipgloss.Left,
baseStyle.
Width(maxDialogWidth).
Render(lipgloss.JoinVertical(lipgloss.Left, modelItems...)),
scrollIndicator,
)
return content
return strings.Join([]string{listView, scrollIndicator}, "\n")
}
func (m *modelDialog) getScrollIndicators(maxWidth int) string {
var indicator string
if len(m.provider.Models) > numVisibleModels {
if m.scrollOffset > 0 {
indicator += "↑ "
}
if m.scrollOffset+numVisibleModels < len(m.provider.Models) {
indicator += "↓ "
}
}
if m.hScrollPossible {
indicator = "← " + indicator + "→"
indicator = "← → (switch provider) "
}
if indicator == "" {
return ""
}
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
return baseStyle.
Foreground(t.Primary()).
return styles.NewStyle().
Foreground(t.TextMuted()).
Width(maxWidth).
Align(lipgloss.Right).
Bold(true).
Render(indicator)
}
// findProviderIndex returns the index of the provider in the list, or -1 if not found
// func findProviderIndex(providers []string, provider string) int {
// for i, p := range providers {
// if p == provider {
// return i
// }
// }
// return -1
// }
func (m *modelDialog) setupModelsForProvider(providerId string) {
models := m.models()
modelNames := make([]string, len(models))
for i, model := range models {
modelNames[i] = model.Name
}
func (m *modelDialog) setupModelsForProvider(_ string) {
m.selectedIdx = 0
m.scrollOffset = 0
m.modelList = list.NewStringList(modelNames, numVisibleModels, "No models available", true)
m.modelList.SetMaxWidth(maxDialogWidth)
// cfg := config.Get()
// agentCfg := cfg.Agents[config.AgentPrimary]
// selectedModelId := agentCfg.Model
// m.provider = provider
// m.models = getModelsForProvider(provider)
// Try to select the current model if it belongs to this provider
// if provider == models.SupportedModels[selectedModelId].Provider {
// for i, model := range m.models {
// if model.ID == selectedModelId {
// m.selectedIdx = i
// // Adjust scroll position to keep selected model visible
// if m.selectedIdx >= numVisibleModels {
// m.scrollOffset = m.selectedIdx - (numVisibleModels - 1)
// }
// break
// }
// }
// }
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 {
m.modelList.SetSelectedIndex(i)
break
}
}
}
}
func (m *modelDialog) Render(background string) string {
@@ -297,11 +196,30 @@ func (s *modelDialog) Close() tea.Cmd {
func NewModelDialog(app *app.App) ModelDialog {
availableProviders, _ := app.ListProviders(context.Background())
return &modelDialog{
availableProviders: availableProviders,
hScrollOffset: 0,
hScrollPossible: len(availableProviders) > 1,
provider: availableProviders[0],
modal: modal.New(modal.WithTitle(fmt.Sprintf("Select %s Model", availableProviders[0].Name))),
currentProvider := availableProviders[0]
hScrollOffset := 0
if app.Provider != nil {
for i, provider := range availableProviders {
if provider.Id == app.Provider.Id {
currentProvider = provider
hScrollOffset = i
break
}
}
}
dialog := &modelDialog{
app: app,
availableProviders: availableProviders,
hScrollOffset: hScrollOffset,
hScrollPossible: len(availableProviders) > 1,
provider: currentProvider,
modal: modal.New(
modal.WithTitle(fmt.Sprintf("Select %s Model", currentProvider.Name)),
modal.WithMaxWidth(maxDialogWidth+4),
),
}
dialog.setupModelsForProvider(currentProvider.Id)
return dialog
}

View File

@@ -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())
}

View File

@@ -2,11 +2,16 @@ package dialog
import (
"context"
"strings"
"slices"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/muesli/reflow/truncate"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/components/modal"
"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"
@@ -19,33 +24,65 @@ type SessionDialog interface {
layout.Modal
}
type sessionItem client.SessionInfo
// sessionItem is a custom list item for sessions that can show delete confirmation
type sessionItem struct {
title string
isDeleteConfirming bool
}
func (s sessionItem) Render(selected bool, width int) string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle().
Width(width - 4).
Background(t.BackgroundElement())
baseStyle := styles.NewStyle()
if selected {
baseStyle = baseStyle.
Background(t.Primary()).
Foreground(t.BackgroundElement()).
Bold(true)
var text string
if s.isDeleteConfirming {
text = "Press again to confirm delete"
} else {
baseStyle = baseStyle.
Foreground(t.Text())
text = s.title
}
return baseStyle.Padding(0, 1).Render(s.Title)
truncatedStr := truncate.StringWithTail(text, uint(width-1), "...")
var itemStyle styles.Style
if selected {
if s.isDeleteConfirming {
// Red background for delete confirmation
itemStyle = baseStyle.
Background(t.Error()).
Foreground(t.BackgroundElement()).
Width(width).
PaddingLeft(1)
} else {
// Normal selection
itemStyle = baseStyle.
Background(t.Primary()).
Foreground(t.BackgroundElement()).
Width(width).
PaddingLeft(1)
}
} else {
if s.isDeleteConfirming {
// Red text for delete confirmation when not selected
itemStyle = baseStyle.
Foreground(t.Error()).
PaddingLeft(1)
} else {
itemStyle = baseStyle.
PaddingLeft(1)
}
}
return itemStyle.Render(truncatedStr)
}
type sessionDialog struct {
width int
height int
modal *modal.Modal
selectedSessionID string
list list.List[sessionItem]
width int
height int
modal *modal.Modal
sessions []client.SessionInfo
list list.List[sessionItem]
app *app.App
deleteConfirmation int // -1 means no confirmation, >= 0 means confirming deletion of session at this index
}
func (s *sessionDialog) Init() tea.Cmd {
@@ -61,13 +98,45 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyPressMsg:
switch msg.String() {
case "enter":
if item, idx := s.list.GetSelectedItem(); idx >= 0 {
s.selectedSessionID = item.Id
if s.deleteConfirmation >= 0 {
s.deleteConfirmation = -1
s.updateListItems()
return s, nil
}
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
selectedSession := s.sessions[idx]
return s, tea.Sequence(
util.CmdHandler(modal.CloseModalMsg{}),
util.CmdHandler(app.SessionSelectedMsg(&item)),
util.CmdHandler(app.SessionSelectedMsg(&selectedSession)),
)
}
case "x", "delete", "backspace":
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
if s.deleteConfirmation == idx {
// Second press - actually delete the session
sessionToDelete := s.sessions[idx]
return s, tea.Sequence(
func() tea.Msg {
s.sessions = slices.Delete(s.sessions, idx, idx+1)
s.deleteConfirmation = -1
s.updateListItems()
return nil
},
s.deleteSession(sessionToDelete.Id),
)
} else {
// First press - enter delete confirmation mode
s.deleteConfirmation = idx
s.updateListItems()
return s, nil
}
}
case "esc":
if s.deleteConfirmation >= 0 {
s.deleteConfirmation = -1
s.updateListItems()
return s, nil
}
}
}
@@ -78,7 +147,42 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (s *sessionDialog) Render(background string) string {
return s.modal.Render(s.list.View(), background)
listView := s.list.View()
t := theme.CurrentTheme()
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")
return s.modal.Render(content, background)
}
func (s *sessionDialog) updateListItems() {
_, currentIdx := s.list.GetSelectedItem()
var items []sessionItem
for i, sess := range s.sessions {
item := sessionItem{
title: sess.Title,
isDeleteConfirming: s.deleteConfirmation == i,
}
items = append(items, item)
}
s.list.SetItems(items)
s.list.SetSelectedIndex(currentIdx)
}
func (s *sessionDialog) deleteSession(sessionID string) tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
if err := s.app.DeleteSession(ctx, sessionID); err != nil {
return toast.NewErrorToast("Failed to delete session: " + err.Error())()
}
return nil
}
}
func (s *sessionDialog) Close() tea.Cmd {
@@ -89,23 +193,36 @@ func (s *sessionDialog) Close() tea.Cmd {
func NewSessionDialog(app *app.App) SessionDialog {
sessions, _ := app.ListSessions(context.Background())
var sessionItems []sessionItem
var filteredSessions []client.SessionInfo
var items []sessionItem
for _, sess := range sessions {
if sess.ParentID != nil {
continue
}
sessionItems = append(sessionItems, sessionItem(sess))
filteredSessions = append(filteredSessions, sess)
items = append(items, sessionItem{
title: sess.Title,
isDeleteConfirming: false,
})
}
list := list.NewListComponent(
sessionItems,
// Create a generic list component
listComponent := list.NewListComponent(
items,
10, // maxVisibleSessions
"No sessions available",
true, // useAlphaNumericKeys
)
listComponent.SetMaxWidth(layout.Current.Container.Width - 12)
return &sessionDialog{
list: list,
modal: modal.New(modal.WithTitle("Switch Session"), modal.WithMaxWidth(80)),
sessions: filteredSessions,
list: listComponent,
app: app,
deleteConfirmation: -1,
modal: modal.New(
modal.WithTitle("Switch Session"),
modal.WithMaxWidth(layout.Current.Container.Width-8),
),
}
}

View File

@@ -5,7 +5,6 @@ import (
list "github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
@@ -20,35 +19,12 @@ type ThemeDialog interface {
layout.Modal
}
type themeItem struct {
name string
}
func (t themeItem) Render(selected bool, width int) string {
th := theme.CurrentTheme()
baseStyle := styles.BaseStyle().
Width(width - 2).
Background(th.BackgroundElement())
if selected {
baseStyle = baseStyle.
Background(th.Primary()).
Foreground(th.BackgroundElement()).
Bold(true)
} else {
baseStyle = baseStyle.
Foreground(th.Text())
}
return baseStyle.Padding(0, 1).Render(t.name)
}
type themeDialog struct {
width int
height int
modal *modal.Modal
list list.List[themeItem]
list list.List[list.StringItem]
originalTheme string
themeApplied bool
}
@@ -66,7 +42,7 @@ func (t *themeDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "enter":
if item, idx := t.list.GetSelectedItem(); idx >= 0 {
selectedTheme := item.name
selectedTheme := string(item)
if err := theme.SetTheme(selectedTheme); err != nil {
// status.Error(err.Error())
return t, nil
@@ -85,12 +61,12 @@ func (t *themeDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
listModel, cmd := t.list.Update(msg)
t.list = listModel.(list.List[themeItem])
t.list = listModel.(list.List[list.StringItem])
if item, newIdx := t.list.GetSelectedItem(); newIdx >= 0 && newIdx != prevIdx {
theme.SetTheme(item.name)
theme.SetTheme(string(item))
return t, util.CmdHandler(ThemeSelectedMsg{ThemeName: string(item)})
}
return t, cmd
}
@@ -101,6 +77,7 @@ func (t *themeDialog) Render(background string) string {
func (t *themeDialog) Close() tea.Cmd {
if !t.themeApplied {
theme.SetTheme(t.originalTheme)
return util.CmdHandler(ThemeSelectedMsg{ThemeName: t.originalTheme})
}
return nil
}
@@ -110,17 +87,15 @@ func NewThemeDialog() ThemeDialog {
themes := theme.AvailableThemes()
currentTheme := theme.CurrentThemeName()
var themeItems []themeItem
var selectedIdx int
for i, name := range themes {
themeItems = append(themeItems, themeItem{name: name})
if name == currentTheme {
selectedIdx = i
}
}
list := list.NewListComponent(
themeItems,
list := list.NewStringList(
themes,
10, // maxVisibleThemes
"No themes available",
true,
@@ -129,6 +104,9 @@ 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)
return &themeDialog{
list: list,
modal: modal.New(modal.WithTitle("Select Theme"), modal.WithMaxWidth(40)),

View File

@@ -441,84 +441,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 +527,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 +549,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 +572,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 +624,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 +670,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 +704,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 +722,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 +741,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 +754,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 +762,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 +772,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 +795,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 +809,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 +817,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 +827,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 +847,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 +878,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
}

View File

@@ -5,6 +5,9 @@ import (
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/muesli/reflow/truncate"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
type ListItem interface {
@@ -123,6 +126,9 @@ func (c *listComponent[T]) SetSelectedIndex(idx int) {
func (c *listComponent[T]) View() string {
items := c.items
maxWidth := c.maxWidth
if maxWidth == 0 {
maxWidth = 80 // Default width if not set
}
maxVisibleItems := min(c.maxVisibleItems, len(items))
startIdx := 0
@@ -161,3 +167,37 @@ func NewListComponent[T ListItem](items []T, maxVisibleItems int, fallbackMsg st
selectedIdx: 0,
}
}
// StringItem is a simple implementation of ListItem for string values
type StringItem string
func (s StringItem) Render(selected bool, width int) string {
t := theme.CurrentTheme()
baseStyle := styles.NewStyle()
truncatedStr := truncate.StringWithTail(string(s), uint(width-1), "...")
var itemStyle styles.Style
if selected {
itemStyle = baseStyle.
Background(t.Primary()).
Foreground(t.BackgroundElement()).
Width(width).
PaddingLeft(1)
} else {
itemStyle = baseStyle.
Foreground(t.TextMuted()).
PaddingLeft(1)
}
return itemStyle.Render(truncatedStr)
}
// NewStringList creates a new list component with string items
func NewStringList(items []string, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) List[StringItem] {
stringItems := make([]StringItem, len(items))
for i, item := range items {
stringItems[i] = StringItem(item)
}
return NewListComponent(stringItems, maxVisibleItems, fallbackMsg, useAlphaNumericKeys)
}

View File

@@ -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.
@@ -103,18 +99,18 @@ func (m *Modal) Render(contentView string, background string) string {
Bold(true).
Padding(0, 1)
escStyle := baseStyle.Foreground(t.TextMuted()).Bold(false)
escStyle := baseStyle.Foreground(t.TextMuted())
escText := escStyle.Render("esc")
// Calculate position for esc text
titleWidth := lipgloss.Width(m.title)
escWidth := lipgloss.Width(escText)
spacesNeeded := max(0, innerWidth-titleWidth-escWidth-3)
spacesNeeded := max(0, innerWidth-titleWidth-escWidth-2)
spacer := strings.Repeat(" ", spacesNeeded)
titleLine := m.title + spacer + escText
titleLine = titleStyle.Render(titleLine)
finalContent = strings.Join([]string{titleLine, contentView}, "\n") + "\n"
finalContent = strings.Join([]string{titleLine, "", contentView}, "\n")
} else {
finalContent = contentView
}

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

View File

@@ -36,14 +36,15 @@ 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)
}
@@ -71,13 +72,13 @@ func formatTokensAndCost(tokens float32, contextWindow float32, cost float32) st
formattedCost := fmt.Sprintf("$%.2f", cost)
percentage := (float64(tokens) / float64(contextWindow)) * 100
return fmt.Sprintf("Tokens: %s (%d%%), Cost: %s", formattedTokens, int(percentage), formattedCost)
return fmt.Sprintf("Context: %s (%d%%), Cost: %s", formattedTokens, int(percentage), formattedCost)
}
func (m statusComponent) View() string {
t := theme.CurrentTheme()
if m.app.Session.Id == "" {
return styles.BaseStyle().
return styles.NewStyle().
Background(t.Background()).
Width(m.width).
Height(2).
@@ -86,9 +87,10 @@ 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 := ""
@@ -111,9 +113,10 @@ func (m statusComponent) View() string {
}
}
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 +126,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
}

View File

@@ -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)

View File

@@ -5,8 +5,8 @@ package image
import (
"bytes"
"fmt"
"image"
"github.com/atotto/clipboard"
"image"
)
func GetImageFromClipboard() ([]byte, string, error) {
@@ -28,8 +28,6 @@ func GetImageFromClipboard() ([]byte, string, error) {
}
func binaryToImage(data []byte) ([]byte, error) {
reader := bytes.NewReader(data)
img, _, err := image.Decode(reader)
@@ -40,7 +38,6 @@ func binaryToImage(data []byte) ([]byte, error) {
return ImageToBytes(img)
}
func min(a, b int) int {
if a < b {
return a

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

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)
}

View File

@@ -109,18 +109,26 @@ func PlaceOverlay(
// Get the foreground line
fgLine := fgLines[i-y]
fgLineWidth := ansi.PrintableRuneWidth(fgLine)
// Extract the styles at the border positions
leftStyle := getStyleAtPosition(bgLine, pos)
rightStyle := getStyleAtPosition(bgLine, pos + 1 + fgLineWidth)
// We need to get the style just before the border position to preserve background
leftStyle := ansiStyle{}
if pos > 0 {
leftStyle = getStyleAtPosition(bgLine, pos-1)
} else {
leftStyle = getStyleAtPosition(bgLine, pos)
}
rightStyle := getStyleAtPosition(bgLine, pos+fgLineWidth)
// Left border - combine background from original with border foreground
leftSeq := combineStyles(leftStyle, options.borderColor)
if leftSeq != "" {
b.WriteString(leftSeq)
}
b.WriteString("┃")
b.WriteString("\x1b[0m") // Reset all styles
if leftSeq != "" {
b.WriteString("\x1b[0m") // Reset all styles only if we applied any
}
pos++
// Content
@@ -133,7 +141,9 @@ func PlaceOverlay(
b.WriteString(rightSeq)
}
b.WriteString("┃")
b.WriteString("\x1b[0m") // Reset all styles
if rightSeq != "" {
b.WriteString("\x1b[0m") // Reset all styles only if we applied any
}
pos++
} else {
// No border, just render the content
@@ -172,23 +182,25 @@ type ansiStyle struct {
// parseANSISequence parses an ANSI escape sequence into its components
func parseANSISequence(seq string) ansiStyle {
style := ansiStyle{}
// Extract the parameters from the sequence (e.g., \x1b[38;5;123;48;5;456m -> "38;5;123;48;5;456")
if !strings.HasPrefix(seq, "\x1b[") || !strings.HasSuffix(seq, "m") {
return style
}
params := seq[2 : len(seq)-1]
if params == "" {
return style
}
parts := strings.Split(params, ";")
i := 0
for i < len(parts) {
switch parts[i] {
case "0": // Reset
style = ansiStyle{}
// Mark this as a reset by adding it to attrs
style.attrs = append(style.attrs, "0")
// Don't clear the style here, let the caller handle it
case "1", "2", "3", "4", "5", "6", "7", "8", "9": // Various attributes
style.attrs = append(style.attrs, parts[i])
case "38": // Foreground color
@@ -222,7 +234,7 @@ func parseANSISequence(seq string) ansiStyle {
}
i++
}
return style
}
@@ -231,32 +243,30 @@ func combineStyles(bgStyle ansiStyle, fgColor *compat.AdaptiveColor) string {
if fgColor == nil && bgStyle.bgColor == "" && len(bgStyle.attrs) == 0 {
return ""
}
var parts []string
// Add attributes
parts = append(parts, bgStyle.attrs...)
// Add background color from the original style
if bgStyle.bgColor != "" {
parts = append(parts, bgStyle.bgColor)
}
// Add foreground color if specified
if fgColor != nil {
// Use the light color (could be improved to detect terminal background)
color := (*fgColor).Light
// Use RGBA to get color components
r, g, b, _ := color.RGBA()
// Use the adaptive color which automatically selects based on terminal background
// The RGBA method already handles light/dark selection
r, g, b, _ := fgColor.RGBA()
// RGBA returns 16-bit values, we need 8-bit
parts = append(parts, fmt.Sprintf("38;2;%d;%d;%d", r>>8, g>>8, b>>8))
}
if len(parts) == 0 {
return ""
}
return fmt.Sprintf("\x1b[%sm", strings.Join(parts, ";"))
}
@@ -264,10 +274,10 @@ func combineStyles(bgStyle ansiStyle, fgColor *compat.AdaptiveColor) string {
func getStyleAtPosition(s string, targetPos int) ansiStyle {
// ANSI escape sequence regex
ansiRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
visualPos := 0
currentStyle := ansiStyle{}
i := 0
for i < len(s) && visualPos <= targetPos {
// Check if we're at an ANSI escape sequence
@@ -275,18 +285,24 @@ func getStyleAtPosition(s string, targetPos int) ansiStyle {
// Found an ANSI sequence at current position
seq := s[i : i+match[1]]
parsedStyle := parseANSISequence(seq)
// Update current style (merge with existing)
if parsedStyle.fgColor != "" {
currentStyle.fgColor = parsedStyle.fgColor
// Check if this is a reset sequence
if len(parsedStyle.attrs) > 0 && parsedStyle.attrs[0] == "0" {
// Reset all styles
currentStyle = ansiStyle{}
} else {
// Update current style (merge with existing)
if parsedStyle.fgColor != "" {
currentStyle.fgColor = parsedStyle.fgColor
}
if parsedStyle.bgColor != "" {
currentStyle.bgColor = parsedStyle.bgColor
}
if len(parsedStyle.attrs) > 0 {
currentStyle.attrs = parsedStyle.attrs
}
}
if parsedStyle.bgColor != "" {
currentStyle.bgColor = parsedStyle.bgColor
}
if len(parsedStyle.attrs) > 0 {
currentStyle.attrs = parsedStyle.attrs
}
i += match[1]
} else if i < len(s) {
// Regular character
@@ -298,7 +314,7 @@ func getStyleAtPosition(s string, targetPos int) ansiStyle {
visualPos++
}
}
return currentStyle
}

View File

@@ -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,
}
}

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())
}

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())
}

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)}
}

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"image/color"
"os"
"path"
"path/filepath"
"strings"
@@ -42,7 +43,7 @@ func LoadThemesFromJSON() error {
continue
}
themeName := strings.TrimSuffix(entry.Name(), ".json")
data, err := themesFS.ReadFile(filepath.Join("themes", entry.Name()))
data, err := themesFS.ReadFile(path.Join("themes", entry.Name()))
if err != nil {
return fmt.Errorf("failed to read theme file %s: %w", entry.Name(), err)
}
@@ -170,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)
@@ -204,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)
@@ -239,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),
@@ -276,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

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")
}
}
@@ -133,4 +139,3 @@ func TestLoadThemesFromDirectories(t *testing.T) {
t.Error("Override theme not properly loaded")
}
}

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())
}

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),
}
}

View File

@@ -78,4 +78,3 @@
"syntaxPunctuation": "darkFg"
}
}

View File

@@ -110,4 +110,3 @@
"syntaxPunctuation": { "dark": "darkText", "light": "lightText" }
}
}

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -239,4 +239,3 @@
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -0,0 +1,235 @@
{
"$schema": "https://opencode.ai/theme.json",
"defs": {
"darkBg": "#263238",
"darkBgAlt": "#1e272c",
"darkBgPanel": "#37474f",
"darkFg": "#eeffff",
"darkFgMuted": "#546e7a",
"darkRed": "#f07178",
"darkPink": "#f78c6c",
"darkOrange": "#ffcb6b",
"darkYellow": "#ffcb6b",
"darkGreen": "#c3e88d",
"darkCyan": "#89ddff",
"darkBlue": "#82aaff",
"darkPurple": "#c792ea",
"darkViolet": "#bb80b3",
"lightBg": "#fafafa",
"lightBgAlt": "#f5f5f5",
"lightBgPanel": "#e7e7e8",
"lightFg": "#263238",
"lightFgMuted": "#90a4ae",
"lightRed": "#e53935",
"lightPink": "#ec407a",
"lightOrange": "#f4511e",
"lightYellow": "#ffb300",
"lightGreen": "#91b859",
"lightCyan": "#39adb5",
"lightBlue": "#6182b8",
"lightPurple": "#7c4dff",
"lightViolet": "#945eb8"
},
"theme": {
"primary": {
"dark": "darkBlue",
"light": "lightBlue"
},
"secondary": {
"dark": "darkPurple",
"light": "lightPurple"
},
"accent": {
"dark": "darkCyan",
"light": "lightCyan"
},
"error": {
"dark": "darkRed",
"light": "lightRed"
},
"warning": {
"dark": "darkYellow",
"light": "lightYellow"
},
"success": {
"dark": "darkGreen",
"light": "lightGreen"
},
"info": {
"dark": "darkOrange",
"light": "lightOrange"
},
"text": {
"dark": "darkFg",
"light": "lightFg"
},
"textMuted": {
"dark": "darkFgMuted",
"light": "lightFgMuted"
},
"background": {
"dark": "darkBg",
"light": "lightBg"
},
"backgroundPanel": {
"dark": "darkBgAlt",
"light": "lightBgAlt"
},
"backgroundElement": {
"dark": "darkBgPanel",
"light": "lightBgPanel"
},
"border": {
"dark": "#37474f",
"light": "#e0e0e0"
},
"borderActive": {
"dark": "darkBlue",
"light": "lightBlue"
},
"borderSubtle": {
"dark": "#1e272c",
"light": "#eeeeee"
},
"diffAdded": {
"dark": "darkGreen",
"light": "lightGreen"
},
"diffRemoved": {
"dark": "darkRed",
"light": "lightRed"
},
"diffContext": {
"dark": "darkFgMuted",
"light": "lightFgMuted"
},
"diffHunkHeader": {
"dark": "darkCyan",
"light": "lightCyan"
},
"diffHighlightAdded": {
"dark": "darkGreen",
"light": "lightGreen"
},
"diffHighlightRemoved": {
"dark": "darkRed",
"light": "lightRed"
},
"diffAddedBg": {
"dark": "#2e3c2b",
"light": "#e8f5e9"
},
"diffRemovedBg": {
"dark": "#3c2b2b",
"light": "#ffebee"
},
"diffContextBg": {
"dark": "darkBgAlt",
"light": "lightBgAlt"
},
"diffLineNumber": {
"dark": "#37474f",
"light": "#cfd8dc"
},
"diffAddedLineNumberBg": {
"dark": "#2e3c2b",
"light": "#e8f5e9"
},
"diffRemovedLineNumberBg": {
"dark": "#3c2b2b",
"light": "#ffebee"
},
"markdownText": {
"dark": "darkFg",
"light": "lightFg"
},
"markdownHeading": {
"dark": "darkBlue",
"light": "lightBlue"
},
"markdownLink": {
"dark": "darkCyan",
"light": "lightCyan"
},
"markdownLinkText": {
"dark": "darkPurple",
"light": "lightPurple"
},
"markdownCode": {
"dark": "darkGreen",
"light": "lightGreen"
},
"markdownBlockQuote": {
"dark": "darkFgMuted",
"light": "lightFgMuted"
},
"markdownEmph": {
"dark": "darkYellow",
"light": "lightYellow"
},
"markdownStrong": {
"dark": "darkOrange",
"light": "lightOrange"
},
"markdownHorizontalRule": {
"dark": "#37474f",
"light": "#e0e0e0"
},
"markdownListItem": {
"dark": "darkBlue",
"light": "lightBlue"
},
"markdownListEnumeration": {
"dark": "darkCyan",
"light": "lightCyan"
},
"markdownImage": {
"dark": "darkCyan",
"light": "lightCyan"
},
"markdownImageText": {
"dark": "darkPurple",
"light": "lightPurple"
},
"markdownCodeBlock": {
"dark": "darkFg",
"light": "lightFg"
},
"syntaxComment": {
"dark": "darkFgMuted",
"light": "lightFgMuted"
},
"syntaxKeyword": {
"dark": "darkPurple",
"light": "lightPurple"
},
"syntaxFunction": {
"dark": "darkBlue",
"light": "lightBlue"
},
"syntaxVariable": {
"dark": "darkFg",
"light": "lightFg"
},
"syntaxString": {
"dark": "darkGreen",
"light": "lightGreen"
},
"syntaxNumber": {
"dark": "darkOrange",
"light": "lightOrange"
},
"syntaxType": {
"dark": "darkYellow",
"light": "lightYellow"
},
"syntaxOperator": {
"dark": "darkCyan",
"light": "lightCyan"
},
"syntaxPunctuation": {
"dark": "darkFg",
"light": "lightFg"
}
}
}

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