Compare commits

...

11 Commits

Author SHA1 Message Date
Nicholas Hamilton
9345830c8a Fix filepicker manual input (#146)
* fix: allows to type i while manual inputting filepath

* fix: file selection in filepicker focus mode

* remove duplicate code
2025-05-09 16:40:06 +02:00
Kujtim Hoxha
5307100f89 small fix 2025-05-09 16:37:02 +02:00
Ed Zynda
a58e607c5f feat: custom commands (#133)
* Implement custom commands

* Add User: prefix

* Reuse var

* Check if the agent is busy and if so report a warning

* Update README

* fix typo

* Implement user and project scoped custom commands

* Allow for $ARGUMENTS

* UI tweaks

* Update internal/tui/components/dialog/arguments.go

Co-authored-by: Kujtim Hoxha <kujtimii.h@gmail.com>

* Also search in $HOME/.opencode/commands

---------

Co-authored-by: Kujtim Hoxha <kujtimii.h@gmail.com>
2025-05-09 16:33:35 +02:00
mineo
cd04c44517 replace github.com/google/generative-ai-go with github.com/googleapis/go-genai (#138)
* replace to github.com/googleapis/go-genai

* fix history logic

* small fixes

---------

Co-authored-by: Kujtim Hoxha <kujtimii.h@gmail.com>
2025-05-09 14:15:38 +02:00
Joshua LaMorey-Salzmann
88711db796 Config fix correcting loose viper string check, default model now set correctly (#147) 2025-05-05 09:40:58 +02:00
phantomreactor
9fec8df7d0 add support for images (#144) 2025-05-02 22:23:58 +02:00
Kujtim Hoxha
603a3e3c71 add xai support (#135) 2025-05-01 14:17:33 +02:00
Aiden Cline
e14de7a211 fix: tweak the logic in config to ensure that env vs file configurations merge properly (#115) 2025-05-01 13:22:48 +02:00
Garrett Ladley
004cfe7e8e feat: test for getContextFromPaths (#105)
* feat: test for getContextFromPaths

* fix: use testify
2025-05-01 12:55:28 +02:00
Adam
58705a1352 fix: more intuitive keybinds (#121) 2025-05-01 12:51:07 +02:00
Adam
82de14371d feat: themes (#113)
* feat: themes

* feat: flexoki theme

* feat: onedark theme

* feat: monokai pro theme

* feat: opencode theme (default)

* feat: dracula theme

* feat: tokyonight theme

* feat: tron theme

* some small fixes

---------

Co-authored-by: Kujtim Hoxha <kujtimii.h@gmail.com>
2025-05-01 12:49:26 +02:00
68 changed files with 6649 additions and 2535 deletions

View File

@@ -318,6 +318,70 @@ OpenCode is built with a modular architecture:
- **internal/session**: Session management
- **internal/lsp**: Language Server Protocol integration
## Custom Commands
OpenCode supports custom commands that can be created by users to quickly send predefined prompts to the AI assistant.
### Creating Custom Commands
Custom commands are predefined prompts stored as Markdown files in one of three locations:
1. **User Commands** (prefixed with `user:`):
```
$XDG_CONFIG_HOME/opencode/commands/
```
(typically `~/.config/opencode/commands/` on Linux/macOS)
or
```
$HOME/.opencode/commands/
```
2. **Project Commands** (prefixed with `project:`):
```
<PROJECT DIR>/.opencode/commands/
```
Each `.md` file in these directories becomes a custom command. The file name (without extension) becomes the command ID.
For example, creating a file at `~/.config/opencode/commands/prime-context.md` with content:
```markdown
RUN git ls-files
READ README.md
```
This creates a command called `user:prime-context`.
### Command Arguments
You can create commands that accept arguments by including the `$ARGUMENTS` placeholder in your command file:
```markdown
RUN git show $ARGUMENTS
```
When you run this command, OpenCode will prompt you to enter the text that should replace `$ARGUMENTS`.
### Organizing Commands
You can organize commands in subdirectories:
```
~/.config/opencode/commands/git/commit.md
```
This creates a command with ID `user:git:commit`.
### Using Custom Commands
1. Press `Ctrl+K` to open the command dialog
2. Select your custom command (prefixed with either `user:` or `project:`)
3. Press Enter to execute the command
The content of the command file will be sent as a message to the AI assistant.
## MCP (Model Context Protocol)
OpenCode implements the Model Context Protocol (MCP) to extend its capabilities through external tools. MCP provides a standardized way for the AI assistant to interact with external services and tools.

View File

@@ -98,6 +98,29 @@ func generateSchema() map[string]any {
},
}
schema["properties"].(map[string]any)["tui"] = map[string]any{
"type": "object",
"description": "Terminal User Interface configuration",
"properties": map[string]any{
"theme": map[string]any{
"type": "string",
"description": "TUI theme name",
"default": "opencode",
"enum": []string{
"opencode",
"catppuccin",
"dracula",
"flexoki",
"gruvbox",
"monokai",
"onedark",
"tokyonight",
"tron",
},
},
},
}
// Add MCP servers
schema["properties"].(map[string]any)["mcpServers"] = map[string]any{
"type": "object",

22
go.mod
View File

@@ -2,8 +2,6 @@ module github.com/opencode-ai/opencode
go 1.24.0
toolchain go1.24.2
require (
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0
github.com/JohannesKaufmann/html-to-markdown v1.6.0
@@ -16,12 +14,10 @@ require (
github.com/charmbracelet/bubbles v0.20.0
github.com/charmbracelet/bubbletea v1.3.4
github.com/charmbracelet/glamour v0.9.1
github.com/charmbracelet/huh v0.6.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/x/ansi v0.8.0
github.com/fsnotify/fsnotify v1.8.0
github.com/go-logfmt/logfmt v0.6.0
github.com/google/generative-ai-go v0.19.0
github.com/google/uuid v1.6.0
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231
github.com/mark3labs/mcp-go v0.17.0
@@ -35,16 +31,14 @@ require (
github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.20.0
github.com/stretchr/testify v1.10.0
google.golang.org/api v0.215.0
)
require (
cloud.google.com/go v0.116.0 // indirect
cloud.google.com/go/ai v0.8.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
cloud.google.com/go/auth v0.13.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
cloud.google.com/go/compute/metadata v0.6.0 // indirect
cloud.google.com/go/longrunning v0.5.7 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 // indirect
@@ -68,11 +62,10 @@ require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/disintegration/imaging v1.6.2
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.2 // indirect
@@ -85,13 +78,12 @@ require (
github.com/gorilla/css v1.0.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/ncruces/julianday v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
@@ -116,21 +108,19 @@ require (
github.com/yuin/goldmark v1.7.8 // indirect
github.com/yuin/goldmark-emoji v1.0.5 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/image v0.26.0 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/oauth2 v0.25.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/term v0.31.0 // indirect
golang.org/x/text v0.24.0 // indirect
golang.org/x/time v0.8.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 // indirect
google.golang.org/genai v1.3.0
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
google.golang.org/grpc v1.71.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect

33
go.sum
View File

@@ -1,15 +1,9 @@
cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
cloud.google.com/go/ai v0.8.0 h1:rXUEz8Wp2OlrM8r1bfmpF2+VKqc1VJpafE3HgzRnD/w=
cloud.google.com/go/ai v0.8.0/go.mod h1:t3Dfk4cM61sytiggo2UyGsDVW3RF1qGZaUKDrZFyqkE=
cloud.google.com/go/auth v0.13.0 h1:8Fu8TZy167JkW8Tj3q7dIkr2v4cndv41ouecJx0PAHs=
cloud.google.com/go/auth v0.13.0/go.mod h1:COOjD9gwfKNKz+IIduatIhYJQIc0mG3H102r/EMxX6Q=
cloud.google.com/go/auth/oauth2adapt v0.2.6 h1:V6a6XDu2lTwPZWOawrAa9HUK+DB2zfJyTuciBG5hFkU=
cloud.google.com/go/auth/oauth2adapt v0.2.6/go.mod h1:AlmsELtlEBnaNTL7jCj8VQFLy6mbZv0s4Q7NGBeQ5E8=
cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
cloud.google.com/go/longrunning v0.5.7 h1:WLbHekDbjK1fVFD3ibpFFVoyizlLRl73I7YKuAKilhU=
cloud.google.com/go/longrunning v0.5.7/go.mod h1:8GClkudohy1Fxm3owmBGid8W0pSgodEMwEAztp38Xng=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 h1:tfLQ34V6F7tVSwoTf/4lH5sE0o6eCJuNDTmH09nDpbc=
@@ -82,8 +76,6 @@ github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4p
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/glamour v0.9.1 h1:11dEfiGP8q1BEqvGoIjivuc2rBk+5qEXdPtaQ2WoiCM=
github.com/charmbracelet/glamour v0.9.1/go.mod h1:+SHvIS8qnwhgTpVMiXwn7OfGomSqff1cHBCI8jLOetk=
github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8=
github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
@@ -92,14 +84,14 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0G
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q=
github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -125,8 +117,6 @@ github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeD
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/generative-ai-go v0.19.0 h1:R71szggh8wHMCUlEMsW2A/3T+5LdEIkiaHSYgSpUgdg=
github.com/google/generative-ai-go v0.19.0/go.mod h1:JYolL13VG7j79kM5BtHz4qwONHkeJQzOCkKXnpqtS/E=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
@@ -139,6 +129,8 @@ github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrk
github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -169,8 +161,6 @@ github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6B
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -258,8 +248,6 @@ github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
@@ -283,6 +271,9 @@ golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -296,8 +287,6 @@ golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -338,17 +327,13 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.215.0 h1:jdYF4qnyczlEz2ReWIsosNLDuzXyvFHJtI5gcr0J7t0=
google.golang.org/api v0.215.0/go.mod h1:fta3CVtuJYOEdugLNWm6WodzOS8KdFckABwN4I40hzY=
google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 h1:GVIKPyP/kLIyVOgOnTwFOrvQaQUzOzGMCxgFUOEmm24=
google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422/go.mod h1:b6h1vNKhxaSoEI+5jc3PJUCustfli/mRab7295pY7rw=
google.golang.org/genai v1.3.0 h1:tXhPJF30skOjnnDY7ZnjK3q7IKy4PuAlEA0fk7uEaEI=
google.golang.org/genai v1.3.0/go.mod h1:TyfOKRz/QyCaj6f/ZDt505x+YreXnY40l2I6k8TvgqY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=

View File

@@ -16,6 +16,7 @@ import (
"github.com/opencode-ai/opencode/internal/message"
"github.com/opencode-ai/opencode/internal/permission"
"github.com/opencode-ai/opencode/internal/session"
"github.com/opencode-ai/opencode/internal/tui/theme"
)
type App struct {
@@ -49,6 +50,9 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) {
LSPClients: make(map[string]*lsp.Client),
}
// Initialize theme based on configuration
app.initTheme()
// Initialize LSP clients in the background
go app.initLSPClients(ctx)
@@ -73,6 +77,21 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) {
return app, nil
}
// initTheme sets the application theme based on the configuration
func (app *App) initTheme() {
cfg := config.Get()
if cfg == nil || cfg.TUI.Theme == "" {
return // Use default theme
}
// Try to set the theme from config
err := theme.SetTheme(cfg.TUI.Theme)
if err != nil {
logging.Warn("Failed to set theme from config, using default theme", "theme", cfg.TUI.Theme, "error", err)
} else {
logging.Debug("Set theme from config", "theme", cfg.TUI.Theme)
}
}
// Shutdown performs a clean shutdown of the application
func (app *App) Shutdown() {

View File

@@ -2,9 +2,11 @@
package config
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"github.com/opencode-ai/opencode/internal/llm/models"
@@ -65,6 +67,11 @@ type LSPConfig struct {
Options any `json:"options"`
}
// TUIConfig defines the configuration for the Terminal User Interface.
type TUIConfig struct {
Theme string `json:"theme,omitempty"`
}
// Config is the main configuration structure for the application.
type Config struct {
Data Data `json:"data"`
@@ -76,6 +83,7 @@ type Config struct {
Debug bool `json:"debug,omitempty"`
DebugLSP bool `json:"debugLSP,omitempty"`
ContextPaths []string `json:"contextPaths,omitempty"`
TUI TUIConfig `json:"tui"`
}
// Application constants
@@ -121,7 +129,6 @@ func Load(workingDir string, debug bool) (*Config, error) {
configureViper()
setDefaults(debug)
setProviderDefaults()
// Read global config
if err := readConfig(viper.ReadInConfig()); err != nil {
@@ -131,6 +138,8 @@ func Load(workingDir string, debug bool) (*Config, error) {
// Load and merge local config
mergeLocalConfig(workingDir)
setProviderDefaults()
// Apply configuration to the struct
if err := viper.Unmarshal(cfg); err != nil {
return cfg, fmt.Errorf("failed to unmarshal config: %w", err)
@@ -203,6 +212,7 @@ func configureViper() {
func setDefaults(debug bool) {
viper.SetDefault("data.directory", defaultDataDirectory)
viper.SetDefault("contextPaths", defaultContextPaths)
viper.SetDefault("tui.theme", "opencode")
if debug {
viper.SetDefault("debug", true)
@@ -213,7 +223,8 @@ func setDefaults(debug bool) {
}
}
// setProviderDefaults configures LLM provider defaults based on environment variables.
// setProviderDefaults configures LLM provider defaults based on provider provided by
// environment variables and configuration file.
func setProviderDefaults() {
// Set all API keys we can find in the environment
if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
@@ -231,15 +242,25 @@ func setProviderDefaults() {
if apiKey := os.Getenv("OPENROUTER_API_KEY"); apiKey != "" {
viper.SetDefault("providers.openrouter.apiKey", apiKey)
}
if apiKey := os.Getenv("XAI_API_KEY"); apiKey != "" {
viper.SetDefault("providers.xai.apiKey", apiKey)
}
if apiKey := os.Getenv("AZURE_OPENAI_ENDPOINT"); apiKey != "" {
// api-key may be empty when using Entra ID credentials that's okay
viper.SetDefault("providers.azure.apiKey", os.Getenv("AZURE_OPENAI_API_KEY"))
}
// Use this order to set the default models
// 1. Anthropic
// 2. OpenAI
// 3. Google Gemini
// 4. Groq
// 5. AWS Bedrock
// 5. OpenRouter
// 6. AWS Bedrock
// 7. Azure
// Anthropic configuration
if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
if key := viper.GetString("providers.anthropic.apiKey"); strings.TrimSpace(key) != "" {
viper.SetDefault("agents.coder.model", models.Claude37Sonnet)
viper.SetDefault("agents.task.model", models.Claude37Sonnet)
viper.SetDefault("agents.title.model", models.Claude37Sonnet)
@@ -247,7 +268,7 @@ func setProviderDefaults() {
}
// OpenAI configuration
if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
if key := viper.GetString("providers.openai.apiKey"); strings.TrimSpace(key) != "" {
viper.SetDefault("agents.coder.model", models.GPT41)
viper.SetDefault("agents.task.model", models.GPT41Mini)
viper.SetDefault("agents.title.model", models.GPT41Mini)
@@ -255,7 +276,7 @@ func setProviderDefaults() {
}
// Google Gemini configuration
if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
if key := viper.GetString("providers.gemini.apiKey"); strings.TrimSpace(key) != "" {
viper.SetDefault("agents.coder.model", models.Gemini25)
viper.SetDefault("agents.task.model", models.Gemini25Flash)
viper.SetDefault("agents.title.model", models.Gemini25Flash)
@@ -263,7 +284,7 @@ func setProviderDefaults() {
}
// Groq configuration
if apiKey := os.Getenv("GROQ_API_KEY"); apiKey != "" {
if key := viper.GetString("providers.groq.apiKey"); strings.TrimSpace(key) != "" {
viper.SetDefault("agents.coder.model", models.QWENQwq)
viper.SetDefault("agents.task.model", models.QWENQwq)
viper.SetDefault("agents.title.model", models.QWENQwq)
@@ -271,14 +292,21 @@ func setProviderDefaults() {
}
// OpenRouter configuration
if apiKey := os.Getenv("OPENROUTER_API_KEY"); apiKey != "" {
viper.SetDefault("providers.openrouter.apiKey", apiKey)
if key := viper.GetString("providers.openrouter.apiKey"); strings.TrimSpace(key) != "" {
viper.SetDefault("agents.coder.model", models.OpenRouterClaude37Sonnet)
viper.SetDefault("agents.task.model", models.OpenRouterClaude37Sonnet)
viper.SetDefault("agents.title.model", models.OpenRouterClaude35Haiku)
return
}
// XAI configuration
if key := viper.GetString("providers.xai.apiKey"); strings.TrimSpace(key) != "" {
viper.SetDefault("agents.coder.model", models.XAIGrok3Beta)
viper.SetDefault("agents.task.model", models.XAIGrok3Beta)
viper.SetDefault("agents.title.model", models.XAiGrok3MiniFastBeta)
return
}
// AWS Bedrock configuration
if hasAWSCredentials() {
viper.SetDefault("agents.coder.model", models.BedrockClaude37Sonnet)
@@ -287,9 +315,8 @@ func setProviderDefaults() {
return
}
// Azure OpenAI configuration
if os.Getenv("AZURE_OPENAI_ENDPOINT") != "" {
// api-key may be empty when using Entra ID credentials that's okay
viper.SetDefault("providers.azure.apiKey", os.Getenv("AZURE_OPENAI_API_KEY"))
viper.SetDefault("agents.coder.model", models.AzureGPT41)
viper.SetDefault("agents.task.model", models.AzureGPT41Mini)
viper.SetDefault("agents.title.model", models.AzureGPT41Mini)
@@ -714,3 +741,62 @@ func UpdateAgentModel(agentName AgentName, modelID models.ModelID) error {
return nil
}
// UpdateTheme updates the theme in the configuration and writes it to the config file.
func UpdateTheme(themeName string) error {
if cfg == nil {
return fmt.Errorf("config not loaded")
}
// Update the in-memory config
cfg.TUI.Theme = themeName
// Get the config file path
configFile := viper.ConfigFileUsed()
var configData []byte
if configFile == "" {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get home directory: %w", err)
}
configFile = filepath.Join(homeDir, fmt.Sprintf(".%s.json", appName))
logging.Info("config file not found, creating new one", "path", configFile)
configData = []byte(`{}`)
} else {
// Read the existing config file
data, err := os.ReadFile(configFile)
if err != nil {
return fmt.Errorf("failed to read config file: %w", err)
}
configData = data
}
// Parse the JSON
var configMap map[string]interface{}
if err := json.Unmarshal(configData, &configMap); err != nil {
return fmt.Errorf("failed to parse config file: %w", err)
}
// Update just the theme value
tuiConfig, ok := configMap["tui"].(map[string]interface{})
if !ok {
// TUI config doesn't exist yet, create it
configMap["tui"] = map[string]interface{}{"theme": themeName}
} else {
// Update existing TUI config
tuiConfig["theme"] = themeName
configMap["tui"] = tuiConfig
}
// Write the updated config back to file
updatedData, err := json.MarshalIndent(configMap, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
if err := os.WriteFile(configFile, updatedData, 0o644); err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}
return nil
}

View File

@@ -16,6 +16,7 @@ import (
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/tui/theme"
"github.com/sergi/go-diff/diffmatchpatch"
)
@@ -68,143 +69,6 @@ type linePair struct {
right *DiffLine
}
// -------------------------------------------------------------------------
// Style Configuration
// -------------------------------------------------------------------------
// StyleConfig defines styling for diff rendering
type StyleConfig struct {
ShowHeader bool
ShowHunkHeader bool
FileNameFg lipgloss.Color
// Background colors
RemovedLineBg lipgloss.Color
AddedLineBg lipgloss.Color
ContextLineBg lipgloss.Color
HunkLineBg lipgloss.Color
RemovedLineNumberBg lipgloss.Color
AddedLineNamerBg lipgloss.Color
// Foreground colors
HunkLineFg lipgloss.Color
RemovedFg lipgloss.Color
AddedFg lipgloss.Color
LineNumberFg lipgloss.Color
RemovedHighlightFg lipgloss.Color
AddedHighlightFg lipgloss.Color
// Highlight settings
HighlightStyle string
RemovedHighlightBg lipgloss.Color
AddedHighlightBg lipgloss.Color
}
// StyleOption is a function that modifies a StyleConfig
type StyleOption func(*StyleConfig)
// NewStyleConfig creates a StyleConfig with default values
func NewStyleConfig(opts ...StyleOption) StyleConfig {
// Default color scheme
config := StyleConfig{
ShowHeader: true,
ShowHunkHeader: true,
FileNameFg: lipgloss.Color("#a0a0a0"),
RemovedLineBg: lipgloss.Color("#3A3030"),
AddedLineBg: lipgloss.Color("#303A30"),
ContextLineBg: lipgloss.Color("#212121"),
HunkLineBg: lipgloss.Color("#212121"),
HunkLineFg: lipgloss.Color("#a0a0a0"),
RemovedFg: lipgloss.Color("#7C4444"),
AddedFg: lipgloss.Color("#478247"),
LineNumberFg: lipgloss.Color("#888888"),
HighlightStyle: "dracula",
RemovedHighlightBg: lipgloss.Color("#612726"),
AddedHighlightBg: lipgloss.Color("#256125"),
RemovedLineNumberBg: lipgloss.Color("#332929"),
AddedLineNamerBg: lipgloss.Color("#293229"),
RemovedHighlightFg: lipgloss.Color("#FADADD"),
AddedHighlightFg: lipgloss.Color("#DAFADA"),
}
// Apply all provided options
for _, opt := range opts {
opt(&config)
}
return config
}
// Style option functions
func WithFileNameFg(color lipgloss.Color) StyleOption {
return func(s *StyleConfig) { s.FileNameFg = color }
}
func WithRemovedLineBg(color lipgloss.Color) StyleOption {
return func(s *StyleConfig) { s.RemovedLineBg = color }
}
func WithAddedLineBg(color lipgloss.Color) StyleOption {
return func(s *StyleConfig) { s.AddedLineBg = color }
}
func WithContextLineBg(color lipgloss.Color) StyleOption {
return func(s *StyleConfig) { s.ContextLineBg = color }
}
func WithRemovedFg(color lipgloss.Color) StyleOption {
return func(s *StyleConfig) { s.RemovedFg = color }
}
func WithAddedFg(color lipgloss.Color) StyleOption {
return func(s *StyleConfig) { s.AddedFg = color }
}
func WithLineNumberFg(color lipgloss.Color) StyleOption {
return func(s *StyleConfig) { s.LineNumberFg = color }
}
func WithHighlightStyle(style string) StyleOption {
return func(s *StyleConfig) { s.HighlightStyle = style }
}
func WithRemovedHighlightColors(bg, fg lipgloss.Color) StyleOption {
return func(s *StyleConfig) {
s.RemovedHighlightBg = bg
s.RemovedHighlightFg = fg
}
}
func WithAddedHighlightColors(bg, fg lipgloss.Color) StyleOption {
return func(s *StyleConfig) {
s.AddedHighlightBg = bg
s.AddedHighlightFg = fg
}
}
func WithRemovedLineNumberBg(color lipgloss.Color) StyleOption {
return func(s *StyleConfig) { s.RemovedLineNumberBg = color }
}
func WithAddedLineNumberBg(color lipgloss.Color) StyleOption {
return func(s *StyleConfig) { s.AddedLineNamerBg = color }
}
func WithHunkLineBg(color lipgloss.Color) StyleOption {
return func(s *StyleConfig) { s.HunkLineBg = color }
}
func WithHunkLineFg(color lipgloss.Color) StyleOption {
return func(s *StyleConfig) { s.HunkLineFg = color }
}
func WithShowHeader(show bool) StyleOption {
return func(s *StyleConfig) { s.ShowHeader = show }
}
func WithShowHunkHeader(show bool) StyleOption {
return func(s *StyleConfig) { s.ShowHunkHeader = show }
}
// -------------------------------------------------------------------------
// Parse Configuration
// -------------------------------------------------------------------------
@@ -233,7 +97,6 @@ func WithContextSize(size int) ParseOption {
// SideBySideConfig configures the rendering of side-by-side diffs
type SideBySideConfig struct {
TotalWidth int
Style StyleConfig
}
// SideBySideOption modifies a SideBySideConfig
@@ -243,7 +106,6 @@ type SideBySideOption func(*SideBySideConfig)
func NewSideBySideConfig(opts ...SideBySideOption) SideBySideConfig {
config := SideBySideConfig{
TotalWidth: 160, // Default width for side-by-side view
Style: NewStyleConfig(),
}
for _, opt := range opts {
@@ -262,20 +124,6 @@ func WithTotalWidth(width int) SideBySideOption {
}
}
// WithStyle sets the styling configuration
func WithStyle(style StyleConfig) SideBySideOption {
return func(s *SideBySideConfig) {
s.Style = style
}
}
// WithStyleOptions applies the specified style options
func WithStyleOptions(opts ...StyleOption) SideBySideOption {
return func(s *SideBySideConfig) {
s.Style = NewStyleConfig(opts...)
}
}
// -------------------------------------------------------------------------
// Diff Parsing
// -------------------------------------------------------------------------
@@ -382,7 +230,7 @@ func ParseUnifiedDiff(diff string) (DiffResult, error) {
}
// HighlightIntralineChanges updates lines in a hunk to show character-level differences
func HighlightIntralineChanges(h *Hunk, style StyleConfig) {
func HighlightIntralineChanges(h *Hunk) {
var updated []DiffLine
dmp := diffmatchpatch.New()
@@ -476,6 +324,8 @@ func pairLines(lines []DiffLine) []linePair {
// SyntaxHighlight applies syntax highlighting to text based on file extension
func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg lipgloss.TerminalColor) error {
t := theme.CurrentTheme()
// Determine the language lexer to use
l := lexers.Match(fileName)
if l == nil {
@@ -491,93 +341,175 @@ func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg lipglos
if f == nil {
f = formatters.Fallback
}
theme := `
<style name="vscode-dark-plus">
<!-- Base colors -->
<entry type="Background" style="bg:#1E1E1E"/>
<entry type="Text" style="#D4D4D4"/>
<entry type="Other" style="#D4D4D4"/>
<entry type="Error" style="#F44747"/>
<!-- Keywords - using the Control flow / Special keywords color -->
<entry type="Keyword" style="#C586C0"/>
<entry type="KeywordConstant" style="#4FC1FF"/>
<entry type="KeywordDeclaration" style="#C586C0"/>
<entry type="KeywordNamespace" style="#C586C0"/>
<entry type="KeywordPseudo" style="#C586C0"/>
<entry type="KeywordReserved" style="#C586C0"/>
<entry type="KeywordType" style="#4EC9B0"/>
<!-- Names -->
<entry type="Name" style="#D4D4D4"/>
<entry type="NameAttribute" style="#9CDCFE"/>
<entry type="NameBuiltin" style="#4EC9B0"/>
<entry type="NameBuiltinPseudo" style="#9CDCFE"/>
<entry type="NameClass" style="#4EC9B0"/>
<entry type="NameConstant" style="#4FC1FF"/>
<entry type="NameDecorator" style="#DCDCAA"/>
<entry type="NameEntity" style="#9CDCFE"/>
<entry type="NameException" style="#4EC9B0"/>
<entry type="NameFunction" style="#DCDCAA"/>
<entry type="NameLabel" style="#C8C8C8"/>
<entry type="NameNamespace" style="#4EC9B0"/>
<entry type="NameOther" style="#9CDCFE"/>
<entry type="NameTag" style="#569CD6"/>
<entry type="NameVariable" style="#9CDCFE"/>
<entry type="NameVariableClass" style="#9CDCFE"/>
<entry type="NameVariableGlobal" style="#9CDCFE"/>
<entry type="NameVariableInstance" style="#9CDCFE"/>
<!-- Literals -->
<entry type="Literal" style="#CE9178"/>
<entry type="LiteralDate" style="#CE9178"/>
<entry type="LiteralString" style="#CE9178"/>
<entry type="LiteralStringBacktick" style="#CE9178"/>
<entry type="LiteralStringChar" style="#CE9178"/>
<entry type="LiteralStringDoc" style="#CE9178"/>
<entry type="LiteralStringDouble" style="#CE9178"/>
<entry type="LiteralStringEscape" style="#d7ba7d"/>
<entry type="LiteralStringHeredoc" style="#CE9178"/>
<entry type="LiteralStringInterpol" style="#CE9178"/>
<entry type="LiteralStringOther" style="#CE9178"/>
<entry type="LiteralStringRegex" style="#d16969"/>
<entry type="LiteralStringSingle" style="#CE9178"/>
<entry type="LiteralStringSymbol" style="#CE9178"/>
<!-- Numbers - using the numberLiteral color -->
<entry type="LiteralNumber" style="#b5cea8"/>
<entry type="LiteralNumberBin" style="#b5cea8"/>
<entry type="LiteralNumberFloat" style="#b5cea8"/>
<entry type="LiteralNumberHex" style="#b5cea8"/>
<entry type="LiteralNumberInteger" style="#b5cea8"/>
<entry type="LiteralNumberIntegerLong" style="#b5cea8"/>
<entry type="LiteralNumberOct" style="#b5cea8"/>
<!-- Operators -->
<entry type="Operator" style="#D4D4D4"/>
<entry type="OperatorWord" style="#C586C0"/>
<entry type="Punctuation" style="#D4D4D4"/>
<!-- Comments - standard VSCode Dark+ comment color -->
<entry type="Comment" style="#6A9955"/>
<entry type="CommentHashbang" style="#6A9955"/>
<entry type="CommentMultiline" style="#6A9955"/>
<entry type="CommentSingle" style="#6A9955"/>
<entry type="CommentSpecial" style="#6A9955"/>
<entry type="CommentPreproc" style="#C586C0"/>
<!-- Generic styles -->
<entry type="Generic" style="#D4D4D4"/>
<entry type="GenericDeleted" style="#F44747"/>
<entry type="GenericEmph" style="italic #D4D4D4"/>
<entry type="GenericError" style="#F44747"/>
<entry type="GenericHeading" style="bold #D4D4D4"/>
<entry type="GenericInserted" style="#b5cea8"/>
<entry type="GenericOutput" style="#808080"/>
<entry type="GenericPrompt" style="#D4D4D4"/>
<entry type="GenericStrong" style="bold #D4D4D4"/>
<entry type="GenericSubheading" style="bold #D4D4D4"/>
<entry type="GenericTraceback" style="#F44747"/>
<entry type="GenericUnderline" style="underline"/>
<entry type="TextWhitespace" style="#D4D4D4"/>
</style>
`
r := strings.NewReader(theme)
// Dynamic theme based on current theme values
syntaxThemeXml := fmt.Sprintf(`
<style name="opencode-theme">
<!-- Base colors -->
<entry type="Background" style="bg:%s"/>
<entry type="Text" style="%s"/>
<entry type="Other" style="%s"/>
<entry type="Error" style="%s"/>
<!-- Keywords -->
<entry type="Keyword" style="%s"/>
<entry type="KeywordConstant" style="%s"/>
<entry type="KeywordDeclaration" style="%s"/>
<entry type="KeywordNamespace" style="%s"/>
<entry type="KeywordPseudo" style="%s"/>
<entry type="KeywordReserved" style="%s"/>
<entry type="KeywordType" style="%s"/>
<!-- Names -->
<entry type="Name" style="%s"/>
<entry type="NameAttribute" style="%s"/>
<entry type="NameBuiltin" style="%s"/>
<entry type="NameBuiltinPseudo" style="%s"/>
<entry type="NameClass" style="%s"/>
<entry type="NameConstant" style="%s"/>
<entry type="NameDecorator" style="%s"/>
<entry type="NameEntity" style="%s"/>
<entry type="NameException" style="%s"/>
<entry type="NameFunction" style="%s"/>
<entry type="NameLabel" style="%s"/>
<entry type="NameNamespace" style="%s"/>
<entry type="NameOther" style="%s"/>
<entry type="NameTag" style="%s"/>
<entry type="NameVariable" style="%s"/>
<entry type="NameVariableClass" style="%s"/>
<entry type="NameVariableGlobal" style="%s"/>
<entry type="NameVariableInstance" style="%s"/>
<!-- Literals -->
<entry type="Literal" style="%s"/>
<entry type="LiteralDate" style="%s"/>
<entry type="LiteralString" style="%s"/>
<entry type="LiteralStringBacktick" style="%s"/>
<entry type="LiteralStringChar" style="%s"/>
<entry type="LiteralStringDoc" style="%s"/>
<entry type="LiteralStringDouble" style="%s"/>
<entry type="LiteralStringEscape" style="%s"/>
<entry type="LiteralStringHeredoc" style="%s"/>
<entry type="LiteralStringInterpol" style="%s"/>
<entry type="LiteralStringOther" style="%s"/>
<entry type="LiteralStringRegex" style="%s"/>
<entry type="LiteralStringSingle" style="%s"/>
<entry type="LiteralStringSymbol" style="%s"/>
<!-- Numbers -->
<entry type="LiteralNumber" style="%s"/>
<entry type="LiteralNumberBin" style="%s"/>
<entry type="LiteralNumberFloat" style="%s"/>
<entry type="LiteralNumberHex" style="%s"/>
<entry type="LiteralNumberInteger" style="%s"/>
<entry type="LiteralNumberIntegerLong" style="%s"/>
<entry type="LiteralNumberOct" style="%s"/>
<!-- Operators -->
<entry type="Operator" style="%s"/>
<entry type="OperatorWord" style="%s"/>
<entry type="Punctuation" style="%s"/>
<!-- Comments -->
<entry type="Comment" style="%s"/>
<entry type="CommentHashbang" style="%s"/>
<entry type="CommentMultiline" style="%s"/>
<entry type="CommentSingle" style="%s"/>
<entry type="CommentSpecial" style="%s"/>
<entry type="CommentPreproc" style="%s"/>
<!-- Generic styles -->
<entry type="Generic" style="%s"/>
<entry type="GenericDeleted" style="%s"/>
<entry type="GenericEmph" style="italic %s"/>
<entry type="GenericError" style="%s"/>
<entry type="GenericHeading" style="bold %s"/>
<entry type="GenericInserted" style="%s"/>
<entry type="GenericOutput" style="%s"/>
<entry type="GenericPrompt" style="%s"/>
<entry type="GenericStrong" style="bold %s"/>
<entry type="GenericSubheading" style="bold %s"/>
<entry type="GenericTraceback" style="%s"/>
<entry type="GenericUnderline" style="underline"/>
<entry type="TextWhitespace" style="%s"/>
</style>
`,
getColor(t.Background()), // Background
getColor(t.Text()), // Text
getColor(t.Text()), // Other
getColor(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
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
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
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
getColor(t.SyntaxOperator()), // Operator
getColor(t.SyntaxKeyword()), // OperatorWord
getColor(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
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
)
r := strings.NewReader(syntaxThemeXml)
style := chroma.MustNewXMLStyle(r)
// Modify the style to use the provided background
s, err := style.Builder().Transform(
func(t chroma.StyleEntry) chroma.StyleEntry {
@@ -599,6 +531,14 @@ func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg lipglos
return f.Format(w, s, it)
}
// getColor returns the appropriate hex color string based on terminal background
func getColor(adaptiveColor lipgloss.AdaptiveColor) string {
if lipgloss.HasDarkBackground() {
return adaptiveColor.Dark
}
return adaptiveColor.Light
}
// highlightLine applies syntax highlighting to a single line
func highlightLine(fileName string, line string, bg lipgloss.TerminalColor) string {
var buf bytes.Buffer
@@ -610,11 +550,11 @@ func highlightLine(fileName string, line string, bg lipgloss.TerminalColor) stri
}
// createStyles generates the lipgloss styles needed for rendering diffs
func createStyles(config StyleConfig) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle lipgloss.Style) {
removedLineStyle = lipgloss.NewStyle().Background(config.RemovedLineBg)
addedLineStyle = lipgloss.NewStyle().Background(config.AddedLineBg)
contextLineStyle = lipgloss.NewStyle().Background(config.ContextLineBg)
lineNumberStyle = lipgloss.NewStyle().Foreground(config.LineNumberFg)
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().Foreground(t.DiffLineNumber())
return
}
@@ -623,9 +563,20 @@ func createStyles(config StyleConfig) (removedLineStyle, addedLineStyle, context
// Rendering Functions
// -------------------------------------------------------------------------
func lipglossToHex(color lipgloss.Color) string {
r, g, b, a := color.RGBA()
// Scale uint32 values (0-65535) to uint8 (0-255).
r8 := uint8(r >> 8)
g8 := uint8(g >> 8)
b8 := uint8(b >> 8)
a8 := uint8(a >> 8)
return fmt.Sprintf("#%02x%02x%02x%02x", r8, g8, b8, a8)
}
// applyHighlighting applies intra-line highlighting to a piece of text
func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg lipgloss.Color,
) string {
func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg lipgloss.AdaptiveColor) string {
// Find all ANSI sequences in the content
ansiRegex := regexp.MustCompile(`\x1b(?:[@-Z\\-_]|\[[0-9?]*(?:;[0-9?]*)*[@-~])`)
ansiMatches := ansiRegex.FindAllStringIndex(content, -1)
@@ -663,6 +614,10 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
inSelection := false
currentPos := 0
// Get the appropriate color based on terminal background
bgColor := lipgloss.Color(getColor(highlightBg))
fgColor := lipgloss.Color(getColor(theme.CurrentTheme().Background()))
for i := 0; i < len(content); {
// Check if we're at an ANSI sequence
isAnsi := false
@@ -697,12 +652,16 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
// Get the current styling
currentStyle := ansiSequences[currentPos]
// Apply background highlight
// 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, _ := highlightBg.RGBA()
r, g, b, _ = bgColor.RGBA()
sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
sb.WriteString(char)
sb.WriteString("\x1b[49m") // Reset only background
// Reset foreground and background
sb.WriteString("\x1b[39m")
// Reapply the original ANSI sequence
sb.WriteString(currentStyle)
@@ -719,22 +678,24 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
}
// renderLeftColumn formats the left side of a side-by-side diff
func renderLeftColumn(fileName string, dl *DiffLine, colWidth int, styles StyleConfig) string {
func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string {
t := theme.CurrentTheme()
if dl == nil {
contextLineStyle := lipgloss.NewStyle().Background(styles.ContextLineBg)
contextLineStyle := lipgloss.NewStyle().Background(t.DiffContextBg())
return contextLineStyle.Width(colWidth).Render("")
}
removedLineStyle, _, contextLineStyle, lineNumberStyle := createStyles(styles)
removedLineStyle, _, contextLineStyle, lineNumberStyle := createStyles(t)
// Determine line style based on line type
var marker string
var bgStyle lipgloss.Style
switch dl.Kind {
case LineRemoved:
marker = removedLineStyle.Foreground(styles.RemovedFg).Render("-")
marker = removedLineStyle.Foreground(t.DiffRemoved()).Render("-")
bgStyle = removedLineStyle
lineNumberStyle = lineNumberStyle.Foreground(styles.RemovedFg).Background(styles.RemovedLineNumberBg)
lineNumberStyle = lineNumberStyle.Foreground(t.DiffRemoved()).Background(t.DiffRemovedLineNumberBg())
case LineAdded:
marker = "?"
bgStyle = contextLineStyle
@@ -757,7 +718,7 @@ func renderLeftColumn(fileName string, dl *DiffLine, colWidth int, styles StyleC
// Apply intra-line highlighting for removed lines
if dl.Kind == LineRemoved && len(dl.Segments) > 0 {
content = applyHighlighting(content, dl.Segments, LineRemoved, styles.RemovedHighlightBg)
content = applyHighlighting(content, dl.Segments, LineRemoved, t.DiffHighlightRemoved())
}
// Add a padding space for removed lines
@@ -771,28 +732,30 @@ func renderLeftColumn(fileName string, dl *DiffLine, colWidth int, styles StyleC
ansi.Truncate(
lineText,
colWidth,
lipgloss.NewStyle().Background(styles.HunkLineBg).Foreground(styles.HunkLineFg).Render("..."),
lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.TextMuted()).Render("..."),
),
)
}
// renderRightColumn formats the right side of a side-by-side diff
func renderRightColumn(fileName string, dl *DiffLine, colWidth int, styles StyleConfig) string {
func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
t := theme.CurrentTheme()
if dl == nil {
contextLineStyle := lipgloss.NewStyle().Background(styles.ContextLineBg)
contextLineStyle := lipgloss.NewStyle().Background(t.DiffContextBg())
return contextLineStyle.Width(colWidth).Render("")
}
_, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(styles)
_, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(t)
// Determine line style based on line type
var marker string
var bgStyle lipgloss.Style
switch dl.Kind {
case LineAdded:
marker = addedLineStyle.Foreground(styles.AddedFg).Render("+")
marker = addedLineStyle.Foreground(t.DiffAdded()).Render("+")
bgStyle = addedLineStyle
lineNumberStyle = lineNumberStyle.Foreground(styles.AddedFg).Background(styles.AddedLineNamerBg)
lineNumberStyle = lineNumberStyle.Foreground(t.DiffAdded()).Background(t.DiffAddedLineNumberBg())
case LineRemoved:
marker = "?"
bgStyle = contextLineStyle
@@ -815,7 +778,7 @@ func renderRightColumn(fileName string, dl *DiffLine, colWidth int, styles Style
// Apply intra-line highlighting for added lines
if dl.Kind == LineAdded && len(dl.Segments) > 0 {
content = applyHighlighting(content, dl.Segments, LineAdded, styles.AddedHighlightBg)
content = applyHighlighting(content, dl.Segments, LineAdded, t.DiffHighlightAdded())
}
// Add a padding space for added lines
@@ -829,7 +792,7 @@ func renderRightColumn(fileName string, dl *DiffLine, colWidth int, styles Style
ansi.Truncate(
lineText,
colWidth,
lipgloss.NewStyle().Background(styles.HunkLineBg).Foreground(styles.HunkLineFg).Render("..."),
lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.TextMuted()).Render("..."),
),
)
}
@@ -848,7 +811,7 @@ func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) str
copy(hunkCopy.Lines, h.Lines)
// Highlight changes within lines
HighlightIntralineChanges(&hunkCopy, config.Style)
HighlightIntralineChanges(&hunkCopy)
// Pair lines for side-by-side display
pairs := pairLines(hunkCopy.Lines)
@@ -860,8 +823,8 @@ func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) str
rightWidth := config.TotalWidth - colWidth
var sb strings.Builder
for _, p := range pairs {
leftStr := renderLeftColumn(fileName, p.left, leftWidth, config.Style)
rightStr := renderRightColumn(fileName, p.right, rightWidth, config.Style)
leftStr := renderLeftColumn(fileName, p.left, leftWidth)
rightStr := renderRightColumn(fileName, p.right, rightWidth)
sb.WriteString(leftStr + rightStr + "\n")
}
@@ -876,54 +839,7 @@ func FormatDiff(diffText string, opts ...SideBySideOption) (string, error) {
}
var sb strings.Builder
config := NewSideBySideConfig(opts...)
if config.Style.ShowHeader {
removeIcon := lipgloss.NewStyle().
Background(config.Style.RemovedLineBg).
Foreground(config.Style.RemovedFg).
Render("⏹")
addIcon := lipgloss.NewStyle().
Background(config.Style.AddedLineBg).
Foreground(config.Style.AddedFg).
Render("⏹")
fileName := lipgloss.NewStyle().
Background(config.Style.ContextLineBg).
Foreground(config.Style.FileNameFg).
Render(" " + diffResult.OldFile)
sb.WriteString(
lipgloss.NewStyle().
Background(config.Style.ContextLineBg).
Padding(0, 1, 0, 1).
Foreground(config.Style.FileNameFg).
BorderStyle(lipgloss.NormalBorder()).
BorderTop(true).
BorderBottom(true).
BorderForeground(config.Style.FileNameFg).
BorderBackground(config.Style.ContextLineBg).
Width(config.TotalWidth).
Render(
lipgloss.JoinHorizontal(lipgloss.Top,
removeIcon,
addIcon,
fileName,
),
) + "\n",
)
}
for _, h := range diffResult.Hunks {
// Render hunk header
if config.Style.ShowHunkHeader {
sb.WriteString(
lipgloss.NewStyle().
Background(config.Style.HunkLineBg).
Foreground(config.Style.HunkLineFg).
Width(config.TotalWidth).
Render(h.Header) + "\n",
)
}
sb.WriteString(RenderSideBySideHunk(diffResult.OldFile, h, opts...))
}
@@ -944,8 +860,8 @@ func GenerateDiff(beforeContent, afterContent, fileName string) (string, int, in
removals = 0
)
lines := strings.Split(unified, "\n")
for _, line := range lines {
lines := strings.SplitSeq(unified, "\n")
for line := range lines {
if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") {
additions++
} else if strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") {

View File

@@ -38,7 +38,7 @@ func (e *AgentEvent) Response() message.Message {
}
type Service interface {
Run(ctx context.Context, sessionID string, content string) (<-chan AgentEvent, error)
Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error)
Cancel(sessionID string)
IsSessionBusy(sessionID string) bool
IsBusy() bool
@@ -117,6 +117,9 @@ func (a *agent) IsSessionBusy(sessionID string) bool {
}
func (a *agent) generateTitle(ctx context.Context, sessionID string, content string) error {
if content == "" {
return nil
}
if a.titleProvider == nil {
return nil
}
@@ -124,16 +127,13 @@ func (a *agent) generateTitle(ctx context.Context, sessionID string, content str
if err != nil {
return err
}
parts := []message.ContentPart{message.TextContent{Text: content}}
response, err := a.titleProvider.SendMessages(
ctx,
[]message.Message{
{
Role: message.User,
Parts: []message.ContentPart{
message.TextContent{
Text: content,
},
},
Role: message.User,
Parts: parts,
},
},
make([]tools.BaseTool, 0),
@@ -158,7 +158,10 @@ func (a *agent) err(err error) AgentEvent {
}
}
func (a *agent) Run(ctx context.Context, sessionID string, content string) (<-chan AgentEvent, error) {
func (a *agent) Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error) {
if !a.provider.Model().SupportsAttachments && attachments != nil {
attachments = nil
}
events := make(chan AgentEvent)
if a.IsSessionBusy(sessionID) {
return nil, ErrSessionBusy
@@ -172,10 +175,13 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string) (<-ch
defer logging.RecoverPanic("agent.Run", func() {
events <- a.err(fmt.Errorf("panic while running the agent"))
})
result := a.processGeneration(genCtx, sessionID, content)
var attachmentParts []message.ContentPart
for _, attachment := range attachments {
attachmentParts = append(attachmentParts, message.BinaryContent{Path: attachment.FilePath, MIMEType: attachment.MimeType, Data: attachment.Content})
}
result := a.processGeneration(genCtx, sessionID, content, attachmentParts)
if result.Err() != nil && !errors.Is(result.Err(), ErrRequestCancelled) && !errors.Is(result.Err(), context.Canceled) {
logging.ErrorPersist(fmt.Sprintf("Generation error for session %s: %v", sessionID, result))
logging.ErrorPersist(result.Err().Error())
}
logging.Debug("Request completed", "sessionID", sessionID)
a.activeRequests.Delete(sessionID)
@@ -186,7 +192,7 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string) (<-ch
return events, nil
}
func (a *agent) processGeneration(ctx context.Context, sessionID, content string) AgentEvent {
func (a *agent) processGeneration(ctx context.Context, sessionID, content string, attachmentParts []message.ContentPart) AgentEvent {
// List existing messages; if none, start title generation asynchronously.
msgs, err := a.messages.List(ctx, sessionID)
if err != nil {
@@ -204,13 +210,13 @@ func (a *agent) processGeneration(ctx context.Context, sessionID, content string
}()
}
userMsg, err := a.createUserMessage(ctx, sessionID, content)
userMsg, err := a.createUserMessage(ctx, sessionID, content, attachmentParts)
if err != nil {
return a.err(fmt.Errorf("failed to create user message: %w", err))
}
// Append the new user message to the conversation history.
msgHistory := append(msgs, userMsg)
for {
// Check for cancellation before each iteration
select {
@@ -240,12 +246,12 @@ func (a *agent) processGeneration(ctx context.Context, sessionID, content string
}
}
func (a *agent) createUserMessage(ctx context.Context, sessionID, content string) (message.Message, error) {
func (a *agent) createUserMessage(ctx context.Context, sessionID, content string, attachmentParts []message.ContentPart) (message.Message, error) {
parts := []message.ContentPart{message.TextContent{Text: content}}
parts = append(parts, attachmentParts...)
return a.messages.Create(ctx, sessionID, message.CreateMessageParams{
Role: message.User,
Parts: []message.ContentPart{
message.TextContent{Text: content},
},
Role: message.User,
Parts: parts,
})
}
@@ -310,7 +316,6 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg
}
continue
}
toolResult, toolErr := tool.Run(ctx, tools.ToolCall{
ID: toolCall.ID,
Name: toolCall.Name,

View File

@@ -14,64 +14,69 @@ const (
// https://docs.anthropic.com/en/docs/about-claude/models/all-models
var AnthropicModels = map[ModelID]Model{
Claude35Sonnet: {
ID: Claude35Sonnet,
Name: "Claude 3.5 Sonnet",
Provider: ProviderAnthropic,
APIModel: "claude-3-5-sonnet-latest",
CostPer1MIn: 3.0,
CostPer1MInCached: 3.75,
CostPer1MOutCached: 0.30,
CostPer1MOut: 15.0,
ContextWindow: 200000,
DefaultMaxTokens: 5000,
ID: Claude35Sonnet,
Name: "Claude 3.5 Sonnet",
Provider: ProviderAnthropic,
APIModel: "claude-3-5-sonnet-latest",
CostPer1MIn: 3.0,
CostPer1MInCached: 3.75,
CostPer1MOutCached: 0.30,
CostPer1MOut: 15.0,
ContextWindow: 200000,
DefaultMaxTokens: 5000,
SupportsAttachments: true,
},
Claude3Haiku: {
ID: Claude3Haiku,
Name: "Claude 3 Haiku",
Provider: ProviderAnthropic,
APIModel: "claude-3-haiku-20240307", // doesn't support "-latest"
CostPer1MIn: 0.25,
CostPer1MInCached: 0.30,
CostPer1MOutCached: 0.03,
CostPer1MOut: 1.25,
ContextWindow: 200000,
DefaultMaxTokens: 4096,
ID: Claude3Haiku,
Name: "Claude 3 Haiku",
Provider: ProviderAnthropic,
APIModel: "claude-3-haiku-20240307", // doesn't support "-latest"
CostPer1MIn: 0.25,
CostPer1MInCached: 0.30,
CostPer1MOutCached: 0.03,
CostPer1MOut: 1.25,
ContextWindow: 200000,
DefaultMaxTokens: 4096,
SupportsAttachments: true,
},
Claude37Sonnet: {
ID: Claude37Sonnet,
Name: "Claude 3.7 Sonnet",
Provider: ProviderAnthropic,
APIModel: "claude-3-7-sonnet-latest",
CostPer1MIn: 3.0,
CostPer1MInCached: 3.75,
CostPer1MOutCached: 0.30,
CostPer1MOut: 15.0,
ContextWindow: 200000,
DefaultMaxTokens: 50000,
CanReason: true,
ID: Claude37Sonnet,
Name: "Claude 3.7 Sonnet",
Provider: ProviderAnthropic,
APIModel: "claude-3-7-sonnet-latest",
CostPer1MIn: 3.0,
CostPer1MInCached: 3.75,
CostPer1MOutCached: 0.30,
CostPer1MOut: 15.0,
ContextWindow: 200000,
DefaultMaxTokens: 50000,
CanReason: true,
SupportsAttachments: true,
},
Claude35Haiku: {
ID: Claude35Haiku,
Name: "Claude 3.5 Haiku",
Provider: ProviderAnthropic,
APIModel: "claude-3-5-haiku-latest",
CostPer1MIn: 0.80,
CostPer1MInCached: 1.0,
CostPer1MOutCached: 0.08,
CostPer1MOut: 4.0,
ContextWindow: 200000,
DefaultMaxTokens: 4096,
ID: Claude35Haiku,
Name: "Claude 3.5 Haiku",
Provider: ProviderAnthropic,
APIModel: "claude-3-5-haiku-latest",
CostPer1MIn: 0.80,
CostPer1MInCached: 1.0,
CostPer1MOutCached: 0.08,
CostPer1MOut: 4.0,
ContextWindow: 200000,
DefaultMaxTokens: 4096,
SupportsAttachments: true,
},
Claude3Opus: {
ID: Claude3Opus,
Name: "Claude 3 Opus",
Provider: ProviderAnthropic,
APIModel: "claude-3-opus-latest",
CostPer1MIn: 15.0,
CostPer1MInCached: 18.75,
CostPer1MOutCached: 1.50,
CostPer1MOut: 75.0,
ContextWindow: 200000,
DefaultMaxTokens: 4096,
ID: Claude3Opus,
Name: "Claude 3 Opus",
Provider: ProviderAnthropic,
APIModel: "claude-3-opus-latest",
CostPer1MIn: 15.0,
CostPer1MInCached: 18.75,
CostPer1MOutCached: 1.50,
CostPer1MOut: 75.0,
ContextWindow: 200000,
DefaultMaxTokens: 4096,
SupportsAttachments: true,
},
}

View File

@@ -18,140 +18,151 @@ const (
var AzureModels = map[ModelID]Model{
AzureGPT41: {
ID: AzureGPT41,
Name: "Azure OpenAI GPT 4.1",
Provider: ProviderAzure,
APIModel: "gpt-4.1",
CostPer1MIn: OpenAIModels[GPT41].CostPer1MIn,
CostPer1MInCached: OpenAIModels[GPT41].CostPer1MInCached,
CostPer1MOut: OpenAIModels[GPT41].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[GPT41].CostPer1MOutCached,
ContextWindow: OpenAIModels[GPT41].ContextWindow,
DefaultMaxTokens: OpenAIModels[GPT41].DefaultMaxTokens,
ID: AzureGPT41,
Name: "Azure OpenAI GPT 4.1",
Provider: ProviderAzure,
APIModel: "gpt-4.1",
CostPer1MIn: OpenAIModels[GPT41].CostPer1MIn,
CostPer1MInCached: OpenAIModels[GPT41].CostPer1MInCached,
CostPer1MOut: OpenAIModels[GPT41].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[GPT41].CostPer1MOutCached,
ContextWindow: OpenAIModels[GPT41].ContextWindow,
DefaultMaxTokens: OpenAIModels[GPT41].DefaultMaxTokens,
SupportsAttachments: true,
},
AzureGPT41Mini: {
ID: AzureGPT41Mini,
Name: "Azure OpenAI GPT 4.1 mini",
Provider: ProviderAzure,
APIModel: "gpt-4.1-mini",
CostPer1MIn: OpenAIModels[GPT41Mini].CostPer1MIn,
CostPer1MInCached: OpenAIModels[GPT41Mini].CostPer1MInCached,
CostPer1MOut: OpenAIModels[GPT41Mini].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[GPT41Mini].CostPer1MOutCached,
ContextWindow: OpenAIModels[GPT41Mini].ContextWindow,
DefaultMaxTokens: OpenAIModels[GPT41Mini].DefaultMaxTokens,
ID: AzureGPT41Mini,
Name: "Azure OpenAI GPT 4.1 mini",
Provider: ProviderAzure,
APIModel: "gpt-4.1-mini",
CostPer1MIn: OpenAIModels[GPT41Mini].CostPer1MIn,
CostPer1MInCached: OpenAIModels[GPT41Mini].CostPer1MInCached,
CostPer1MOut: OpenAIModels[GPT41Mini].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[GPT41Mini].CostPer1MOutCached,
ContextWindow: OpenAIModels[GPT41Mini].ContextWindow,
DefaultMaxTokens: OpenAIModels[GPT41Mini].DefaultMaxTokens,
SupportsAttachments: true,
},
AzureGPT41Nano: {
ID: AzureGPT41Nano,
Name: "Azure OpenAI GPT 4.1 nano",
Provider: ProviderAzure,
APIModel: "gpt-4.1-nano",
CostPer1MIn: OpenAIModels[GPT41Nano].CostPer1MIn,
CostPer1MInCached: OpenAIModels[GPT41Nano].CostPer1MInCached,
CostPer1MOut: OpenAIModels[GPT41Nano].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[GPT41Nano].CostPer1MOutCached,
ContextWindow: OpenAIModels[GPT41Nano].ContextWindow,
DefaultMaxTokens: OpenAIModels[GPT41Nano].DefaultMaxTokens,
ID: AzureGPT41Nano,
Name: "Azure OpenAI GPT 4.1 nano",
Provider: ProviderAzure,
APIModel: "gpt-4.1-nano",
CostPer1MIn: OpenAIModels[GPT41Nano].CostPer1MIn,
CostPer1MInCached: OpenAIModels[GPT41Nano].CostPer1MInCached,
CostPer1MOut: OpenAIModels[GPT41Nano].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[GPT41Nano].CostPer1MOutCached,
ContextWindow: OpenAIModels[GPT41Nano].ContextWindow,
DefaultMaxTokens: OpenAIModels[GPT41Nano].DefaultMaxTokens,
SupportsAttachments: true,
},
AzureGPT45Preview: {
ID: AzureGPT45Preview,
Name: "Azure OpenAI GPT 4.5 preview",
Provider: ProviderAzure,
APIModel: "gpt-4.5-preview",
CostPer1MIn: OpenAIModels[GPT45Preview].CostPer1MIn,
CostPer1MInCached: OpenAIModels[GPT45Preview].CostPer1MInCached,
CostPer1MOut: OpenAIModels[GPT45Preview].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[GPT45Preview].CostPer1MOutCached,
ContextWindow: OpenAIModels[GPT45Preview].ContextWindow,
DefaultMaxTokens: OpenAIModels[GPT45Preview].DefaultMaxTokens,
ID: AzureGPT45Preview,
Name: "Azure OpenAI GPT 4.5 preview",
Provider: ProviderAzure,
APIModel: "gpt-4.5-preview",
CostPer1MIn: OpenAIModels[GPT45Preview].CostPer1MIn,
CostPer1MInCached: OpenAIModels[GPT45Preview].CostPer1MInCached,
CostPer1MOut: OpenAIModels[GPT45Preview].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[GPT45Preview].CostPer1MOutCached,
ContextWindow: OpenAIModels[GPT45Preview].ContextWindow,
DefaultMaxTokens: OpenAIModels[GPT45Preview].DefaultMaxTokens,
SupportsAttachments: true,
},
AzureGPT4o: {
ID: AzureGPT4o,
Name: "Azure OpenAI GPT-4o",
Provider: ProviderAzure,
APIModel: "gpt-4o",
CostPer1MIn: OpenAIModels[GPT4o].CostPer1MIn,
CostPer1MInCached: OpenAIModels[GPT4o].CostPer1MInCached,
CostPer1MOut: OpenAIModels[GPT4o].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[GPT4o].CostPer1MOutCached,
ContextWindow: OpenAIModels[GPT4o].ContextWindow,
DefaultMaxTokens: OpenAIModels[GPT4o].DefaultMaxTokens,
ID: AzureGPT4o,
Name: "Azure OpenAI GPT-4o",
Provider: ProviderAzure,
APIModel: "gpt-4o",
CostPer1MIn: OpenAIModels[GPT4o].CostPer1MIn,
CostPer1MInCached: OpenAIModels[GPT4o].CostPer1MInCached,
CostPer1MOut: OpenAIModels[GPT4o].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[GPT4o].CostPer1MOutCached,
ContextWindow: OpenAIModels[GPT4o].ContextWindow,
DefaultMaxTokens: OpenAIModels[GPT4o].DefaultMaxTokens,
SupportsAttachments: true,
},
AzureGPT4oMini: {
ID: AzureGPT4oMini,
Name: "Azure OpenAI GPT-4o mini",
Provider: ProviderAzure,
APIModel: "gpt-4o-mini",
CostPer1MIn: OpenAIModels[GPT4oMini].CostPer1MIn,
CostPer1MInCached: OpenAIModels[GPT4oMini].CostPer1MInCached,
CostPer1MOut: OpenAIModels[GPT4oMini].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[GPT4oMini].CostPer1MOutCached,
ContextWindow: OpenAIModels[GPT4oMini].ContextWindow,
DefaultMaxTokens: OpenAIModels[GPT4oMini].DefaultMaxTokens,
ID: AzureGPT4oMini,
Name: "Azure OpenAI GPT-4o mini",
Provider: ProviderAzure,
APIModel: "gpt-4o-mini",
CostPer1MIn: OpenAIModels[GPT4oMini].CostPer1MIn,
CostPer1MInCached: OpenAIModels[GPT4oMini].CostPer1MInCached,
CostPer1MOut: OpenAIModels[GPT4oMini].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[GPT4oMini].CostPer1MOutCached,
ContextWindow: OpenAIModels[GPT4oMini].ContextWindow,
DefaultMaxTokens: OpenAIModels[GPT4oMini].DefaultMaxTokens,
SupportsAttachments: true,
},
AzureO1: {
ID: AzureO1,
Name: "Azure OpenAI O1",
Provider: ProviderAzure,
APIModel: "o1",
CostPer1MIn: OpenAIModels[O1].CostPer1MIn,
CostPer1MInCached: OpenAIModels[O1].CostPer1MInCached,
CostPer1MOut: OpenAIModels[O1].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[O1].CostPer1MOutCached,
ContextWindow: OpenAIModels[O1].ContextWindow,
DefaultMaxTokens: OpenAIModels[O1].DefaultMaxTokens,
CanReason: OpenAIModels[O1].CanReason,
ID: AzureO1,
Name: "Azure OpenAI O1",
Provider: ProviderAzure,
APIModel: "o1",
CostPer1MIn: OpenAIModels[O1].CostPer1MIn,
CostPer1MInCached: OpenAIModels[O1].CostPer1MInCached,
CostPer1MOut: OpenAIModels[O1].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[O1].CostPer1MOutCached,
ContextWindow: OpenAIModels[O1].ContextWindow,
DefaultMaxTokens: OpenAIModels[O1].DefaultMaxTokens,
CanReason: OpenAIModels[O1].CanReason,
SupportsAttachments: true,
},
AzureO1Mini: {
ID: AzureO1Mini,
Name: "Azure OpenAI O1 mini",
Provider: ProviderAzure,
APIModel: "o1-mini",
CostPer1MIn: OpenAIModels[O1Mini].CostPer1MIn,
CostPer1MInCached: OpenAIModels[O1Mini].CostPer1MInCached,
CostPer1MOut: OpenAIModels[O1Mini].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[O1Mini].CostPer1MOutCached,
ContextWindow: OpenAIModels[O1Mini].ContextWindow,
DefaultMaxTokens: OpenAIModels[O1Mini].DefaultMaxTokens,
CanReason: OpenAIModels[O1Mini].CanReason,
ID: AzureO1Mini,
Name: "Azure OpenAI O1 mini",
Provider: ProviderAzure,
APIModel: "o1-mini",
CostPer1MIn: OpenAIModels[O1Mini].CostPer1MIn,
CostPer1MInCached: OpenAIModels[O1Mini].CostPer1MInCached,
CostPer1MOut: OpenAIModels[O1Mini].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[O1Mini].CostPer1MOutCached,
ContextWindow: OpenAIModels[O1Mini].ContextWindow,
DefaultMaxTokens: OpenAIModels[O1Mini].DefaultMaxTokens,
CanReason: OpenAIModels[O1Mini].CanReason,
SupportsAttachments: true,
},
AzureO3: {
ID: AzureO3,
Name: "Azure OpenAI O3",
Provider: ProviderAzure,
APIModel: "o3",
CostPer1MIn: OpenAIModels[O3].CostPer1MIn,
CostPer1MInCached: OpenAIModels[O3].CostPer1MInCached,
CostPer1MOut: OpenAIModels[O3].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[O3].CostPer1MOutCached,
ContextWindow: OpenAIModels[O3].ContextWindow,
DefaultMaxTokens: OpenAIModels[O3].DefaultMaxTokens,
CanReason: OpenAIModels[O3].CanReason,
ID: AzureO3,
Name: "Azure OpenAI O3",
Provider: ProviderAzure,
APIModel: "o3",
CostPer1MIn: OpenAIModels[O3].CostPer1MIn,
CostPer1MInCached: OpenAIModels[O3].CostPer1MInCached,
CostPer1MOut: OpenAIModels[O3].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[O3].CostPer1MOutCached,
ContextWindow: OpenAIModels[O3].ContextWindow,
DefaultMaxTokens: OpenAIModels[O3].DefaultMaxTokens,
CanReason: OpenAIModels[O3].CanReason,
SupportsAttachments: true,
},
AzureO3Mini: {
ID: AzureO3Mini,
Name: "Azure OpenAI O3 mini",
Provider: ProviderAzure,
APIModel: "o3-mini",
CostPer1MIn: OpenAIModels[O3Mini].CostPer1MIn,
CostPer1MInCached: OpenAIModels[O3Mini].CostPer1MInCached,
CostPer1MOut: OpenAIModels[O3Mini].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[O3Mini].CostPer1MOutCached,
ContextWindow: OpenAIModels[O3Mini].ContextWindow,
DefaultMaxTokens: OpenAIModels[O3Mini].DefaultMaxTokens,
CanReason: OpenAIModels[O3Mini].CanReason,
ID: AzureO3Mini,
Name: "Azure OpenAI O3 mini",
Provider: ProviderAzure,
APIModel: "o3-mini",
CostPer1MIn: OpenAIModels[O3Mini].CostPer1MIn,
CostPer1MInCached: OpenAIModels[O3Mini].CostPer1MInCached,
CostPer1MOut: OpenAIModels[O3Mini].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[O3Mini].CostPer1MOutCached,
ContextWindow: OpenAIModels[O3Mini].ContextWindow,
DefaultMaxTokens: OpenAIModels[O3Mini].DefaultMaxTokens,
CanReason: OpenAIModels[O3Mini].CanReason,
SupportsAttachments: false,
},
AzureO4Mini: {
ID: AzureO4Mini,
Name: "Azure OpenAI O4 mini",
Provider: ProviderAzure,
APIModel: "o4-mini",
CostPer1MIn: OpenAIModels[O4Mini].CostPer1MIn,
CostPer1MInCached: OpenAIModels[O4Mini].CostPer1MInCached,
CostPer1MOut: OpenAIModels[O4Mini].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[O4Mini].CostPer1MOutCached,
ContextWindow: OpenAIModels[O4Mini].ContextWindow,
DefaultMaxTokens: OpenAIModels[O4Mini].DefaultMaxTokens,
CanReason: OpenAIModels[O4Mini].CanReason,
ID: AzureO4Mini,
Name: "Azure OpenAI O4 mini",
Provider: ProviderAzure,
APIModel: "o4-mini",
CostPer1MIn: OpenAIModels[O4Mini].CostPer1MIn,
CostPer1MInCached: OpenAIModels[O4Mini].CostPer1MInCached,
CostPer1MOut: OpenAIModels[O4Mini].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[O4Mini].CostPer1MOutCached,
ContextWindow: OpenAIModels[O4Mini].ContextWindow,
DefaultMaxTokens: OpenAIModels[O4Mini].DefaultMaxTokens,
CanReason: OpenAIModels[O4Mini].CanReason,
SupportsAttachments: true,
},
}

View File

@@ -12,52 +12,56 @@ const (
var GeminiModels = map[ModelID]Model{
Gemini25Flash: {
ID: Gemini25Flash,
Name: "Gemini 2.5 Flash",
Provider: ProviderGemini,
APIModel: "gemini-2.5-flash-preview-04-17",
CostPer1MIn: 0.15,
CostPer1MInCached: 0,
CostPer1MOutCached: 0,
CostPer1MOut: 0.60,
ContextWindow: 1000000,
DefaultMaxTokens: 50000,
ID: Gemini25Flash,
Name: "Gemini 2.5 Flash",
Provider: ProviderGemini,
APIModel: "gemini-2.5-flash-preview-04-17",
CostPer1MIn: 0.15,
CostPer1MInCached: 0,
CostPer1MOutCached: 0,
CostPer1MOut: 0.60,
ContextWindow: 1000000,
DefaultMaxTokens: 50000,
SupportsAttachments: true,
},
Gemini25: {
ID: Gemini25,
Name: "Gemini 2.5 Pro",
Provider: ProviderGemini,
APIModel: "gemini-2.5-pro-preview-03-25",
CostPer1MIn: 1.25,
CostPer1MInCached: 0,
CostPer1MOutCached: 0,
CostPer1MOut: 10,
ContextWindow: 1000000,
DefaultMaxTokens: 50000,
ID: Gemini25,
Name: "Gemini 2.5 Pro",
Provider: ProviderGemini,
APIModel: "gemini-2.5-pro-preview-03-25",
CostPer1MIn: 1.25,
CostPer1MInCached: 0,
CostPer1MOutCached: 0,
CostPer1MOut: 10,
ContextWindow: 1000000,
DefaultMaxTokens: 50000,
SupportsAttachments: true,
},
Gemini20Flash: {
ID: Gemini20Flash,
Name: "Gemini 2.0 Flash",
Provider: ProviderGemini,
APIModel: "gemini-2.0-flash",
CostPer1MIn: 0.10,
CostPer1MInCached: 0,
CostPer1MOutCached: 0,
CostPer1MOut: 0.40,
ContextWindow: 1000000,
DefaultMaxTokens: 6000,
ID: Gemini20Flash,
Name: "Gemini 2.0 Flash",
Provider: ProviderGemini,
APIModel: "gemini-2.0-flash",
CostPer1MIn: 0.10,
CostPer1MInCached: 0,
CostPer1MOutCached: 0,
CostPer1MOut: 0.40,
ContextWindow: 1000000,
DefaultMaxTokens: 6000,
SupportsAttachments: true,
},
Gemini20FlashLite: {
ID: Gemini20FlashLite,
Name: "Gemini 2.0 Flash Lite",
Provider: ProviderGemini,
APIModel: "gemini-2.0-flash-lite",
CostPer1MIn: 0.05,
CostPer1MInCached: 0,
CostPer1MOutCached: 0,
CostPer1MOut: 0.30,
ContextWindow: 1000000,
DefaultMaxTokens: 6000,
ID: Gemini20FlashLite,
Name: "Gemini 2.0 Flash Lite",
Provider: ProviderGemini,
APIModel: "gemini-2.0-flash-lite",
CostPer1MIn: 0.05,
CostPer1MInCached: 0,
CostPer1MOutCached: 0,
CostPer1MOut: 0.30,
ContextWindow: 1000000,
DefaultMaxTokens: 6000,
SupportsAttachments: true,
},
}

View File

@@ -28,55 +28,60 @@ var GroqModels = map[ModelID]Model{
ContextWindow: 128_000,
DefaultMaxTokens: 50000,
// for some reason, the groq api doesn't like the reasoningEffort parameter
CanReason: false,
CanReason: false,
SupportsAttachments: false,
},
Llama4Scout: {
ID: Llama4Scout,
Name: "Llama4Scout",
Provider: ProviderGROQ,
APIModel: "meta-llama/llama-4-scout-17b-16e-instruct",
CostPer1MIn: 0.11,
CostPer1MInCached: 0,
CostPer1MOutCached: 0,
CostPer1MOut: 0.34,
ContextWindow: 128_000, // 10M when?
ID: Llama4Scout,
Name: "Llama4Scout",
Provider: ProviderGROQ,
APIModel: "meta-llama/llama-4-scout-17b-16e-instruct",
CostPer1MIn: 0.11,
CostPer1MInCached: 0,
CostPer1MOutCached: 0,
CostPer1MOut: 0.34,
ContextWindow: 128_000, // 10M when?
SupportsAttachments: true,
},
Llama4Maverick: {
ID: Llama4Maverick,
Name: "Llama4Maverick",
Provider: ProviderGROQ,
APIModel: "meta-llama/llama-4-maverick-17b-128e-instruct",
CostPer1MIn: 0.20,
CostPer1MInCached: 0,
CostPer1MOutCached: 0,
CostPer1MOut: 0.20,
ContextWindow: 128_000,
ID: Llama4Maverick,
Name: "Llama4Maverick",
Provider: ProviderGROQ,
APIModel: "meta-llama/llama-4-maverick-17b-128e-instruct",
CostPer1MIn: 0.20,
CostPer1MInCached: 0,
CostPer1MOutCached: 0,
CostPer1MOut: 0.20,
ContextWindow: 128_000,
SupportsAttachments: true,
},
Llama3_3_70BVersatile: {
ID: Llama3_3_70BVersatile,
Name: "Llama3_3_70BVersatile",
Provider: ProviderGROQ,
APIModel: "llama-3.3-70b-versatile",
CostPer1MIn: 0.59,
CostPer1MInCached: 0,
CostPer1MOutCached: 0,
CostPer1MOut: 0.79,
ContextWindow: 128_000,
ID: Llama3_3_70BVersatile,
Name: "Llama3_3_70BVersatile",
Provider: ProviderGROQ,
APIModel: "llama-3.3-70b-versatile",
CostPer1MIn: 0.59,
CostPer1MInCached: 0,
CostPer1MOutCached: 0,
CostPer1MOut: 0.79,
ContextWindow: 128_000,
SupportsAttachments: false,
},
DeepseekR1DistillLlama70b: {
ID: DeepseekR1DistillLlama70b,
Name: "DeepseekR1DistillLlama70b",
Provider: ProviderGROQ,
APIModel: "deepseek-r1-distill-llama-70b",
CostPer1MIn: 0.75,
CostPer1MInCached: 0,
CostPer1MOutCached: 0,
CostPer1MOut: 0.99,
ContextWindow: 128_000,
CanReason: true,
ID: DeepseekR1DistillLlama70b,
Name: "DeepseekR1DistillLlama70b",
Provider: ProviderGROQ,
APIModel: "deepseek-r1-distill-llama-70b",
CostPer1MIn: 0.75,
CostPer1MInCached: 0,
CostPer1MOutCached: 0,
CostPer1MOut: 0.99,
ContextWindow: 128_000,
CanReason: true,
SupportsAttachments: false,
},
}

View File

@@ -8,17 +8,18 @@ type (
)
type Model struct {
ID ModelID `json:"id"`
Name string `json:"name"`
Provider ModelProvider `json:"provider"`
APIModel string `json:"api_model"`
CostPer1MIn float64 `json:"cost_per_1m_in"`
CostPer1MOut float64 `json:"cost_per_1m_out"`
CostPer1MInCached float64 `json:"cost_per_1m_in_cached"`
CostPer1MOutCached float64 `json:"cost_per_1m_out_cached"`
ContextWindow int64 `json:"context_window"`
DefaultMaxTokens int64 `json:"default_max_tokens"`
CanReason bool `json:"can_reason"`
ID ModelID `json:"id"`
Name string `json:"name"`
Provider ModelProvider `json:"provider"`
APIModel string `json:"api_model"`
CostPer1MIn float64 `json:"cost_per_1m_in"`
CostPer1MOut float64 `json:"cost_per_1m_out"`
CostPer1MInCached float64 `json:"cost_per_1m_in_cached"`
CostPer1MOutCached float64 `json:"cost_per_1m_out_cached"`
ContextWindow int64 `json:"context_window"`
DefaultMaxTokens int64 `json:"default_max_tokens"`
CanReason bool `json:"can_reason"`
SupportsAttachments bool `json:"supports_attachments"`
}
// Model IDs
@@ -35,11 +36,13 @@ const (
// Providers in order of popularity
var ProviderPopularity = map[ModelProvider]int{
ProviderAnthropic: 1,
ProviderOpenAI: 2,
ProviderGemini: 3,
ProviderGROQ: 4,
ProviderBedrock: 5,
ProviderAnthropic: 1,
ProviderOpenAI: 2,
ProviderGemini: 3,
ProviderGROQ: 4,
ProviderOpenRouter: 5,
ProviderBedrock: 6,
ProviderAzure: 7,
}
var SupportedModels = map[ModelID]Model{
@@ -87,4 +90,5 @@ func init() {
maps.Copy(SupportedModels, GroqModels)
maps.Copy(SupportedModels, AzureModels)
maps.Copy(SupportedModels, OpenRouterModels)
maps.Copy(SupportedModels, XAIModels)
}

View File

@@ -19,151 +19,163 @@ const (
var OpenAIModels = map[ModelID]Model{
GPT41: {
ID: GPT41,
Name: "GPT 4.1",
Provider: ProviderOpenAI,
APIModel: "gpt-4.1",
CostPer1MIn: 2.00,
CostPer1MInCached: 0.50,
CostPer1MOutCached: 0.0,
CostPer1MOut: 8.00,
ContextWindow: 1_047_576,
DefaultMaxTokens: 20000,
ID: GPT41,
Name: "GPT 4.1",
Provider: ProviderOpenAI,
APIModel: "gpt-4.1",
CostPer1MIn: 2.00,
CostPer1MInCached: 0.50,
CostPer1MOutCached: 0.0,
CostPer1MOut: 8.00,
ContextWindow: 1_047_576,
DefaultMaxTokens: 20000,
SupportsAttachments: true,
},
GPT41Mini: {
ID: GPT41Mini,
Name: "GPT 4.1 mini",
Provider: ProviderOpenAI,
APIModel: "gpt-4.1",
CostPer1MIn: 0.40,
CostPer1MInCached: 0.10,
CostPer1MOutCached: 0.0,
CostPer1MOut: 1.60,
ContextWindow: 200_000,
DefaultMaxTokens: 20000,
ID: GPT41Mini,
Name: "GPT 4.1 mini",
Provider: ProviderOpenAI,
APIModel: "gpt-4.1",
CostPer1MIn: 0.40,
CostPer1MInCached: 0.10,
CostPer1MOutCached: 0.0,
CostPer1MOut: 1.60,
ContextWindow: 200_000,
DefaultMaxTokens: 20000,
SupportsAttachments: true,
},
GPT41Nano: {
ID: GPT41Nano,
Name: "GPT 4.1 nano",
Provider: ProviderOpenAI,
APIModel: "gpt-4.1-nano",
CostPer1MIn: 0.10,
CostPer1MInCached: 0.025,
CostPer1MOutCached: 0.0,
CostPer1MOut: 0.40,
ContextWindow: 1_047_576,
DefaultMaxTokens: 20000,
ID: GPT41Nano,
Name: "GPT 4.1 nano",
Provider: ProviderOpenAI,
APIModel: "gpt-4.1-nano",
CostPer1MIn: 0.10,
CostPer1MInCached: 0.025,
CostPer1MOutCached: 0.0,
CostPer1MOut: 0.40,
ContextWindow: 1_047_576,
DefaultMaxTokens: 20000,
SupportsAttachments: true,
},
GPT45Preview: {
ID: GPT45Preview,
Name: "GPT 4.5 preview",
Provider: ProviderOpenAI,
APIModel: "gpt-4.5-preview",
CostPer1MIn: 75.00,
CostPer1MInCached: 37.50,
CostPer1MOutCached: 0.0,
CostPer1MOut: 150.00,
ContextWindow: 128_000,
DefaultMaxTokens: 15000,
ID: GPT45Preview,
Name: "GPT 4.5 preview",
Provider: ProviderOpenAI,
APIModel: "gpt-4.5-preview",
CostPer1MIn: 75.00,
CostPer1MInCached: 37.50,
CostPer1MOutCached: 0.0,
CostPer1MOut: 150.00,
ContextWindow: 128_000,
DefaultMaxTokens: 15000,
SupportsAttachments: true,
},
GPT4o: {
ID: GPT4o,
Name: "GPT 4o",
Provider: ProviderOpenAI,
APIModel: "gpt-4o",
CostPer1MIn: 2.50,
CostPer1MInCached: 1.25,
CostPer1MOutCached: 0.0,
CostPer1MOut: 10.00,
ContextWindow: 128_000,
DefaultMaxTokens: 4096,
ID: GPT4o,
Name: "GPT 4o",
Provider: ProviderOpenAI,
APIModel: "gpt-4o",
CostPer1MIn: 2.50,
CostPer1MInCached: 1.25,
CostPer1MOutCached: 0.0,
CostPer1MOut: 10.00,
ContextWindow: 128_000,
DefaultMaxTokens: 4096,
SupportsAttachments: true,
},
GPT4oMini: {
ID: GPT4oMini,
Name: "GPT 4o mini",
Provider: ProviderOpenAI,
APIModel: "gpt-4o-mini",
CostPer1MIn: 0.15,
CostPer1MInCached: 0.075,
CostPer1MOutCached: 0.0,
CostPer1MOut: 0.60,
ContextWindow: 128_000,
ID: GPT4oMini,
Name: "GPT 4o mini",
Provider: ProviderOpenAI,
APIModel: "gpt-4o-mini",
CostPer1MIn: 0.15,
CostPer1MInCached: 0.075,
CostPer1MOutCached: 0.0,
CostPer1MOut: 0.60,
ContextWindow: 128_000,
SupportsAttachments: true,
},
O1: {
ID: O1,
Name: "O1",
Provider: ProviderOpenAI,
APIModel: "o1",
CostPer1MIn: 15.00,
CostPer1MInCached: 7.50,
CostPer1MOutCached: 0.0,
CostPer1MOut: 60.00,
ContextWindow: 200_000,
DefaultMaxTokens: 50000,
CanReason: true,
ID: O1,
Name: "O1",
Provider: ProviderOpenAI,
APIModel: "o1",
CostPer1MIn: 15.00,
CostPer1MInCached: 7.50,
CostPer1MOutCached: 0.0,
CostPer1MOut: 60.00,
ContextWindow: 200_000,
DefaultMaxTokens: 50000,
CanReason: true,
SupportsAttachments: true,
},
O1Pro: {
ID: O1Pro,
Name: "o1 pro",
Provider: ProviderOpenAI,
APIModel: "o1-pro",
CostPer1MIn: 150.00,
CostPer1MInCached: 0.0,
CostPer1MOutCached: 0.0,
CostPer1MOut: 600.00,
ContextWindow: 200_000,
DefaultMaxTokens: 50000,
CanReason: true,
ID: O1Pro,
Name: "o1 pro",
Provider: ProviderOpenAI,
APIModel: "o1-pro",
CostPer1MIn: 150.00,
CostPer1MInCached: 0.0,
CostPer1MOutCached: 0.0,
CostPer1MOut: 600.00,
ContextWindow: 200_000,
DefaultMaxTokens: 50000,
CanReason: true,
SupportsAttachments: true,
},
O1Mini: {
ID: O1Mini,
Name: "o1 mini",
Provider: ProviderOpenAI,
APIModel: "o1-mini",
CostPer1MIn: 1.10,
CostPer1MInCached: 0.55,
CostPer1MOutCached: 0.0,
CostPer1MOut: 4.40,
ContextWindow: 128_000,
DefaultMaxTokens: 50000,
CanReason: true,
ID: O1Mini,
Name: "o1 mini",
Provider: ProviderOpenAI,
APIModel: "o1-mini",
CostPer1MIn: 1.10,
CostPer1MInCached: 0.55,
CostPer1MOutCached: 0.0,
CostPer1MOut: 4.40,
ContextWindow: 128_000,
DefaultMaxTokens: 50000,
CanReason: true,
SupportsAttachments: true,
},
O3: {
ID: O3,
Name: "o3",
Provider: ProviderOpenAI,
APIModel: "o3",
CostPer1MIn: 10.00,
CostPer1MInCached: 2.50,
CostPer1MOutCached: 0.0,
CostPer1MOut: 40.00,
ContextWindow: 200_000,
CanReason: true,
ID: O3,
Name: "o3",
Provider: ProviderOpenAI,
APIModel: "o3",
CostPer1MIn: 10.00,
CostPer1MInCached: 2.50,
CostPer1MOutCached: 0.0,
CostPer1MOut: 40.00,
ContextWindow: 200_000,
CanReason: true,
SupportsAttachments: true,
},
O3Mini: {
ID: O3Mini,
Name: "o3 mini",
Provider: ProviderOpenAI,
APIModel: "o3-mini",
CostPer1MIn: 1.10,
CostPer1MInCached: 0.55,
CostPer1MOutCached: 0.0,
CostPer1MOut: 4.40,
ContextWindow: 200_000,
DefaultMaxTokens: 50000,
CanReason: true,
ID: O3Mini,
Name: "o3 mini",
Provider: ProviderOpenAI,
APIModel: "o3-mini",
CostPer1MIn: 1.10,
CostPer1MInCached: 0.55,
CostPer1MOutCached: 0.0,
CostPer1MOut: 4.40,
ContextWindow: 200_000,
DefaultMaxTokens: 50000,
CanReason: true,
SupportsAttachments: false,
},
O4Mini: {
ID: O4Mini,
Name: "o4 mini",
Provider: ProviderOpenAI,
APIModel: "o4-mini",
CostPer1MIn: 1.10,
CostPer1MInCached: 0.275,
CostPer1MOutCached: 0.0,
CostPer1MOut: 4.40,
ContextWindow: 128_000,
DefaultMaxTokens: 50000,
CanReason: true,
ID: O4Mini,
Name: "o4 mini",
Provider: ProviderOpenAI,
APIModel: "o4-mini",
CostPer1MIn: 1.10,
CostPer1MInCached: 0.275,
CostPer1MOutCached: 0.0,
CostPer1MOut: 4.40,
ContextWindow: 128_000,
DefaultMaxTokens: 50000,
CanReason: true,
SupportsAttachments: true,
},
}

View File

@@ -0,0 +1,61 @@
package models
const (
ProviderXAI ModelProvider = "xai"
XAIGrok3Beta ModelID = "grok-3-beta"
XAIGrok3MiniBeta ModelID = "grok-3-mini-beta"
XAIGrok3FastBeta ModelID = "grok-3-fast-beta"
XAiGrok3MiniFastBeta ModelID = "grok-3-mini-fast-beta"
)
var XAIModels = map[ModelID]Model{
XAIGrok3Beta: {
ID: XAIGrok3Beta,
Name: "Grok3 Beta",
Provider: ProviderXAI,
APIModel: "grok-3-beta",
CostPer1MIn: 3.0,
CostPer1MInCached: 0,
CostPer1MOut: 15,
CostPer1MOutCached: 0,
ContextWindow: 131_072,
DefaultMaxTokens: 20_000,
},
XAIGrok3MiniBeta: {
ID: XAIGrok3MiniBeta,
Name: "Grok3 Mini Beta",
Provider: ProviderXAI,
APIModel: "grok-3-mini-beta",
CostPer1MIn: 0.3,
CostPer1MInCached: 0,
CostPer1MOut: 0.5,
CostPer1MOutCached: 0,
ContextWindow: 131_072,
DefaultMaxTokens: 20_000,
},
XAIGrok3FastBeta: {
ID: XAIGrok3FastBeta,
Name: "Grok3 Fast Beta",
Provider: ProviderXAI,
APIModel: "grok-3-fast-beta",
CostPer1MIn: 5,
CostPer1MInCached: 0,
CostPer1MOut: 25,
CostPer1MOutCached: 0,
ContextWindow: 131_072,
DefaultMaxTokens: 20_000,
},
XAiGrok3MiniFastBeta: {
ID: XAiGrok3MiniFastBeta,
Name: "Grok3 Mini Fast Beta",
Provider: ProviderXAI,
APIModel: "grok-3-mini-fast-beta",
CostPer1MIn: 0.6,
CostPer1MInCached: 0,
CostPer1MOut: 4.0,
CostPer1MOutCached: 0,
ContextWindow: 131_072,
DefaultMaxTokens: 20_000,
},
}

View File

@@ -0,0 +1,57 @@
package prompt
import (
"fmt"
"os"
"path/filepath"
"testing"
"github.com/opencode-ai/opencode/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetContextFromPaths(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
_, err := config.Load(tmpDir, false)
if err != nil {
t.Fatalf("Failed to load config: %v", err)
}
cfg := config.Get()
cfg.WorkingDir = tmpDir
cfg.ContextPaths = []string{
"file.txt",
"directory/",
}
testFiles := []string{
"file.txt",
"directory/file_a.txt",
"directory/file_b.txt",
"directory/file_c.txt",
}
createTestFiles(t, tmpDir, testFiles)
context := getContextFromPaths()
expectedContext := fmt.Sprintf("# From:%s/file.txt\nfile.txt: test content\n# From:%s/directory/file_a.txt\ndirectory/file_a.txt: test content\n# From:%s/directory/file_b.txt\ndirectory/file_b.txt: test content\n# From:%s/directory/file_c.txt\ndirectory/file_c.txt: test content", tmpDir, tmpDir, tmpDir, tmpDir)
assert.Equal(t, expectedContext, context)
}
func createTestFiles(t *testing.T, tmpDir string, testFiles []string) {
t.Helper()
for _, path := range testFiles {
fullPath := filepath.Join(tmpDir, path)
if path[len(path)-1] == '/' {
err := os.MkdirAll(fullPath, 0755)
require.NoError(t, err)
} else {
dir := filepath.Dir(fullPath)
err := os.MkdirAll(dir, 0755)
require.NoError(t, err)
err = os.WriteFile(fullPath, []byte(path+": test content"), 0644)
require.NoError(t, err)
}
}
}

View File

@@ -8,5 +8,6 @@ func TitlePrompt(_ models.ModelProvider) string {
- 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`
- the entire text you return will be used as the title
- never return anything that is more than one sentence (one line) long`
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/anthropics/anthropic-sdk-go/bedrock"
"github.com/anthropics/anthropic-sdk-go/option"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/llm/models"
"github.com/opencode-ai/opencode/internal/llm/tools"
"github.com/opencode-ai/opencode/internal/logging"
"github.com/opencode-ai/opencode/internal/message"
@@ -70,7 +71,14 @@ func (a *anthropicClient) convertMessages(messages []message.Message) (anthropic
Type: "ephemeral",
}
}
anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(content))
var contentBlocks []anthropic.ContentBlockParamUnion
contentBlocks = append(contentBlocks, content)
for _, binaryContent := range msg.BinaryContent() {
base64Image := binaryContent.String(models.ProviderAnthropic)
imageBlock := anthropic.NewImageBlockBase64(binaryContent.MIMEType, base64Image)
contentBlocks = append(contentBlocks, imageBlock)
}
anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(contentBlocks...))
case message.Assistant:
blocks := []anthropic.ContentBlockParamUnion{}
@@ -196,9 +204,10 @@ func (a *anthropicClient) send(ctx context.Context, messages []message.Message,
preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
cfg := config.Get()
if cfg.Debug {
// jsonData, _ := json.Marshal(preparedMessages)
// logging.Debug("Prepared messages", "messages", string(jsonData))
jsonData, _ := json.Marshal(preparedMessages)
logging.Debug("Prepared messages", "messages", string(jsonData))
}
attempts := 0
for {
attempts++
@@ -208,6 +217,7 @@ func (a *anthropicClient) send(ctx context.Context, messages []message.Message,
)
// If there is an error we are going to see if we can retry the call
if err != nil {
logging.Error("Error in Anthropic API call", "error", err)
retry, after, retryErr := a.shouldRetry(attempts, err)
if retryErr != nil {
return nil, retryErr

View File

@@ -55,7 +55,7 @@ func newBedrockClient(opts providerClientOptions) BedrockClient {
if strings.Contains(string(opts.model.APIModel), "anthropic") {
// Create Anthropic client with Bedrock configuration
anthropicOpts := opts
anthropicOpts.anthropicOptions = append(anthropicOpts.anthropicOptions,
anthropicOpts.anthropicOptions = append(anthropicOpts.anthropicOptions,
WithAnthropicBedrock(true),
WithAnthropicDisableCache(),
)
@@ -84,7 +84,7 @@ func (b *bedrockClient) send(ctx context.Context, messages []message.Message, to
func (b *bedrockClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
eventChan := make(chan ProviderEvent)
if b.childProvider == nil {
go func() {
eventChan <- ProviderEvent{
@@ -95,6 +95,7 @@ func (b *bedrockClient) stream(ctx context.Context, messages []message.Message,
}()
return eventChan
}
return b.childProvider.stream(ctx, messages, tools)
}
}

View File

@@ -9,14 +9,12 @@ import (
"strings"
"time"
"github.com/google/generative-ai-go/genai"
"github.com/google/uuid"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/llm/tools"
"github.com/opencode-ai/opencode/internal/logging"
"github.com/opencode-ai/opencode/internal/message"
"google.golang.org/api/iterator"
"google.golang.org/api/option"
"google.golang.org/genai"
)
type geminiOptions struct {
@@ -39,7 +37,7 @@ func newGeminiClient(opts providerClientOptions) GeminiClient {
o(&geminiOpts)
}
client, err := genai.NewClient(context.Background(), option.WithAPIKey(opts.apiKey))
client, err := genai.NewClient(context.Background(), &genai.ClientConfig{APIKey: opts.apiKey, Backend: genai.BackendGeminiAPI})
if err != nil {
logging.Error("Failed to create Gemini client", "error", err)
return nil
@@ -57,27 +55,37 @@ func (g *geminiClient) convertMessages(messages []message.Message) []*genai.Cont
for _, msg := range messages {
switch msg.Role {
case message.User:
var parts []*genai.Part
parts = append(parts, &genai.Part{Text: msg.Content().String()})
for _, binaryContent := range msg.BinaryContent() {
imageFormat := strings.Split(binaryContent.MIMEType, "/")
parts = append(parts, &genai.Part{InlineData: &genai.Blob{
MIMEType: imageFormat[1],
Data: binaryContent.Data,
}})
}
history = append(history, &genai.Content{
Parts: []genai.Part{genai.Text(msg.Content().String())},
Parts: parts,
Role: "user",
})
case message.Assistant:
content := &genai.Content{
Role: "model",
Parts: []genai.Part{},
Parts: []*genai.Part{},
}
if msg.Content().String() != "" {
content.Parts = append(content.Parts, genai.Text(msg.Content().String()))
content.Parts = append(content.Parts, &genai.Part{Text: msg.Content().String()})
}
if len(msg.ToolCalls()) > 0 {
for _, call := range msg.ToolCalls() {
args, _ := parseJsonToMap(call.Input)
content.Parts = append(content.Parts, genai.FunctionCall{
Name: call.Name,
Args: args,
content.Parts = append(content.Parts, &genai.Part{
FunctionCall: &genai.FunctionCall{
Name: call.Name,
Args: args,
},
})
}
}
@@ -105,10 +113,14 @@ func (g *geminiClient) convertMessages(messages []message.Message) []*genai.Cont
}
history = append(history, &genai.Content{
Parts: []genai.Part{genai.FunctionResponse{
Name: toolCall.Name,
Response: response,
}},
Parts: []*genai.Part{
{
FunctionResponse: &genai.FunctionResponse{
Name: toolCall.Name,
Response: response,
},
},
},
Role: "function",
})
}
@@ -152,18 +164,6 @@ func (g *geminiClient) finishReason(reason genai.FinishReason) message.FinishRea
}
func (g *geminiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
model := g.client.GenerativeModel(g.providerOptions.model.APIModel)
model.SetMaxOutputTokens(int32(g.providerOptions.maxTokens))
model.SystemInstruction = &genai.Content{
Parts: []genai.Part{
genai.Text(g.providerOptions.systemMessage),
},
}
// Convert tools
if len(tools) > 0 {
model.Tools = g.convertTools(tools)
}
// Convert messages
geminiMessages := g.convertMessages(messages)
@@ -173,16 +173,26 @@ func (g *geminiClient) send(ctx context.Context, messages []message.Message, too
logging.Debug("Prepared messages", "messages", string(jsonData))
}
history := geminiMessages[:len(geminiMessages)-1] // All but last message
lastMsg := geminiMessages[len(geminiMessages)-1]
chat, _ := g.client.Chats.Create(ctx, g.providerOptions.model.APIModel, &genai.GenerateContentConfig{
MaxOutputTokens: int32(g.providerOptions.maxTokens),
SystemInstruction: &genai.Content{
Parts: []*genai.Part{{Text: g.providerOptions.systemMessage}},
},
Tools: g.convertTools(tools),
}, history)
attempts := 0
for {
attempts++
var toolCalls []message.ToolCall
chat := model.StartChat()
chat.History = geminiMessages[:len(geminiMessages)-1] // All but last message
lastMsg := geminiMessages[len(geminiMessages)-1]
resp, err := chat.SendMessage(ctx, lastMsg.Parts...)
var lastMsgParts []genai.Part
for _, part := range lastMsg.Parts {
lastMsgParts = append(lastMsgParts, *part)
}
resp, err := chat.SendMessage(ctx, lastMsgParts...)
// If there is an error we are going to see if we can retry the call
if err != nil {
retry, after, retryErr := g.shouldRetry(attempts, err)
@@ -205,15 +215,15 @@ func (g *geminiClient) send(ctx context.Context, messages []message.Message, too
if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
for _, part := range resp.Candidates[0].Content.Parts {
switch p := part.(type) {
case genai.Text:
content = string(p)
case genai.FunctionCall:
switch {
case part.Text != "":
content = string(part.Text)
case part.FunctionCall != nil:
id := "call_" + uuid.New().String()
args, _ := json.Marshal(p.Args)
args, _ := json.Marshal(part.FunctionCall.Args)
toolCalls = append(toolCalls, message.ToolCall{
ID: id,
Name: p.Name,
Name: part.FunctionCall.Name,
Input: string(args),
Type: "function",
Finished: true,
@@ -239,18 +249,6 @@ func (g *geminiClient) send(ctx context.Context, messages []message.Message, too
}
func (g *geminiClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
model := g.client.GenerativeModel(g.providerOptions.model.APIModel)
model.SetMaxOutputTokens(int32(g.providerOptions.maxTokens))
model.SystemInstruction = &genai.Content{
Parts: []genai.Part{
genai.Text(g.providerOptions.systemMessage),
},
}
// Convert tools
if len(tools) > 0 {
model.Tools = g.convertTools(tools)
}
// Convert messages
geminiMessages := g.convertMessages(messages)
@@ -260,6 +258,16 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
logging.Debug("Prepared messages", "messages", string(jsonData))
}
history := geminiMessages[:len(geminiMessages)-1] // All but last message
lastMsg := geminiMessages[len(geminiMessages)-1]
chat, _ := g.client.Chats.Create(ctx, g.providerOptions.model.APIModel, &genai.GenerateContentConfig{
MaxOutputTokens: int32(g.providerOptions.maxTokens),
SystemInstruction: &genai.Content{
Parts: []*genai.Part{{Text: g.providerOptions.systemMessage}},
},
Tools: g.convertTools(tools),
}, history)
attempts := 0
eventChan := make(chan ProviderEvent)
@@ -268,11 +276,6 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
for {
attempts++
chat := model.StartChat()
chat.History = geminiMessages[:len(geminiMessages)-1]
lastMsg := geminiMessages[len(geminiMessages)-1]
iter := chat.SendMessageStream(ctx, lastMsg.Parts...)
currentContent := ""
toolCalls := []message.ToolCall{}
@@ -280,11 +283,12 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
eventChan <- ProviderEvent{Type: EventContentStart}
for {
resp, err := iter.Next()
if err == iterator.Done {
break
}
var lastMsgParts []genai.Part
for _, part := range lastMsg.Parts {
lastMsgParts = append(lastMsgParts, *part)
}
for resp, err := range chat.SendMessageStream(ctx, lastMsgParts...) {
if err != nil {
retry, after, retryErr := g.shouldRetry(attempts, err)
if retryErr != nil {
@@ -313,9 +317,9 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
for _, part := range resp.Candidates[0].Content.Parts {
switch p := part.(type) {
case genai.Text:
delta := string(p)
switch {
case part.Text != "":
delta := string(part.Text)
if delta != "" {
eventChan <- ProviderEvent{
Type: EventContentDelta,
@@ -323,12 +327,12 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
}
currentContent += delta
}
case genai.FunctionCall:
case part.FunctionCall != nil:
id := "call_" + uuid.New().String()
args, _ := json.Marshal(p.Args)
args, _ := json.Marshal(part.FunctionCall.Args)
newCall := message.ToolCall{
ID: id,
Name: p.Name,
Name: part.FunctionCall.Name,
Input: string(args),
Type: "function",
Finished: true,
@@ -416,12 +420,12 @@ func (g *geminiClient) toolCalls(resp *genai.GenerateContentResponse) []message.
if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
for _, part := range resp.Candidates[0].Content.Parts {
if funcCall, ok := part.(genai.FunctionCall); ok {
if part.FunctionCall != nil {
id := "call_" + uuid.New().String()
args, _ := json.Marshal(funcCall.Args)
args, _ := json.Marshal(part.FunctionCall.Args)
toolCalls = append(toolCalls, message.ToolCall{
ID: id,
Name: funcCall.Name,
Name: part.FunctionCall.Name,
Input: string(args),
Type: "function",
})

View File

@@ -12,6 +12,7 @@ import (
"github.com/openai/openai-go/option"
"github.com/openai/openai-go/shared"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/llm/models"
"github.com/opencode-ai/opencode/internal/llm/tools"
"github.com/opencode-ai/opencode/internal/logging"
"github.com/opencode-ai/opencode/internal/message"
@@ -71,7 +72,17 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
for _, msg := range messages {
switch msg.Role {
case message.User:
openaiMessages = append(openaiMessages, openai.UserMessage(msg.Content().String()))
var content []openai.ChatCompletionContentPartUnionParam
textBlock := openai.ChatCompletionContentPartTextParam{Text: msg.Content().String()}
content = append(content, openai.ChatCompletionContentPartUnionParam{OfText: &textBlock})
for _, binaryContent := range msg.BinaryContent() {
imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: binaryContent.String(models.ProviderOpenAI)}
imageBlock := openai.ChatCompletionContentPartImageParam{ImageURL: imageURL}
content = append(content, openai.ChatCompletionContentPartUnionParam{OfImageURL: &imageBlock})
}
openaiMessages = append(openaiMessages, openai.UserMessage(content))
case message.Assistant:
assistantMsg := openai.ChatCompletionAssistantMessageParam{
@@ -258,15 +269,6 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t
chunk := openaiStream.Current()
acc.AddChunk(chunk)
if tool, ok := acc.JustFinishedToolCall(); ok {
toolCalls = append(toolCalls, message.ToolCall{
ID: tool.Id,
Name: tool.Name,
Input: tool.Arguments,
Type: "function",
})
}
for _, choice := range chunk.Choices {
if choice.Delta.Content != "" {
eventChan <- ProviderEvent{
@@ -282,7 +284,9 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t
if err == nil || errors.Is(err, io.EOF) {
// Stream completed successfully
finishReason := o.finishReason(string(acc.ChatCompletion.Choices[0].FinishReason))
if len(acc.ChatCompletion.Choices[0].Message.ToolCalls) > 0 {
toolCalls = append(toolCalls, o.toolCalls(acc.ChatCompletion)...)
}
if len(toolCalls) > 0 {
finishReason = message.FinishReasonToolUse
}

View File

@@ -132,6 +132,15 @@ func NewProvider(providerName models.ModelProvider, opts ...ProviderClientOption
options: clientOptions,
client: newOpenAIClient(clientOptions),
}, nil
case models.ProviderXAI:
clientOptions.openaiOptions = append(clientOptions.openaiOptions,
WithOpenAIBaseURL("https://api.x.ai/v1"),
)
return &baseProvider[OpenAIClient]{
options: clientOptions,
client: newOpenAIClient(clientOptions),
}, nil
case models.ProviderMock:
// TODO: implement mock client for test
panic("not implemented")

View File

@@ -0,0 +1,8 @@
package message
type Attachment struct {
FilePath string
FileName string
MimeType string
Content []byte
}

View File

@@ -66,13 +66,17 @@ func (iuc ImageURLContent) String() string {
func (ImageURLContent) isPart() {}
type BinaryContent struct {
Path string
MIMEType string
Data []byte
}
func (bc BinaryContent) String() string {
func (bc BinaryContent) String(provider models.ModelProvider) string {
base64Encoded := base64.StdEncoding.EncodeToString(bc.Data)
return "data:" + bc.MIMEType + ";base64," + base64Encoded
if provider == models.ProviderOpenAI {
return "data:" + bc.MIMEType + ";base64," + base64Encoded
}
return base64Encoded
}
func (BinaryContent) isPart() {}
@@ -110,7 +114,6 @@ type Message struct {
SessionID string
Parts []ContentPart
Model models.ModelID
CreatedAt int64
UpdatedAt int64
}

View File

@@ -64,7 +64,6 @@ func (s *service) Create(ctx context.Context, sessionID string, params CreateMes
if err != nil {
return Message{}, err
}
dbMessage, err := s.q.CreateMessage(ctx, db.CreateMessageParams{
ID: uuid.New().String(),
SessionID: sessionID,

View File

@@ -7,13 +7,16 @@ import (
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/message"
"github.com/opencode-ai/opencode/internal/session"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/version"
)
type SendMsg struct {
Text string
Text string
Attachments []message.Attachment
}
type SessionSelectedMsg = session.Session
@@ -22,12 +25,29 @@ type SessionClearedMsg struct{}
type EditorFocusMsg bool
func header(width int) string {
return lipgloss.JoinVertical(
lipgloss.Top,
logo(width),
repo(width),
"",
cwd(width),
)
}
func lspsConfigured(width int) string {
cfg := config.Get()
title := "LSP Configuration"
title = ansi.Truncate(title, width, "…")
lsps := styles.BaseStyle.Width(width).Foreground(styles.PrimaryColor).Bold(true).Render(title)
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
lsps := baseStyle.
Width(width).
Foreground(t.Primary()).
Bold(true).
Render(title)
// Get LSP names and sort them for consistent ordering
var lspNames []string
@@ -39,16 +59,19 @@ func lspsConfigured(width int) string {
var lspViews []string
for _, name := range lspNames {
lsp := cfg.LSP[name]
lspName := styles.BaseStyle.Foreground(styles.Forground).Render(
fmt.Sprintf("• %s", name),
)
lspName := baseStyle.
Foreground(t.Text()).
Render(fmt.Sprintf("• %s", name))
cmd := lsp.Command
cmd = ansi.Truncate(cmd, width-lipgloss.Width(lspName)-3, "…")
lspPath := styles.BaseStyle.Foreground(styles.ForgroundDim).Render(
fmt.Sprintf(" (%s)", cmd),
)
lspPath := baseStyle.
Foreground(t.TextMuted()).
Render(fmt.Sprintf(" (%s)", cmd))
lspViews = append(lspViews,
styles.BaseStyle.
baseStyle.
Width(width).
Render(
lipgloss.JoinHorizontal(
@@ -59,7 +82,8 @@ func lspsConfigured(width int) string {
),
)
}
return styles.BaseStyle.
return baseStyle.
Width(width).
Render(
lipgloss.JoinVertical(
@@ -75,10 +99,14 @@ func lspsConfigured(width int) string {
func logo(width int) string {
logo := fmt.Sprintf("%s %s", styles.OpenCodeIcon, "OpenCode")
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
version := styles.BaseStyle.Foreground(styles.ForgroundDim).Render(version.Version)
versionText := baseStyle.
Foreground(t.TextMuted()).
Render(version.Version)
return styles.BaseStyle.
return baseStyle.
Bold(true).
Width(width).
Render(
@@ -86,34 +114,28 @@ func logo(width int) string {
lipgloss.Left,
logo,
" ",
version,
versionText,
),
)
}
func repo(width int) string {
repo := "https://github.com/opencode-ai/opencode"
return styles.BaseStyle.
Foreground(styles.ForgroundDim).
t := theme.CurrentTheme()
return styles.BaseStyle().
Foreground(t.TextMuted()).
Width(width).
Render(repo)
}
func cwd(width int) string {
cwd := fmt.Sprintf("cwd: %s", config.WorkingDirectory())
return styles.BaseStyle.
Foreground(styles.ForgroundDim).
t := theme.CurrentTheme()
return styles.BaseStyle().
Foreground(t.TextMuted()).
Width(width).
Render(cwd)
}
func header(width int) string {
header := lipgloss.JoinVertical(
lipgloss.Top,
logo(width),
repo(width),
"",
cwd(width),
)
return header
}

View File

@@ -1,24 +1,35 @@
package chat
import (
"fmt"
"os"
"os/exec"
"slices"
"unicode"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/opencode-ai/opencode/internal/app"
"github.com/opencode-ai/opencode/internal/logging"
"github.com/opencode-ai/opencode/internal/message"
"github.com/opencode-ai/opencode/internal/session"
"github.com/opencode-ai/opencode/internal/tui/components/dialog"
"github.com/opencode-ai/opencode/internal/tui/layout"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/tui/util"
)
type editorCmp struct {
app *app.App
session session.Session
textarea textarea.Model
width int
height int
app *app.App
session session.Session
textarea textarea.Model
attachments []message.Attachment
deleteMode bool
}
type EditorKeyMaps struct {
@@ -31,6 +42,11 @@ type bluredEditorKeyMaps struct {
Focus key.Binding
OpenEditor key.Binding
}
type DeleteAttachmentKeyMaps struct {
AttachmentDeleteMode key.Binding
Escape key.Binding
DeleteAllAttachments key.Binding
}
var editorMaps = EditorKeyMaps{
Send: key.NewBinding(
@@ -43,7 +59,26 @@ var editorMaps = EditorKeyMaps{
),
}
func openEditor() tea.Cmd {
var DeleteKeyMaps = DeleteAttachmentKeyMaps{
AttachmentDeleteMode: key.NewBinding(
key.WithKeys("ctrl+r"),
key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
),
Escape: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "cancel delete mode"),
),
DeleteAllAttachments: key.NewBinding(
key.WithKeys("r"),
key.WithHelp("ctrl+r+r", "delete all attchments"),
),
}
const (
maxAttachments = 5
)
func (m *editorCmp) openEditor() tea.Cmd {
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "nvim"
@@ -70,8 +105,11 @@ func openEditor() tea.Cmd {
return util.ReportWarn("Message is empty")
}
os.Remove(tmpfile.Name())
attachments := m.attachments
m.attachments = nil
return SendMsg{
Text: string(content),
Text: string(content),
Attachments: attachments,
}
})
}
@@ -87,12 +125,16 @@ func (m *editorCmp) send() tea.Cmd {
value := m.textarea.Value()
m.textarea.Reset()
attachments := m.attachments
m.attachments = nil
if value == "" {
return nil
}
return tea.Batch(
util.CmdHandler(SendMsg{
Text: value,
Text: value,
Attachments: attachments,
}),
)
}
@@ -100,12 +142,42 @@ func (m *editorCmp) send() tea.Cmd {
func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case dialog.ThemeChangedMsg:
m.textarea = CreateTextArea(&m.textarea)
return m, nil
case SessionSelectedMsg:
if msg.ID != m.session.ID {
m.session = msg
}
return m, nil
case dialog.AttachmentAddedMsg:
if len(m.attachments) >= maxAttachments {
logging.ErrorPersist(fmt.Sprintf("cannot add more than %d images", maxAttachments))
return m, cmd
}
m.attachments = append(m.attachments, msg.Attachment)
case tea.KeyMsg:
if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
m.deleteMode = true
return m, nil
}
if key.Matches(msg, DeleteKeyMaps.DeleteAllAttachments) && m.deleteMode {
m.deleteMode = false
m.attachments = nil
return m, nil
}
if m.deleteMode && len(msg.Runes) > 0 && unicode.IsDigit(msg.Runes[0]) {
num := int(msg.Runes[0] - '0')
m.deleteMode = false
if num < 10 && len(m.attachments) > num {
if num == 0 {
m.attachments = m.attachments[num+1:]
} else {
m.attachments = slices.Delete(m.attachments, num, num+1)
}
return m, nil
}
}
if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) ||
key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) {
return m, nil
@@ -114,7 +186,11 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
return m, util.ReportWarn("Agent is working, please wait...")
}
return m, openEditor()
return m, m.openEditor()
}
if key.Matches(msg, DeleteKeyMaps.Escape) {
m.deleteMode = false
return m, nil
}
// Handle Enter key
if m.textarea.Focused() && key.Matches(msg, editorMaps.Send) {
@@ -128,20 +204,38 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.send()
}
}
}
m.textarea, cmd = m.textarea.Update(msg)
return m, cmd
}
func (m *editorCmp) View() string {
style := lipgloss.NewStyle().Padding(0, 0, 0, 1).Bold(true)
t := theme.CurrentTheme()
return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View())
// Style the prompt with theme colors
style := lipgloss.NewStyle().
Padding(0, 0, 0, 1).
Bold(true).
Foreground(t.Primary())
if len(m.attachments) == 0 {
return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View())
}
m.textarea.SetHeight(m.height - 1)
return lipgloss.JoinVertical(lipgloss.Top,
m.attachmentsContent(),
lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"),
m.textarea.View()),
)
}
func (m *editorCmp) SetSize(width, height int) tea.Cmd {
m.width = width
m.height = height
m.textarea.SetWidth(width - 3) // account for the prompt and padding right
m.textarea.SetHeight(height)
m.textarea.SetWidth(width)
return nil
}
@@ -149,29 +243,70 @@ func (m *editorCmp) GetSize() (int, int) {
return m.textarea.Width(), m.textarea.Height()
}
func (m *editorCmp) attachmentsContent() string {
var styledAttachments []string
t := theme.CurrentTheme()
attachmentStyles := styles.BaseStyle().
MarginLeft(1).
Background(t.TextMuted()).
Foreground(t.Text())
for i, attachment := range m.attachments {
var filename string
if len(attachment.FileName) > 10 {
filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7])
} else {
filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName)
}
if m.deleteMode {
filename = fmt.Sprintf("%d%s", i, filename)
}
styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
}
content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
return content
}
func (m *editorCmp) BindingKeys() []key.Binding {
bindings := []key.Binding{}
bindings = append(bindings, layout.KeyMapToSlice(editorMaps)...)
bindings = append(bindings, layout.KeyMapToSlice(DeleteKeyMaps)...)
return bindings
}
func NewEditorCmp(app *app.App) tea.Model {
ti := textarea.New()
ti.Prompt = " "
ti.ShowLineNumbers = false
ti.BlurredStyle.Base = ti.BlurredStyle.Base.Background(styles.Background)
ti.BlurredStyle.CursorLine = ti.BlurredStyle.CursorLine.Background(styles.Background)
ti.BlurredStyle.Placeholder = ti.BlurredStyle.Placeholder.Background(styles.Background)
ti.BlurredStyle.Text = ti.BlurredStyle.Text.Background(styles.Background)
func CreateTextArea(existing *textarea.Model) textarea.Model {
t := theme.CurrentTheme()
bgColor := t.Background()
textColor := t.Text()
textMutedColor := t.TextMuted()
ti.FocusedStyle.Base = ti.FocusedStyle.Base.Background(styles.Background)
ti.FocusedStyle.CursorLine = ti.FocusedStyle.CursorLine.Background(styles.Background)
ti.FocusedStyle.Placeholder = ti.FocusedStyle.Placeholder.Background(styles.Background)
ti.FocusedStyle.Text = ti.BlurredStyle.Text.Background(styles.Background)
ti.CharLimit = -1
ti.Focus()
ta := textarea.New()
ta.BlurredStyle.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
ta.BlurredStyle.CursorLine = styles.BaseStyle().Background(bgColor)
ta.BlurredStyle.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
ta.BlurredStyle.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
ta.FocusedStyle.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
ta.FocusedStyle.CursorLine = styles.BaseStyle().Background(bgColor)
ta.FocusedStyle.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
ta.FocusedStyle.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
ta.Prompt = " "
ta.ShowLineNumbers = false
ta.CharLimit = -1
if existing != nil {
ta.SetValue(existing.Value())
ta.SetWidth(existing.Width())
ta.SetHeight(existing.Height())
}
ta.Focus()
return ta
}
func NewEditorCmp(app *app.App) tea.Model {
ta := CreateTextArea(nil)
return &editorCmp{
app: app,
textarea: ti,
textarea: ta,
}
}

View File

@@ -14,7 +14,9 @@ import (
"github.com/opencode-ai/opencode/internal/message"
"github.com/opencode-ai/opencode/internal/pubsub"
"github.com/opencode-ai/opencode/internal/session"
"github.com/opencode-ai/opencode/internal/tui/components/dialog"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/tui/util"
)
@@ -33,6 +35,7 @@ type messagesCmp struct {
cachedContent map[string]cacheItem
spinner spinner.Model
rendering bool
attachments viewport.Model
}
type renderFinishedMsg struct{}
@@ -69,7 +72,9 @@ func (m *messagesCmp) Init() tea.Cmd {
func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case dialog.ThemeChangedMsg:
m.rerender()
return m, nil
case SessionSelectedMsg:
if msg.ID != m.session.ID {
cmd := m.SetSession(msg)
@@ -174,6 +179,7 @@ func formatTimeDifference(unixTime1, unixTime2 int64) string {
func (m *messagesCmp) renderView() {
m.uiMessages = make([]uiMessage, 0)
pos := 0
baseStyle := styles.BaseStyle()
if m.width == 0 {
return
@@ -224,16 +230,17 @@ func (m *messagesCmp) renderView() {
messages := make([]string, 0)
for _, v := range m.uiMessages {
messages = append(messages, v.content,
styles.BaseStyle.
messages = append(messages, lipgloss.JoinVertical(lipgloss.Left, v.content),
baseStyle.
Width(m.width).
Render(
"",
),
)
}
m.viewport.SetContent(
styles.BaseStyle.
baseStyle.
Width(m.width).
Render(
lipgloss.JoinVertical(
@@ -245,8 +252,10 @@ func (m *messagesCmp) renderView() {
}
func (m *messagesCmp) View() string {
baseStyle := styles.BaseStyle()
if m.rendering {
return styles.BaseStyle.
return baseStyle.
Width(m.width).
Render(
lipgloss.JoinVertical(
@@ -258,14 +267,14 @@ func (m *messagesCmp) View() string {
)
}
if len(m.messages) == 0 {
content := styles.BaseStyle.
content := baseStyle.
Width(m.width).
Height(m.height - 1).
Render(
m.initialScreen(),
)
return styles.BaseStyle.
return baseStyle.
Width(m.width).
Render(
lipgloss.JoinVertical(
@@ -277,7 +286,7 @@ func (m *messagesCmp) View() string {
)
}
return styles.BaseStyle.
return baseStyle.
Width(m.width).
Render(
lipgloss.JoinVertical(
@@ -328,6 +337,9 @@ func hasUnfinishedToolCalls(messages []message.Message) bool {
func (m *messagesCmp) working() string {
text := ""
if m.IsAgentWorking() && len(m.messages) > 0 {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
task := "Thinking..."
lastMessage := m.messages[len(m.messages)-1]
if hasToolsWithoutResponse(m.messages) {
@@ -338,42 +350,49 @@ func (m *messagesCmp) working() string {
task = "Generating..."
}
if task != "" {
text += styles.BaseStyle.Width(m.width).Foreground(styles.PrimaryColor).Bold(true).Render(
fmt.Sprintf("%s %s ", m.spinner.View(), task),
)
text += baseStyle.
Width(m.width).
Foreground(t.Primary()).
Bold(true).
Render(fmt.Sprintf("%s %s ", m.spinner.View(), task))
}
}
return text
}
func (m *messagesCmp) help() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
text := ""
if m.app.CoderAgent.IsBusy() {
text += lipgloss.JoinHorizontal(
lipgloss.Left,
styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "),
styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("esc"),
styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render(" to exit cancel"),
baseStyle.Foreground(t.TextMuted()).Bold(true).Render("press "),
baseStyle.Foreground(t.Text()).Bold(true).Render("esc"),
baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to exit cancel"),
)
} else {
text += lipgloss.JoinHorizontal(
lipgloss.Left,
styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "),
styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("enter"),
styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render(" to send the message,"),
styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render(" write"),
styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render(" \\"),
styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render(" and enter to add a new line"),
baseStyle.Foreground(t.TextMuted()).Bold(true).Render("press "),
baseStyle.Foreground(t.Text()).Bold(true).Render("enter"),
baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to send the message,"),
baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" write"),
baseStyle.Foreground(t.Text()).Bold(true).Render(" \\"),
baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" and enter to add a new line"),
)
}
return styles.BaseStyle.
return baseStyle.
Width(m.width).
Render(text)
}
func (m *messagesCmp) initialScreen() string {
return styles.BaseStyle.Width(m.width).Render(
baseStyle := styles.BaseStyle()
return baseStyle.Width(m.width).Render(
lipgloss.JoinVertical(
lipgloss.Top,
header(m.width),
@@ -383,6 +402,13 @@ func (m *messagesCmp) initialScreen() string {
)
}
func (m *messagesCmp) rerender() {
for _, msg := range m.messages {
delete(m.cachedContent, msg.ID)
}
m.renderView()
}
func (m *messagesCmp) SetSize(width, height int) tea.Cmd {
if m.width == width && m.height == height {
return nil
@@ -391,11 +417,9 @@ func (m *messagesCmp) SetSize(width, height int) tea.Cmd {
m.height = height
m.viewport.Width = width
m.viewport.Height = height - 2
for _, msg := range m.messages {
delete(m.cachedContent, msg.ID)
}
m.uiMessages = make([]uiMessage, 0)
m.renderView()
m.attachments.Width = width + 40
m.attachments.Height = 3
m.rerender()
return nil
}
@@ -413,7 +437,9 @@ func (m *messagesCmp) SetSession(session session.Session) tea.Cmd {
return util.ReportError(err)
}
m.messages = messages
m.currentMsgID = m.messages[len(m.messages)-1].ID
if len(m.messages) > 0 {
m.currentMsgID = m.messages[len(m.messages)-1].ID
}
delete(m.cachedContent, m.currentMsgID)
m.rendering = true
return func() tea.Msg {
@@ -435,6 +461,7 @@ func NewMessagesCmp(app *app.App) tea.Model {
s := spinner.New()
s.Spinner = spinner.Pulse
vp := viewport.New(0, 0)
attachmets := viewport.New(0, 0)
vp.KeyMap.PageUp = messageKeys.PageUp
vp.KeyMap.PageDown = messageKeys.PageDown
vp.KeyMap.HalfPageUp = messageKeys.HalfPageUp
@@ -444,5 +471,6 @@ func NewMessagesCmp(app *app.App) tea.Model {
cachedContent: make(map[string]cacheItem),
viewport: vp,
spinner: s,
attachments: attachmets,
}
}

View File

@@ -6,10 +6,8 @@ import (
"fmt"
"path/filepath"
"strings"
"sync"
"time"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/opencode-ai/opencode/internal/config"
@@ -19,6 +17,7 @@ import (
"github.com/opencode-ai/opencode/internal/llm/tools"
"github.com/opencode-ai/opencode/internal/message"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
)
type uiMessageType int
@@ -31,8 +30,6 @@ const (
maxResultHeight = 10
)
var diffStyle = diff.NewStyleConfig(diff.WithShowHeader(false), diff.WithShowHunkHeader(false))
type uiMessage struct {
ID string
messageType uiMessageType
@@ -41,46 +38,37 @@ type uiMessage struct {
content string
}
type renderCache struct {
mutex sync.Mutex
cache map[string][]uiMessage
}
func toMarkdown(content string, focused bool, width int) string {
r, _ := glamour.NewTermRenderer(
glamour.WithStyles(styles.MarkdownTheme(false)),
glamour.WithWordWrap(width),
)
if focused {
r, _ = glamour.NewTermRenderer(
glamour.WithStyles(styles.MarkdownTheme(true)),
glamour.WithWordWrap(width),
)
}
r := styles.GetMarkdownRenderer(width)
rendered, _ := r.Render(content)
return rendered
}
func renderMessage(msg string, isUser bool, isFocused bool, width int, info ...string) string {
style := styles.BaseStyle.
t := theme.CurrentTheme()
style := styles.BaseStyle().
Width(width - 1).
BorderLeft(true).
Foreground(styles.ForgroundDim).
BorderForeground(styles.PrimaryColor).
Foreground(t.TextMuted()).
BorderForeground(t.Primary()).
BorderStyle(lipgloss.ThickBorder())
if isUser {
style = style.
BorderForeground(styles.Blue)
}
parts := []string{
styles.ForceReplaceBackgroundWithLipgloss(toMarkdown(msg, isFocused, width), styles.Background),
style = style.BorderForeground(t.Secondary())
}
// remove newline at the end
// Apply markdown formatting and handle background color
parts := []string{
styles.ForceReplaceBackgroundWithLipgloss(toMarkdown(msg, isFocused, width), t.Background()),
}
// Remove newline at the end
parts[0] = strings.TrimSuffix(parts[0], "\n")
if len(info) > 0 {
parts = append(parts, info...)
}
rendered := style.Render(
lipgloss.JoinVertical(
lipgloss.Left,
@@ -92,7 +80,29 @@ func renderMessage(msg string, isUser bool, isFocused bool, width int, info ...s
}
func renderUserMessage(msg message.Message, isFocused bool, width int, position int) uiMessage {
content := renderMessage(msg.Content().String(), true, isFocused, width)
var styledAttachments []string
t := theme.CurrentTheme()
attachmentStyles := styles.BaseStyle().
MarginLeft(1).
Background(t.TextMuted()).
Foreground(t.Text())
for _, attachment := range msg.BinaryContent() {
file := filepath.Base(attachment.Path)
var filename string
if len(file) > 10 {
filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, file[0:7])
} else {
filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, file)
}
styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
}
content := ""
if len(styledAttachments) > 0 {
attachmentContent := styles.BaseStyle().Width(width).Render(lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...))
content = renderMessage(msg.Content().String(), true, isFocused, width, attachmentContent)
} else {
content = renderMessage(msg.Content().String(), true, isFocused, width)
}
userMsg := uiMessage{
ID: msg.ID,
messageType: userMessageType,
@@ -121,26 +131,37 @@ func renderAssistantMessage(
finishData := msg.FinishPart()
info := []string{}
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
// Add finish info if available
if finished {
switch finishData.Reason {
case message.FinishReasonEndTurn:
took := formatTimeDifference(msg.CreatedAt, finishData.Time)
info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, took),
))
took := formatTimestampDiff(msg.CreatedAt, finishData.Time)
info = append(info, baseStyle.
Width(width-1).
Foreground(t.TextMuted()).
Render(fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, took)),
)
case message.FinishReasonCanceled:
info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "canceled"),
))
info = append(info, baseStyle.
Width(width-1).
Foreground(t.TextMuted()).
Render(fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "canceled")),
)
case message.FinishReasonError:
info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "error"),
))
info = append(info, baseStyle.
Width(width-1).
Foreground(t.TextMuted()).
Render(fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "error")),
)
case message.FinishReasonPermissionDenied:
info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "permission denied"),
))
info = append(info, baseStyle.
Width(width-1).
Foreground(t.TextMuted()).
Render(fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "permission denied")),
)
}
}
if content != "" || (finished && finishData.Reason == message.FinishReasonEndTurn) {
@@ -414,32 +435,36 @@ func truncateHeight(content string, height int) string {
}
func renderToolResponse(toolCall message.ToolCall, response message.ToolResult, width int) string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
if response.IsError {
errContent := fmt.Sprintf("Error: %s", strings.ReplaceAll(response.Content, "\n", " "))
errContent = ansi.Truncate(errContent, width-1, "...")
return styles.BaseStyle.
return baseStyle.
Width(width).
Foreground(styles.Error).
Foreground(t.Error()).
Render(errContent)
}
resultContent := truncateHeight(response.Content, maxResultHeight)
switch toolCall.Name {
case agent.AgentToolName:
return styles.ForceReplaceBackgroundWithLipgloss(
toMarkdown(resultContent, false, width),
styles.Background,
t.Background(),
)
case tools.BashToolName:
resultContent = fmt.Sprintf("```bash\n%s\n```", resultContent)
return styles.ForceReplaceBackgroundWithLipgloss(
toMarkdown(resultContent, true, width),
styles.Background,
t.Background(),
)
case tools.EditToolName:
metadata := tools.EditResponseMetadata{}
json.Unmarshal([]byte(response.Metadata), &metadata)
truncDiff := truncateHeight(metadata.Diff, maxResultHeight)
formattedDiff, _ := diff.FormatDiff(truncDiff, diff.WithTotalWidth(width), diff.WithStyle(diffStyle))
formattedDiff, _ := diff.FormatDiff(truncDiff, diff.WithTotalWidth(width))
return formattedDiff
case tools.FetchToolName:
var params tools.FetchParams
@@ -454,16 +479,16 @@ func renderToolResponse(toolCall message.ToolCall, response message.ToolResult,
resultContent = fmt.Sprintf("```%s\n%s\n```", mdFormat, resultContent)
return styles.ForceReplaceBackgroundWithLipgloss(
toMarkdown(resultContent, true, width),
styles.Background,
t.Background(),
)
case tools.GlobToolName:
return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
case tools.GrepToolName:
return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
case tools.LSToolName:
return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
case tools.SourcegraphToolName:
return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
case tools.ViewToolName:
metadata := tools.ViewResponseMetadata{}
json.Unmarshal([]byte(response.Metadata), &metadata)
@@ -476,7 +501,7 @@ func renderToolResponse(toolCall message.ToolCall, response message.ToolResult,
resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(metadata.Content, maxResultHeight))
return styles.ForceReplaceBackgroundWithLipgloss(
toMarkdown(resultContent, true, width),
styles.Background,
t.Background(),
)
case tools.WriteToolName:
params := tools.WriteParams{}
@@ -492,13 +517,13 @@ func renderToolResponse(toolCall message.ToolCall, response message.ToolResult,
resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(params.Content, maxResultHeight))
return styles.ForceReplaceBackgroundWithLipgloss(
toMarkdown(resultContent, true, width),
styles.Background,
t.Background(),
)
default:
resultContent = fmt.Sprintf("```text\n%s\n```", resultContent)
return styles.ForceReplaceBackgroundWithLipgloss(
toMarkdown(resultContent, true, width),
styles.Background,
t.Background(),
)
}
}
@@ -515,39 +540,31 @@ func renderToolMessage(
if nested {
width = width - 3
}
style := styles.BaseStyle.
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
style := baseStyle.
Width(width - 1).
BorderLeft(true).
BorderStyle(lipgloss.ThickBorder()).
PaddingLeft(1).
BorderForeground(styles.ForgroundDim)
BorderForeground(t.TextMuted())
response := findToolResponse(toolCall.ID, allMessages)
toolName := styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf("%s: ", toolName(toolCall.Name)))
toolNameText := baseStyle.Foreground(t.TextMuted()).
Render(fmt.Sprintf("%s: ", toolName(toolCall.Name)))
if !toolCall.Finished {
// Get a brief description of what the tool is doing
toolAction := getToolAction(toolCall.Name)
// toolInput := strings.ReplaceAll(toolCall.Input, "\n", " ")
// truncatedInput := toolInput
// if len(truncatedInput) > 10 {
// truncatedInput = truncatedInput[len(truncatedInput)-10:]
// }
//
// truncatedInput = styles.BaseStyle.
// Italic(true).
// Width(width - 2 - lipgloss.Width(toolName)).
// Background(styles.BackgroundDim).
// Foreground(styles.ForgroundMid).
// Render(truncatedInput)
progressText := styles.BaseStyle.
Width(width - 2 - lipgloss.Width(toolName)).
Foreground(styles.ForgroundDim).
progressText := baseStyle.
Width(width - 2 - lipgloss.Width(toolNameText)).
Foreground(t.TextMuted()).
Render(fmt.Sprintf("%s", toolAction))
content := style.Render(lipgloss.JoinHorizontal(lipgloss.Left, toolName, progressText))
content := style.Render(lipgloss.JoinHorizontal(lipgloss.Left, toolNameText, progressText))
toolMsg := uiMessage{
messageType: toolMessageType,
position: position,
@@ -556,37 +573,39 @@ func renderToolMessage(
}
return toolMsg
}
params := renderToolParams(width-2-lipgloss.Width(toolName), toolCall)
params := renderToolParams(width-2-lipgloss.Width(toolNameText), toolCall)
responseContent := ""
if response != nil {
responseContent = renderToolResponse(toolCall, *response, width-2)
responseContent = strings.TrimSuffix(responseContent, "\n")
} else {
responseContent = styles.BaseStyle.
responseContent = baseStyle.
Italic(true).
Width(width - 2).
Foreground(styles.ForgroundDim).
Foreground(t.TextMuted()).
Render("Waiting for response...")
}
parts := []string{}
if !nested {
params := styles.BaseStyle.
Width(width - 2 - lipgloss.Width(toolName)).
Foreground(styles.ForgroundDim).
formattedParams := baseStyle.
Width(width - 2 - lipgloss.Width(toolNameText)).
Foreground(t.TextMuted()).
Render(params)
parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, toolName, params))
parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, toolNameText, formattedParams))
} else {
prefix := styles.BaseStyle.
Foreground(styles.ForgroundDim).
prefix := baseStyle.
Foreground(t.TextMuted()).
Render(" └ ")
params := styles.BaseStyle.
Width(width - 2 - lipgloss.Width(toolName)).
Foreground(styles.ForgroundMid).
formattedParams := baseStyle.
Width(width - 2 - lipgloss.Width(toolNameText)).
Foreground(t.TextMuted()).
Render(params)
parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, prefix, toolName, params))
parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, prefix, toolNameText, formattedParams))
}
if toolCall.Name == agent.AgentToolName {
taskMessages, _ := messagesService.List(context.Background(), toolCall.ID)
toolCalls := []message.ToolCall{}
@@ -622,3 +641,15 @@ func renderToolMessage(
}
return toolMsg
}
// Helper function to format the time difference between two Unix timestamps
func formatTimestampDiff(start, end int64) string {
diffSeconds := float64(end-start) / 1000.0 // Convert to seconds
if diffSeconds < 1 {
return fmt.Sprintf("%dms", int(diffSeconds*1000))
}
if diffSeconds < 60 {
return fmt.Sprintf("%.1fs", diffSeconds)
}
return fmt.Sprintf("%.1fm", diffSeconds/60)
}

View File

@@ -14,6 +14,7 @@ import (
"github.com/opencode-ai/opencode/internal/pubsub"
"github.com/opencode-ai/opencode/internal/session"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
)
type sidebarCmp struct {
@@ -81,7 +82,9 @@ func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (m *sidebarCmp) View() string {
return styles.BaseStyle.
baseStyle := styles.BaseStyle()
return baseStyle.
Width(m.width).
PaddingLeft(4).
PaddingRight(2).
@@ -101,11 +104,19 @@ func (m *sidebarCmp) View() string {
}
func (m *sidebarCmp) sessionSection() string {
sessionKey := styles.BaseStyle.Foreground(styles.PrimaryColor).Bold(true).Render("Session")
sessionValue := styles.BaseStyle.
Foreground(styles.Forground).
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
sessionKey := baseStyle.
Foreground(t.Primary()).
Bold(true).
Render("Session")
sessionValue := baseStyle.
Foreground(t.Text()).
Width(m.width - lipgloss.Width(sessionKey)).
Render(fmt.Sprintf(": %s", m.session.Title))
return lipgloss.JoinHorizontal(
lipgloss.Left,
sessionKey,
@@ -114,22 +125,40 @@ func (m *sidebarCmp) sessionSection() string {
}
func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
stats := ""
if additions > 0 && removals > 0 {
additions := styles.BaseStyle.Foreground(styles.Green).PaddingLeft(1).Render(fmt.Sprintf("+%d", additions))
removals := styles.BaseStyle.Foreground(styles.Red).PaddingLeft(1).Render(fmt.Sprintf("-%d", removals))
content := lipgloss.JoinHorizontal(lipgloss.Left, additions, removals)
stats = styles.BaseStyle.Width(lipgloss.Width(content)).Render(content)
} else if additions > 0 {
additions := fmt.Sprintf(" %s", styles.BaseStyle.PaddingLeft(1).Foreground(styles.Green).Render(fmt.Sprintf("+%d", additions)))
stats = styles.BaseStyle.Width(lipgloss.Width(additions)).Render(additions)
} else if removals > 0 {
removals := fmt.Sprintf(" %s", styles.BaseStyle.PaddingLeft(1).Foreground(styles.Red).Render(fmt.Sprintf("-%d", removals)))
stats = styles.BaseStyle.Width(lipgloss.Width(removals)).Render(removals)
}
filePathStr := styles.BaseStyle.Render(filePath)
additionsStr := baseStyle.
Foreground(t.Success()).
PaddingLeft(1).
Render(fmt.Sprintf("+%d", additions))
return styles.BaseStyle.
removalsStr := baseStyle.
Foreground(t.Error()).
PaddingLeft(1).
Render(fmt.Sprintf("-%d", removals))
content := lipgloss.JoinHorizontal(lipgloss.Left, additionsStr, removalsStr)
stats = baseStyle.Width(lipgloss.Width(content)).Render(content)
} else if additions > 0 {
additionsStr := fmt.Sprintf(" %s", baseStyle.
PaddingLeft(1).
Foreground(t.Success()).
Render(fmt.Sprintf("+%d", additions)))
stats = baseStyle.Width(lipgloss.Width(additionsStr)).Render(additionsStr)
} else if removals > 0 {
removalsStr := fmt.Sprintf(" %s", baseStyle.
PaddingLeft(1).
Foreground(t.Error()).
Render(fmt.Sprintf("-%d", removals)))
stats = baseStyle.Width(lipgloss.Width(removalsStr)).Render(removalsStr)
}
filePathStr := baseStyle.Render(filePath)
return baseStyle.
Width(m.width).
Render(
lipgloss.JoinHorizontal(
@@ -141,7 +170,14 @@ func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) stri
}
func (m *sidebarCmp) modifiedFiles() string {
modifiedFiles := styles.BaseStyle.Width(m.width).Foreground(styles.PrimaryColor).Bold(true).Render("Modified Files:")
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
modifiedFiles := baseStyle.
Width(m.width).
Foreground(t.Primary()).
Bold(true).
Render("Modified Files:")
// If no modified files, show a placeholder message
if m.modFiles == nil || len(m.modFiles) == 0 {
@@ -150,13 +186,13 @@ func (m *sidebarCmp) modifiedFiles() string {
if remainingWidth > 0 {
message += strings.Repeat(" ", remainingWidth)
}
return styles.BaseStyle.
return baseStyle.
Width(m.width).
Render(
lipgloss.JoinVertical(
lipgloss.Top,
modifiedFiles,
styles.BaseStyle.Foreground(styles.ForgroundDim).Render(message),
baseStyle.Foreground(t.TextMuted()).Render(message),
),
)
}
@@ -175,7 +211,7 @@ func (m *sidebarCmp) modifiedFiles() string {
fileViews = append(fileViews, m.modifiedFile(path, stats.additions, stats.removals))
}
return styles.BaseStyle.
return baseStyle.
Width(m.width).
Render(
lipgloss.JoinVertical(

View File

@@ -15,12 +15,13 @@ import (
"github.com/opencode-ai/opencode/internal/session"
"github.com/opencode-ai/opencode/internal/tui/components/chat"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/tui/util"
)
type StatusCmp interface {
tea.Model
SetHelpMsg(string)
SetHelpWidgetMsg(string)
}
type statusCmp struct {
@@ -70,7 +71,21 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
var helpWidget = styles.Padded.Background(styles.ForgroundMid).Foreground(styles.BackgroundDarker).Bold(true).Render("ctrl+? help")
var helpWidget = ""
// getHelpWidget returns the help widget with current theme colors
func getHelpWidget(helpText string) string {
t := theme.CurrentTheme()
if helpText == "" {
helpText = "ctrl+? help"
}
return styles.Padded().
Background(t.TextMuted()).
Foreground(t.BackgroundDarker()).
Bold(true).
Render(helpText)
}
func formatTokensAndCost(tokens int64, cost float64) string {
// Format tokens in human-readable format (e.g., 110K, 1.2M)
@@ -99,29 +114,38 @@ func formatTokensAndCost(tokens int64, cost float64) string {
}
func (m statusCmp) View() string {
status := helpWidget
t := theme.CurrentTheme()
// Initialize the help widget
status := getHelpWidget("")
if m.session.ID != "" {
tokens := formatTokensAndCost(m.session.PromptTokens+m.session.CompletionTokens, m.session.Cost)
tokensStyle := styles.Padded.
Background(styles.Forground).
Foreground(styles.BackgroundDim).
tokensStyle := styles.Padded().
Background(t.Text()).
Foreground(t.BackgroundSecondary()).
Render(tokens)
status += tokensStyle
}
diagnostics := styles.Padded.Background(styles.BackgroundDarker).Render(m.projectDiagnostics())
diagnostics := styles.Padded().
Background(t.BackgroundDarker()).
Render(m.projectDiagnostics())
if m.info.Msg != "" {
infoStyle := styles.Padded.
Foreground(styles.Base).
infoStyle := styles.Padded().
Foreground(t.Background()).
Width(m.availableFooterMsgWidth(diagnostics))
switch m.info.Type {
case util.InfoTypeInfo:
infoStyle = infoStyle.Background(styles.BorderColor)
infoStyle = infoStyle.Background(t.Info())
case util.InfoTypeWarn:
infoStyle = infoStyle.Background(styles.Peach)
infoStyle = infoStyle.Background(t.Warning())
case util.InfoTypeError:
infoStyle = infoStyle.Background(styles.Red)
infoStyle = infoStyle.Background(t.Error())
}
// Truncate message if it's longer than available width
msg := m.info.Msg
availWidth := m.availableFooterMsgWidth(diagnostics) - 10
@@ -130,9 +154,9 @@ func (m statusCmp) View() string {
}
status += infoStyle.Render(msg)
} else {
status += styles.Padded.
Foreground(styles.Base).
Background(styles.BackgroundDim).
status += styles.Padded().
Foreground(t.Text()).
Background(t.BackgroundSecondary()).
Width(m.availableFooterMsgWidth(diagnostics)).
Render("")
}
@@ -143,6 +167,8 @@ func (m statusCmp) View() string {
}
func (m *statusCmp) projectDiagnostics() string {
t := theme.CurrentTheme()
// Check if any LSP server is still initializing
initializing := false
for _, client := range m.lspClients {
@@ -155,8 +181,8 @@ func (m *statusCmp) projectDiagnostics() string {
// If any server is initializing, show that status
if initializing {
return lipgloss.NewStyle().
Background(styles.BackgroundDarker).
Foreground(styles.Peach).
Background(t.BackgroundDarker()).
Foreground(t.Warning()).
Render(fmt.Sprintf("%s Initializing LSP...", styles.SpinnerIcon))
}
@@ -189,29 +215,29 @@ func (m *statusCmp) projectDiagnostics() string {
if len(errorDiagnostics) > 0 {
errStr := lipgloss.NewStyle().
Background(styles.BackgroundDarker).
Foreground(styles.Error).
Background(t.BackgroundDarker()).
Foreground(t.Error()).
Render(fmt.Sprintf("%s %d", styles.ErrorIcon, len(errorDiagnostics)))
diagnostics = append(diagnostics, errStr)
}
if len(warnDiagnostics) > 0 {
warnStr := lipgloss.NewStyle().
Background(styles.BackgroundDarker).
Foreground(styles.Warning).
Background(t.BackgroundDarker()).
Foreground(t.Warning()).
Render(fmt.Sprintf("%s %d", styles.WarningIcon, len(warnDiagnostics)))
diagnostics = append(diagnostics, warnStr)
}
if len(hintDiagnostics) > 0 {
hintStr := lipgloss.NewStyle().
Background(styles.BackgroundDarker).
Foreground(styles.Text).
Background(t.BackgroundDarker()).
Foreground(t.Text()).
Render(fmt.Sprintf("%s %d", styles.HintIcon, len(hintDiagnostics)))
diagnostics = append(diagnostics, hintStr)
}
if len(infoDiagnostics) > 0 {
infoStr := lipgloss.NewStyle().
Background(styles.BackgroundDarker).
Foreground(styles.Peach).
Background(t.BackgroundDarker()).
Foreground(t.Info()).
Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics)))
diagnostics = append(diagnostics, infoStr)
}
@@ -230,6 +256,8 @@ func (m statusCmp) availableFooterMsgWidth(diagnostics string) int {
}
func (m statusCmp) model() string {
t := theme.CurrentTheme()
cfg := config.Get()
coder, ok := cfg.Agents[config.AgentCoder]
@@ -237,14 +265,22 @@ func (m statusCmp) model() string {
return "Unknown"
}
model := models.SupportedModels[coder.Model]
return styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render(model.Name)
return styles.Padded().
Background(t.Secondary()).
Foreground(t.Background()).
Render(model.Name)
}
func (m statusCmp) SetHelpMsg(s string) {
helpWidget = styles.Padded.Background(styles.Forground).Foreground(styles.BackgroundDarker).Bold(true).Render(s)
func (m statusCmp) SetHelpWidgetMsg(s string) {
// Update the help widget text using the getHelpWidget function
helpWidget = getHelpWidget(s)
}
func NewStatusCmp(lspClients map[string]*lsp.Client) StatusCmp {
// Initialize the help widget with default text
helpWidget = getHelpWidget("")
return &statusCmp{
messageTTL: 10 * time.Second,
lspClients: lspClients,

View File

@@ -0,0 +1,173 @@
package dialog
import (
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/tui/util"
)
// ArgumentsDialogCmp is a component that asks the user for command arguments.
type ArgumentsDialogCmp struct {
width, height int
textInput textinput.Model
keys argumentsDialogKeyMap
commandID string
content string
}
// NewArgumentsDialogCmp creates a new ArgumentsDialogCmp.
func NewArgumentsDialogCmp(commandID, content string) ArgumentsDialogCmp {
t := theme.CurrentTheme()
ti := textinput.New()
ti.Placeholder = "Enter arguments..."
ti.Focus()
ti.Width = 40
ti.Prompt = ""
ti.PlaceholderStyle = ti.PlaceholderStyle.Background(t.Background())
ti.PromptStyle = ti.PromptStyle.Background(t.Background())
ti.TextStyle = ti.TextStyle.Background(t.Background())
return ArgumentsDialogCmp{
textInput: ti,
keys: argumentsDialogKeyMap{},
commandID: commandID,
content: content,
}
}
type argumentsDialogKeyMap struct {
Enter key.Binding
Escape key.Binding
}
// ShortHelp implements key.Map.
func (k argumentsDialogKeyMap) ShortHelp() []key.Binding {
return []key.Binding{
key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "confirm"),
),
key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "cancel"),
),
}
}
// FullHelp implements key.Map.
func (k argumentsDialogKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{k.ShortHelp()}
}
// Init implements tea.Model.
func (m ArgumentsDialogCmp) Init() tea.Cmd {
return tea.Batch(
textinput.Blink,
m.textInput.Focus(),
)
}
// Update implements tea.Model.
func (m ArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
return m, util.CmdHandler(CloseArgumentsDialogMsg{})
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
return m, util.CmdHandler(CloseArgumentsDialogMsg{
Submit: true,
CommandID: m.commandID,
Content: m.content,
Arguments: m.textInput.Value(),
})
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
}
m.textInput, cmd = m.textInput.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
// View implements tea.Model.
func (m ArgumentsDialogCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
// Calculate width needed for content
maxWidth := 60 // Width for explanation text
title := baseStyle.
Foreground(t.Primary()).
Bold(true).
Width(maxWidth).
Padding(0, 1).
Render("Command Arguments")
explanation := baseStyle.
Foreground(t.Text()).
Width(maxWidth).
Padding(0, 1).
Render("This command requires arguments. Please enter the text to replace $ARGUMENTS with:")
inputField := baseStyle.
Foreground(t.Text()).
Width(maxWidth).
Padding(1, 1).
Render(m.textInput.View())
maxWidth = min(maxWidth, m.width-10)
content := lipgloss.JoinVertical(
lipgloss.Left,
title,
explanation,
inputField,
)
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Background(t.Background()).
Width(lipgloss.Width(content) + 4).
Render(content)
}
// SetSize sets the size of the component.
func (m *ArgumentsDialogCmp) SetSize(width, height int) {
m.width = width
m.height = height
}
// Bindings implements layout.Bindings.
func (m ArgumentsDialogCmp) Bindings() []key.Binding {
return m.keys.ShortHelp()
}
// CloseArgumentsDialogMsg is a message that is sent when the arguments dialog is closed.
type CloseArgumentsDialogMsg struct {
Submit bool
CommandID string
Content string
Arguments string
}
// ShowArgumentsDialogMsg is a message that is sent to show the arguments dialog.
type ShowArgumentsDialogMsg struct {
CommandID string
Content string
}

View File

@@ -6,6 +6,7 @@ import (
"github.com/charmbracelet/lipgloss"
"github.com/opencode-ai/opencode/internal/tui/layout"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/tui/util"
)
@@ -112,11 +113,14 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (c *commandDialogCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
if len(c.commands) == 0 {
return styles.BaseStyle.Padding(1, 2).
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(styles.Background).
BorderForeground(styles.ForgroundDim).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Width(40).
Render("No commands available")
}
@@ -154,17 +158,17 @@ func (c *commandDialogCmp) View() string {
for i := startIdx; i < endIdx; i++ {
cmd := c.commands[i]
itemStyle := styles.BaseStyle.Width(maxWidth)
descStyle := styles.BaseStyle.Width(maxWidth).Foreground(styles.ForgroundDim)
itemStyle := baseStyle.Width(maxWidth)
descStyle := baseStyle.Width(maxWidth).Foreground(t.TextMuted())
if i == c.selectedIdx {
itemStyle = itemStyle.
Background(styles.PrimaryColor).
Foreground(styles.Background).
Background(t.Primary()).
Foreground(t.Background()).
Bold(true)
descStyle = descStyle.
Background(styles.PrimaryColor).
Foreground(styles.Background)
Background(t.Primary()).
Foreground(t.Background())
}
title := itemStyle.Padding(0, 1).Render(cmd.Title)
@@ -177,8 +181,8 @@ func (c *commandDialogCmp) View() string {
}
}
title := styles.BaseStyle.
Foreground(styles.PrimaryColor).
title := baseStyle.
Foreground(t.Primary()).
Bold(true).
Width(maxWidth).
Padding(0, 1).
@@ -187,15 +191,15 @@ func (c *commandDialogCmp) View() string {
content := lipgloss.JoinVertical(
lipgloss.Left,
title,
styles.BaseStyle.Width(maxWidth).Render(""),
styles.BaseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, commandItems...)),
styles.BaseStyle.Width(maxWidth).Render(""),
baseStyle.Width(maxWidth).Render(""),
baseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, commandItems...)),
baseStyle.Width(maxWidth).Render(""),
)
return styles.BaseStyle.Padding(1, 2).
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(styles.Background).
BorderForeground(styles.ForgroundDim).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Width(lipgloss.Width(content) + 4).
Render(content)
}

View File

@@ -0,0 +1,166 @@
package dialog
import (
"fmt"
"os"
"path/filepath"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/tui/util"
)
// Command prefix constants
const (
UserCommandPrefix = "user:"
ProjectCommandPrefix = "project:"
)
// LoadCustomCommands loads custom commands from both XDG_CONFIG_HOME and project data directory
func LoadCustomCommands() ([]Command, error) {
cfg := config.Get()
if cfg == nil {
return nil, fmt.Errorf("config not loaded")
}
var commands []Command
// Load user commands from XDG_CONFIG_HOME/opencode/commands
xdgConfigHome := os.Getenv("XDG_CONFIG_HOME")
if xdgConfigHome == "" {
// Default to ~/.config if XDG_CONFIG_HOME is not set
home, err := os.UserHomeDir()
if err == nil {
xdgConfigHome = filepath.Join(home, ".config")
}
}
if xdgConfigHome != "" {
userCommandsDir := filepath.Join(xdgConfigHome, "opencode", "commands")
userCommands, err := loadCommandsFromDir(userCommandsDir, UserCommandPrefix)
if err != nil {
// Log error but continue - we'll still try to load other commands
fmt.Printf("Warning: failed to load user commands from XDG_CONFIG_HOME: %v\n", err)
} else {
commands = append(commands, userCommands...)
}
}
// Load commands from $HOME/.opencode/commands
home, err := os.UserHomeDir()
if err == nil {
homeCommandsDir := filepath.Join(home, ".opencode", "commands")
homeCommands, err := loadCommandsFromDir(homeCommandsDir, UserCommandPrefix)
if err != nil {
// Log error but continue - we'll still try to load other commands
fmt.Printf("Warning: failed to load home commands: %v\n", err)
} else {
commands = append(commands, homeCommands...)
}
}
// Load project commands from data directory
projectCommandsDir := filepath.Join(cfg.Data.Directory, "commands")
projectCommands, err := loadCommandsFromDir(projectCommandsDir, ProjectCommandPrefix)
if err != nil {
// Log error but return what we have so far
fmt.Printf("Warning: failed to load project commands: %v\n", err)
} else {
commands = append(commands, projectCommands...)
}
return commands, nil
}
// loadCommandsFromDir loads commands from a specific directory with the given prefix
func loadCommandsFromDir(commandsDir string, prefix string) ([]Command, error) {
// Check if the commands directory exists
if _, err := os.Stat(commandsDir); os.IsNotExist(err) {
// Create the commands directory if it doesn't exist
if err := os.MkdirAll(commandsDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create commands directory %s: %w", commandsDir, err)
}
// Return empty list since we just created the directory
return []Command{}, nil
}
var commands []Command
// Walk through the commands directory and load all .md files
err := filepath.Walk(commandsDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Skip directories
if info.IsDir() {
return nil
}
// Only process markdown files
if !strings.HasSuffix(strings.ToLower(info.Name()), ".md") {
return nil
}
// Read the file content
content, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read command file %s: %w", path, err)
}
// Get the command ID from the file name without the .md extension
commandID := strings.TrimSuffix(info.Name(), filepath.Ext(info.Name()))
// Get relative path from commands directory
relPath, err := filepath.Rel(commandsDir, path)
if err != nil {
return fmt.Errorf("failed to get relative path for %s: %w", path, err)
}
// Create the command ID from the relative path
// Replace directory separators with colons
commandIDPath := strings.ReplaceAll(filepath.Dir(relPath), string(filepath.Separator), ":")
if commandIDPath != "." {
commandID = commandIDPath + ":" + commandID
}
// Create a command
command := Command{
ID: prefix + commandID,
Title: prefix + commandID,
Description: fmt.Sprintf("Custom command from %s", relPath),
Handler: func(cmd Command) tea.Cmd {
commandContent := string(content)
// Check if the command contains $ARGUMENTS placeholder
if strings.Contains(commandContent, "$ARGUMENTS") {
// Show arguments dialog
return util.CmdHandler(ShowArgumentsDialogMsg{
CommandID: cmd.ID,
Content: commandContent,
})
}
// No arguments needed, run command directly
return util.CmdHandler(CommandRunCustomMsg{
Content: commandContent,
})
},
}
commands = append(commands, command)
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to load custom commands from %s: %w", commandsDir, err)
}
return commands, nil
}
// CommandRunCustomMsg is sent when a custom command is executed
type CommandRunCustomMsg struct {
Content string
}

View File

@@ -0,0 +1,474 @@
package dialog
import (
"fmt"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/opencode-ai/opencode/internal/app"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/logging"
"github.com/opencode-ai/opencode/internal/message"
"github.com/opencode-ai/opencode/internal/tui/image"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/tui/util"
)
const (
maxAttachmentSize = int64(5 * 1024 * 1024) // 5MB
downArrow = "down"
upArrow = "up"
)
type FilePrickerKeyMap struct {
Enter key.Binding
Down key.Binding
Up key.Binding
Forward key.Binding
Backward key.Binding
OpenFilePicker key.Binding
Esc key.Binding
InsertCWD key.Binding
}
var filePickerKeyMap = FilePrickerKeyMap{
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "select file/enter directory"),
),
Down: key.NewBinding(
key.WithKeys("j", downArrow),
key.WithHelp("↓/j", "down"),
),
Up: key.NewBinding(
key.WithKeys("k", upArrow),
key.WithHelp("↑/k", "up"),
),
Forward: key.NewBinding(
key.WithKeys("l"),
key.WithHelp("l", "enter directory"),
),
Backward: key.NewBinding(
key.WithKeys("h", "backspace"),
key.WithHelp("h/backspace", "go back"),
),
OpenFilePicker: key.NewBinding(
key.WithKeys("ctrl+f"),
key.WithHelp("ctrl+f", "open file picker"),
),
Esc: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "close/exit"),
),
InsertCWD: key.NewBinding(
key.WithKeys("i"),
key.WithHelp("i", "manual path input"),
),
}
type filepickerCmp struct {
basePath string
width int
height int
cursor int
err error
cursorChain stack
viewport viewport.Model
dirs []os.DirEntry
cwdDetails *DirNode
selectedFile string
cwd textinput.Model
ShowFilePicker bool
app *app.App
}
type DirNode struct {
parent *DirNode
child *DirNode
directory string
}
type stack []int
func (s stack) Push(v int) stack {
return append(s, v)
}
func (s stack) Pop() (stack, int) {
l := len(s)
return s[:l-1], s[l-1]
}
type AttachmentAddedMsg struct {
Attachment message.Attachment
}
func (f *filepickerCmp) Init() tea.Cmd {
return nil
}
func (f *filepickerCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
f.width = 60
f.height = 20
f.viewport.Width = 80
f.viewport.Height = 22
f.cursor = 0
f.getCurrentFileBelowCursor()
case tea.KeyMsg:
if f.cwd.Focused() {
f.cwd, cmd = f.cwd.Update(msg)
}
switch {
case key.Matches(msg, filePickerKeyMap.InsertCWD):
f.cwd.Focus()
return f, cmd
case key.Matches(msg, filePickerKeyMap.Esc):
if f.cwd.Focused() {
f.cwd.Blur()
}
case key.Matches(msg, filePickerKeyMap.Down):
if !f.cwd.Focused() || msg.String() == downArrow {
if f.cursor < len(f.dirs)-1 {
f.cursor++
f.getCurrentFileBelowCursor()
}
}
case key.Matches(msg, filePickerKeyMap.Up):
if !f.cwd.Focused() || msg.String() == upArrow {
if f.cursor > 0 {
f.cursor--
f.getCurrentFileBelowCursor()
}
}
case key.Matches(msg, filePickerKeyMap.Enter):
var path string
var isPathDir bool
if f.cwd.Focused() {
path = f.cwd.Value()
fileInfo, err := os.Stat(path)
if err != nil {
logging.ErrorPersist("Invalid path")
return f, cmd
}
isPathDir = fileInfo.IsDir()
} else {
path = filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name())
isPathDir = f.dirs[f.cursor].IsDir()
}
if isPathDir {
newWorkingDir := DirNode{parent: f.cwdDetails, directory: path}
f.cwdDetails.child = &newWorkingDir
f.cwdDetails = f.cwdDetails.child
f.cursorChain = f.cursorChain.Push(f.cursor)
f.dirs = readDir(f.cwdDetails.directory, false)
f.cursor = 0
f.cwd.SetValue(f.cwdDetails.directory)
f.getCurrentFileBelowCursor()
} else {
f.selectedFile = path
return f.addAttachmentToMessage()
}
case key.Matches(msg, filePickerKeyMap.Esc):
if !f.cwd.Focused() {
f.cursorChain = make(stack, 0)
f.cursor = 0
} else {
f.cwd.Blur()
}
case key.Matches(msg, filePickerKeyMap.Forward):
if !f.cwd.Focused() {
if f.dirs[f.cursor].IsDir() {
path := filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name())
newWorkingDir := DirNode{parent: f.cwdDetails, directory: path}
f.cwdDetails.child = &newWorkingDir
f.cwdDetails = f.cwdDetails.child
f.cursorChain = f.cursorChain.Push(f.cursor)
f.dirs = readDir(f.cwdDetails.directory, false)
f.cursor = 0
f.cwd.SetValue(f.cwdDetails.directory)
f.getCurrentFileBelowCursor()
}
}
case key.Matches(msg, filePickerKeyMap.Backward):
if !f.cwd.Focused() {
if len(f.cursorChain) != 0 && f.cwdDetails.parent != nil {
f.cursorChain, f.cursor = f.cursorChain.Pop()
f.cwdDetails = f.cwdDetails.parent
f.cwdDetails.child = nil
f.dirs = readDir(f.cwdDetails.directory, false)
f.cwd.SetValue(f.cwdDetails.directory)
f.getCurrentFileBelowCursor()
}
}
case key.Matches(msg, filePickerKeyMap.OpenFilePicker):
f.dirs = readDir(f.cwdDetails.directory, false)
f.cursor = 0
f.getCurrentFileBelowCursor()
}
}
return f, cmd
}
func (f *filepickerCmp) addAttachmentToMessage() (tea.Model, tea.Cmd) {
modeInfo := GetSelectedModel(config.Get())
if !modeInfo.SupportsAttachments {
logging.ErrorPersist(fmt.Sprintf("Model %s doesn't support attachments", modeInfo.Name))
return f, nil
}
selectedFilePath := f.selectedFile
if !isExtSupported(selectedFilePath) {
logging.ErrorPersist("Unsupported file")
return f, nil
}
isFileLarge, err := image.ValidateFileSize(selectedFilePath, maxAttachmentSize)
if err != nil {
logging.ErrorPersist("unable to read the image")
return f, nil
}
if isFileLarge {
logging.ErrorPersist("file too large, max 5MB")
return f, nil
}
content, err := os.ReadFile(selectedFilePath)
if err != nil {
logging.ErrorPersist("Unable read selected file")
return f, nil
}
mimeBufferSize := min(512, len(content))
mimeType := http.DetectContentType(content[:mimeBufferSize])
fileName := filepath.Base(selectedFilePath)
attachment := message.Attachment{FilePath: selectedFilePath, FileName: fileName, MimeType: mimeType, Content: content}
f.selectedFile = ""
return f, util.CmdHandler(AttachmentAddedMsg{attachment})
}
func (f *filepickerCmp) View() string {
t := theme.CurrentTheme()
const maxVisibleDirs = 20
const maxWidth = 80
adjustedWidth := maxWidth
for _, file := range f.dirs {
if len(file.Name()) > adjustedWidth-4 { // Account for padding
adjustedWidth = len(file.Name()) + 4
}
}
adjustedWidth = max(30, min(adjustedWidth, f.width-15)) + 1
files := make([]string, 0, maxVisibleDirs)
startIdx := 0
if len(f.dirs) > maxVisibleDirs {
halfVisible := maxVisibleDirs / 2
if f.cursor >= halfVisible && f.cursor < len(f.dirs)-halfVisible {
startIdx = f.cursor - halfVisible
} else if f.cursor >= len(f.dirs)-halfVisible {
startIdx = len(f.dirs) - maxVisibleDirs
}
}
endIdx := min(startIdx+maxVisibleDirs, len(f.dirs))
for i := startIdx; i < endIdx; i++ {
file := f.dirs[i]
itemStyle := styles.BaseStyle().Width(adjustedWidth)
if i == f.cursor {
itemStyle = itemStyle.
Background(t.Primary()).
Foreground(t.Background()).
Bold(true)
}
filename := file.Name()
if len(filename) > adjustedWidth-4 {
filename = filename[:adjustedWidth-7] + "..."
}
if file.IsDir() {
filename = filename + "/"
} else if isExtSupported(file.Name()) {
filename = filename
} else {
filename = filename
}
files = append(files, itemStyle.Padding(0, 1).Render(filename))
}
// Pad to always show exactly 21 lines
for len(files) < maxVisibleDirs {
files = append(files, styles.BaseStyle().Width(adjustedWidth).Render(""))
}
currentPath := styles.BaseStyle().
Height(1).
Width(adjustedWidth).
Render(f.cwd.View())
viewportstyle := lipgloss.NewStyle().
Width(f.viewport.Width).
Background(t.Background()).
Border(lipgloss.RoundedBorder()).
BorderForeground(t.TextMuted()).
BorderBackground(t.Background()).
Padding(2).
Render(f.viewport.View())
var insertExitText string
if f.IsCWDFocused() {
insertExitText = "Press esc to exit typing path"
} else {
insertExitText = "Press i to start typing path"
}
content := lipgloss.JoinVertical(
lipgloss.Left,
currentPath,
styles.BaseStyle().Width(adjustedWidth).Render(""),
styles.BaseStyle().Width(adjustedWidth).Render(lipgloss.JoinVertical(lipgloss.Left, files...)),
styles.BaseStyle().Width(adjustedWidth).Render(""),
styles.BaseStyle().Foreground(t.TextMuted()).Width(adjustedWidth).Render(insertExitText),
)
f.cwd.SetValue(f.cwd.Value())
contentStyle := styles.BaseStyle().Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Width(lipgloss.Width(content) + 4)
return lipgloss.JoinHorizontal(lipgloss.Center, contentStyle.Render(content), viewportstyle)
}
type FilepickerCmp interface {
tea.Model
ToggleFilepicker(showFilepicker bool)
IsCWDFocused() bool
}
func (f *filepickerCmp) ToggleFilepicker(showFilepicker bool) {
f.ShowFilePicker = showFilepicker
}
func (f *filepickerCmp) IsCWDFocused() bool {
return f.cwd.Focused()
}
func NewFilepickerCmp(app *app.App) FilepickerCmp {
homepath, err := os.UserHomeDir()
if err != nil {
logging.Error("error loading user files")
return nil
}
baseDir := DirNode{parent: nil, directory: homepath}
dirs := readDir(homepath, false)
viewport := viewport.New(0, 0)
currentDirectory := textinput.New()
currentDirectory.CharLimit = 200
currentDirectory.Width = 44
currentDirectory.Cursor.Blink = true
currentDirectory.SetValue(baseDir.directory)
return &filepickerCmp{cwdDetails: &baseDir, dirs: dirs, cursorChain: make(stack, 0), viewport: viewport, cwd: currentDirectory, app: app}
}
func (f *filepickerCmp) getCurrentFileBelowCursor() {
if len(f.dirs) == 0 || f.cursor < 0 || f.cursor >= len(f.dirs) {
logging.Error(fmt.Sprintf("Invalid cursor position. Dirs length: %d, Cursor: %d", len(f.dirs), f.cursor))
f.viewport.SetContent("Preview unavailable")
return
}
dir := f.dirs[f.cursor]
filename := dir.Name()
if !dir.IsDir() && isExtSupported(filename) {
fullPath := f.cwdDetails.directory + "/" + dir.Name()
go func() {
imageString, err := image.ImagePreview(f.viewport.Width-4, fullPath)
if err != nil {
logging.Error(err.Error())
f.viewport.SetContent("Preview unavailable")
return
}
f.viewport.SetContent(imageString)
}()
} else {
f.viewport.SetContent("Preview unavailable")
}
}
func readDir(path string, showHidden bool) []os.DirEntry {
logging.Info(fmt.Sprintf("Reading directory: %s", path))
entriesChan := make(chan []os.DirEntry, 1)
errChan := make(chan error, 1)
go func() {
dirEntries, err := os.ReadDir(path)
if err != nil {
logging.ErrorPersist(err.Error())
errChan <- err
return
}
entriesChan <- dirEntries
}()
select {
case dirEntries := <-entriesChan:
sort.Slice(dirEntries, func(i, j int) bool {
if dirEntries[i].IsDir() == dirEntries[j].IsDir() {
return dirEntries[i].Name() < dirEntries[j].Name()
}
return dirEntries[i].IsDir()
})
if showHidden {
return dirEntries
}
var sanitizedDirEntries []os.DirEntry
for _, dirEntry := range dirEntries {
isHidden, _ := IsHidden(dirEntry.Name())
if !isHidden {
if dirEntry.IsDir() || isExtSupported(dirEntry.Name()) {
sanitizedDirEntries = append(sanitizedDirEntries, dirEntry)
}
}
}
return sanitizedDirEntries
case err := <-errChan:
logging.ErrorPersist(fmt.Sprintf("Error reading directory %s", path), err)
return []os.DirEntry{}
case <-time.After(5 * time.Second):
logging.ErrorPersist(fmt.Sprintf("Timeout reading directory %s", path), nil)
return []os.DirEntry{}
}
}
func IsHidden(file string) (bool, error) {
return strings.HasPrefix(file, "."), nil
}
func isExtSupported(path string) bool {
ext := strings.ToLower(filepath.Ext(path))
return (ext == ".jpg" || ext == ".jpeg" || ext == ".webp" || ext == ".png")
}

View File

@@ -7,6 +7,7 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
)
type helpCmp struct {
@@ -53,17 +54,29 @@ func removeDuplicateBindings(bindings []key.Binding) []key.Binding {
}
func (h *helpCmp) render() string {
helpKeyStyle := styles.Bold.Background(styles.Background).Foreground(styles.Forground).Padding(0, 1, 0, 0)
helpDescStyle := styles.Regular.Background(styles.Background).Foreground(styles.ForgroundMid)
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
helpKeyStyle := styles.Bold().
Background(t.Background()).
Foreground(t.Text()).
Padding(0, 1, 0, 0)
helpDescStyle := styles.Regular().
Background(t.Background()).
Foreground(t.TextMuted())
// Compile list of bindings to render
bindings := removeDuplicateBindings(h.keys)
// Enumerate through each group of bindings, populating a series of
// pairs of columns, one for keys, one for descriptions
var (
pairs []string
width int
rows = 10 - 2
rows = 12 - 2
)
for i := 0; i < len(bindings); i += rows {
var (
keys []string
@@ -73,11 +86,12 @@ func (h *helpCmp) render() string {
keys = append(keys, helpKeyStyle.Render(bindings[j].Help().Key))
descs = append(descs, helpDescStyle.Render(bindings[j].Help().Desc))
}
// Render pair of columns; beyond the first pair, render a three space
// left margin, in order to visually separate the pairs.
var cols []string
if len(pairs) > 0 {
cols = []string{styles.BaseStyle.Render(" ")}
cols = []string{baseStyle.Render(" ")}
}
maxDescWidth := 0
@@ -89,7 +103,7 @@ func (h *helpCmp) render() string {
for i := range descs {
remainingWidth := maxDescWidth - lipgloss.Width(descs[i])
if remainingWidth > 0 {
descs[i] = descs[i] + styles.BaseStyle.Render(strings.Repeat(" ", remainingWidth))
descs[i] = descs[i] + baseStyle.Render(strings.Repeat(" ", remainingWidth))
}
}
maxKeyWidth := 0
@@ -101,7 +115,7 @@ func (h *helpCmp) render() string {
for i := range keys {
remainingWidth := maxKeyWidth - lipgloss.Width(keys[i])
if remainingWidth > 0 {
keys[i] = keys[i] + styles.BaseStyle.Render(strings.Repeat(" ", remainingWidth))
keys[i] = keys[i] + baseStyle.Render(strings.Repeat(" ", remainingWidth))
}
}
@@ -110,7 +124,7 @@ func (h *helpCmp) render() string {
strings.Join(descs, "\n"),
)
pair := styles.BaseStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, cols...))
pair := baseStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, cols...))
// check whether it exceeds the maximum width avail (the width of the
// terminal, subtracting 2 for the borders).
width += lipgloss.Width(pair)
@@ -130,9 +144,9 @@ func (h *helpCmp) render() string {
lipgloss.Left, // x
lipgloss.Top, // y
lastPair, // content
lipgloss.WithWhitespaceBackground(styles.Background), // background
lipgloss.WithWhitespaceBackground(t.Background()),
))
content := styles.BaseStyle.Width(h.width).Render(
content := baseStyle.Width(h.width).Render(
lipgloss.JoinHorizontal(
lipgloss.Top,
prefix...,
@@ -140,8 +154,9 @@ func (h *helpCmp) render() string {
)
return content
}
// Join pairs of columns and enclose in a border
content := styles.BaseStyle.Width(h.width).Render(
content := baseStyle.Width(h.width).Render(
lipgloss.JoinHorizontal(
lipgloss.Top,
pairs...,
@@ -151,22 +166,25 @@ func (h *helpCmp) render() string {
}
func (h *helpCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
content := h.render()
header := styles.BaseStyle.
header := baseStyle.
Bold(true).
Width(lipgloss.Width(content)).
Foreground(styles.PrimaryColor).
Foreground(t.Primary()).
Render("Keyboard Shortcuts")
return styles.BaseStyle.Padding(1).
return baseStyle.Padding(1).
Border(lipgloss.RoundedBorder()).
BorderForeground(styles.ForgroundDim).
BorderForeground(t.TextMuted()).
Width(h.width).
BorderBackground(styles.Background).
BorderBackground(t.Background()).
Render(
lipgloss.JoinVertical(lipgloss.Center,
header,
styles.BaseStyle.Render(strings.Repeat(" ", lipgloss.Width(header))),
baseStyle.Render(strings.Repeat(" ", lipgloss.Width(header))),
content,
),
)

View File

@@ -6,6 +6,7 @@ import (
"github.com/charmbracelet/lipgloss"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/tui/util"
)
@@ -92,55 +93,58 @@ 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()
// Calculate width needed for content
maxWidth := 60 // Width for explanation text
title := styles.BaseStyle.
Foreground(styles.PrimaryColor).
title := baseStyle.
Foreground(t.Primary()).
Bold(true).
Width(maxWidth).
Padding(0, 1).
Render("Initialize Project")
explanation := styles.BaseStyle.
Foreground(styles.Forground).
explanation := baseStyle.
Foreground(t.Text()).
Width(maxWidth).
Padding(0, 1).
Render("Initialization generates a new OpenCode.md file that contains information about your codebase, this file serves as memory for each project, you can freely add to it to help the agents be better at their job.")
question := styles.BaseStyle.
Foreground(styles.Forground).
question := baseStyle.
Foreground(t.Text()).
Width(maxWidth).
Padding(1, 1).
Render("Would you like to initialize this project?")
maxWidth = min(maxWidth, m.width-10)
yesStyle := styles.BaseStyle
noStyle := styles.BaseStyle
yesStyle := baseStyle
noStyle := baseStyle
if m.selected == 0 {
yesStyle = yesStyle.
Background(styles.PrimaryColor).
Foreground(styles.Background).
Background(t.Primary()).
Foreground(t.Background()).
Bold(true)
noStyle = noStyle.
Background(styles.Background).
Foreground(styles.PrimaryColor)
Background(t.Background()).
Foreground(t.Primary())
} else {
noStyle = noStyle.
Background(styles.PrimaryColor).
Foreground(styles.Background).
Background(t.Primary()).
Foreground(t.Background()).
Bold(true)
yesStyle = yesStyle.
Background(styles.Background).
Foreground(styles.PrimaryColor)
Background(t.Background()).
Foreground(t.Primary())
}
yes := yesStyle.Padding(0, 3).Render("Yes")
no := noStyle.Padding(0, 3).Render("No")
buttons := lipgloss.JoinHorizontal(lipgloss.Center, yes, styles.BaseStyle.Render(" "), no)
buttons = styles.BaseStyle.
buttons := lipgloss.JoinHorizontal(lipgloss.Center, yes, baseStyle.Render(" "), no)
buttons = baseStyle.
Width(maxWidth).
Padding(1, 0).
Render(buttons)
@@ -148,17 +152,17 @@ func (m InitDialogCmp) View() string {
content := lipgloss.JoinVertical(
lipgloss.Left,
title,
styles.BaseStyle.Width(maxWidth).Render(""),
baseStyle.Width(maxWidth).Render(""),
explanation,
question,
buttons,
styles.BaseStyle.Width(maxWidth).Render(""),
baseStyle.Width(maxWidth).Render(""),
)
return styles.BaseStyle.Padding(1, 2).
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(styles.Background).
BorderForeground(styles.ForgroundDim).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Width(lipgloss.Width(content) + 4).
Render(content)
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/opencode-ai/opencode/internal/llm/models"
"github.com/opencode-ai/opencode/internal/tui/layout"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/tui/util"
)
@@ -185,10 +186,13 @@ func (m *modelDialogCmp) switchProvider(offset int) {
}
func (m *modelDialogCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
// Capitalize first letter of provider name
providerName := strings.ToUpper(string(m.provider)[:1]) + string(m.provider[1:])
title := styles.BaseStyle.
Foreground(styles.PrimaryColor).
title := baseStyle.
Foreground(t.Primary()).
Bold(true).
Width(maxDialogWidth).
Padding(0, 0, 1).
@@ -199,10 +203,10 @@ func (m *modelDialogCmp) View() string {
modelItems := make([]string, 0, endIdx-m.scrollOffset)
for i := m.scrollOffset; i < endIdx; i++ {
itemStyle := styles.BaseStyle.Width(maxDialogWidth)
itemStyle := baseStyle.Width(maxDialogWidth)
if i == m.selectedIdx {
itemStyle = itemStyle.Background(styles.PrimaryColor).
Foreground(styles.Background).Bold(true)
itemStyle = itemStyle.Background(t.Primary()).
Foreground(t.Background()).Bold(true)
}
modelItems = append(modelItems, itemStyle.Render(m.models[i].Name))
}
@@ -212,14 +216,14 @@ func (m *modelDialogCmp) View() string {
content := lipgloss.JoinVertical(
lipgloss.Left,
title,
styles.BaseStyle.Width(maxDialogWidth).Render(lipgloss.JoinVertical(lipgloss.Left, modelItems...)),
baseStyle.Width(maxDialogWidth).Render(lipgloss.JoinVertical(lipgloss.Left, modelItems...)),
scrollIndicator,
)
return styles.BaseStyle.Padding(1, 2).
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(styles.Background).
BorderForeground(styles.ForgroundDim).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Width(lipgloss.Width(content) + 4).
Render(content)
}
@@ -249,8 +253,11 @@ func (m *modelDialogCmp) getScrollIndicators(maxWidth int) string {
return ""
}
return styles.BaseStyle.
Foreground(styles.PrimaryColor).
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
return baseStyle.
Foreground(t.Primary()).
Width(maxWidth).
Align(lipgloss.Right).
Bold(true).
@@ -263,20 +270,23 @@ func (m *modelDialogCmp) BindingKeys() []key.Binding {
func (m *modelDialogCmp) setupModels() {
cfg := config.Get()
modelInfo := GetSelectedModel(cfg)
m.availableProviders = getEnabledProviders(cfg)
m.hScrollPossible = len(m.availableProviders) > 1
agentCfg := cfg.Agents[config.AgentCoder]
selectedModelId := agentCfg.Model
modelInfo := models.SupportedModels[selectedModelId]
m.provider = modelInfo.Provider
m.hScrollOffset = findProviderIndex(m.availableProviders, m.provider)
m.setupModelsForProvider(m.provider)
}
func GetSelectedModel(cfg *config.Config) models.Model {
agentCfg := cfg.Agents[config.AgentCoder]
selectedModelId := agentCfg.Model
return models.SupportedModels[selectedModelId]
}
func getEnabledProviders(cfg *config.Config) []models.ModelProvider {
var providers []models.ModelProvider
for providerId, provider := range cfg.Providers {

View File

@@ -2,19 +2,18 @@ package dialog
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/lipgloss"
"github.com/opencode-ai/opencode/internal/diff"
"github.com/opencode-ai/opencode/internal/llm/tools"
"github.com/opencode-ai/opencode/internal/permission"
"github.com/opencode-ai/opencode/internal/tui/layout"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/tui/util"
"strings"
)
type PermissionAction string
@@ -149,25 +148,28 @@ func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
}
func (p *permissionDialogCmp) renderButtons() string {
allowStyle := styles.BaseStyle
allowSessionStyle := styles.BaseStyle
denyStyle := styles.BaseStyle
spacerStyle := styles.BaseStyle.Background(styles.Background)
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
allowStyle := baseStyle
allowSessionStyle := baseStyle
denyStyle := baseStyle
spacerStyle := baseStyle.Background(t.Background())
// Style the selected button
switch p.selectedOption {
case 0:
allowStyle = allowStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
allowSessionStyle = allowSessionStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
denyStyle = denyStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
allowStyle = allowStyle.Background(t.Primary()).Foreground(t.Background())
allowSessionStyle = allowSessionStyle.Background(t.Background()).Foreground(t.Primary())
denyStyle = denyStyle.Background(t.Background()).Foreground(t.Primary())
case 1:
allowStyle = allowStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
allowSessionStyle = allowSessionStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
denyStyle = denyStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
allowStyle = allowStyle.Background(t.Background()).Foreground(t.Primary())
allowSessionStyle = allowSessionStyle.Background(t.Primary()).Foreground(t.Background())
denyStyle = denyStyle.Background(t.Background()).Foreground(t.Primary())
case 2:
allowStyle = allowStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
allowSessionStyle = allowSessionStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
denyStyle = denyStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
allowStyle = allowStyle.Background(t.Background()).Foreground(t.Primary())
allowSessionStyle = allowSessionStyle.Background(t.Background()).Foreground(t.Primary())
denyStyle = denyStyle.Background(t.Primary()).Foreground(t.Background())
}
allowButton := allowStyle.Padding(0, 1).Render("Allow (a)")
@@ -192,15 +194,18 @@ func (p *permissionDialogCmp) renderButtons() string {
}
func (p *permissionDialogCmp) renderHeader() string {
toolKey := styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("Tool")
toolValue := styles.BaseStyle.
Foreground(styles.Forground).
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
toolKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("Tool")
toolValue := baseStyle.
Foreground(t.Text()).
Width(p.width - lipgloss.Width(toolKey)).
Render(fmt.Sprintf(": %s", p.permission.ToolName))
pathKey := styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("Path")
pathValue := styles.BaseStyle.
Foreground(styles.Forground).
pathKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("Path")
pathValue := baseStyle.
Foreground(t.Text()).
Width(p.width - lipgloss.Width(pathKey)).
Render(fmt.Sprintf(": %s", p.permission.Path))
@@ -210,45 +215,45 @@ func (p *permissionDialogCmp) renderHeader() string {
toolKey,
toolValue,
),
styles.BaseStyle.Render(strings.Repeat(" ", p.width)),
baseStyle.Render(strings.Repeat(" ", p.width)),
lipgloss.JoinHorizontal(
lipgloss.Left,
pathKey,
pathValue,
),
styles.BaseStyle.Render(strings.Repeat(" ", p.width)),
baseStyle.Render(strings.Repeat(" ", p.width)),
}
// Add tool-specific header information
switch p.permission.ToolName {
case tools.BashToolName:
headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("Command"))
headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Command"))
case tools.EditToolName:
headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("Diff"))
headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Diff"))
case tools.WriteToolName:
headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("Diff"))
headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Diff"))
case tools.FetchToolName:
headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("URL"))
headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("URL"))
}
return lipgloss.NewStyle().Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
return lipgloss.NewStyle().Background(t.Background()).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
}
func (p *permissionDialogCmp) renderBashContent() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
// Use the cache for markdown rendering
renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
r, _ := glamour.NewTermRenderer(
glamour.WithStyles(styles.MarkdownTheme(true)),
glamour.WithWordWrap(p.width-10),
)
r := styles.GetMarkdownRenderer(p.width-10)
s, err := r.Render(content)
return styles.ForceReplaceBackgroundWithLipgloss(s, styles.Background), err
return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
})
finalContent := styles.BaseStyle.
finalContent := baseStyle.
Width(p.contentViewPort.Width).
Render(renderedContent)
p.contentViewPort.SetContent(finalContent)
@@ -295,39 +300,45 @@ func (p *permissionDialogCmp) renderWriteContent() string {
}
func (p *permissionDialogCmp) renderFetchContent() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
// Use the cache for markdown rendering
renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
r, _ := glamour.NewTermRenderer(
glamour.WithStyles(styles.MarkdownTheme(true)),
glamour.WithWordWrap(p.width-10),
)
r := styles.GetMarkdownRenderer(p.width-10)
s, err := r.Render(content)
return styles.ForceReplaceBackgroundWithLipgloss(s, styles.Background), err
return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
})
p.contentViewPort.SetContent(renderedContent)
finalContent := baseStyle.
Width(p.contentViewPort.Width).
Render(renderedContent)
p.contentViewPort.SetContent(finalContent)
return p.styleViewport()
}
return ""
}
func (p *permissionDialogCmp) renderDefaultContent() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
content := p.permission.Description
// Use the cache for markdown rendering
renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
r, _ := glamour.NewTermRenderer(
glamour.WithStyles(styles.CatppuccinMarkdownStyle()),
glamour.WithWordWrap(p.width-10),
)
r := styles.GetMarkdownRenderer(p.width-10)
s, err := r.Render(content)
return styles.ForceReplaceBackgroundWithLipgloss(s, styles.Background), err
return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
})
p.contentViewPort.SetContent(renderedContent)
finalContent := baseStyle.
Width(p.contentViewPort.Width).
Render(renderedContent)
p.contentViewPort.SetContent(finalContent)
if renderedContent == "" {
return ""
@@ -337,17 +348,21 @@ func (p *permissionDialogCmp) renderDefaultContent() string {
}
func (p *permissionDialogCmp) styleViewport() string {
t := theme.CurrentTheme()
contentStyle := lipgloss.NewStyle().
Background(styles.Background)
Background(t.Background())
return contentStyle.Render(p.contentViewPort.View())
}
func (p *permissionDialogCmp) render() string {
title := styles.BaseStyle.
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
title := baseStyle.
Bold(true).
Width(p.width - 4).
Foreground(styles.PrimaryColor).
Foreground(t.Primary()).
Render("Permission Required")
// Render header
headerContent := p.renderHeader()
@@ -378,18 +393,18 @@ func (p *permissionDialogCmp) render() string {
content := lipgloss.JoinVertical(
lipgloss.Top,
title,
styles.BaseStyle.Render(strings.Repeat(" ", lipgloss.Width(title))),
baseStyle.Render(strings.Repeat(" ", lipgloss.Width(title))),
headerContent,
contentFinal,
buttons,
styles.BaseStyle.Render(strings.Repeat(" ", p.width-4)),
baseStyle.Render(strings.Repeat(" ", p.width-4)),
)
return styles.BaseStyle.
return baseStyle.
Padding(1, 0, 0, 1).
Border(lipgloss.RoundedBorder()).
BorderBackground(styles.Background).
BorderForeground(styles.ForgroundDim).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Width(p.width).
Height(p.height).
Render(

View File

@@ -8,6 +8,7 @@ import (
"github.com/charmbracelet/lipgloss"
"github.com/opencode-ai/opencode/internal/tui/layout"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/tui/util"
)
@@ -81,16 +82,19 @@ func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (q *quitDialogCmp) View() string {
yesStyle := styles.BaseStyle
noStyle := styles.BaseStyle
spacerStyle := styles.BaseStyle.Background(styles.Background)
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
yesStyle := baseStyle
noStyle := baseStyle
spacerStyle := baseStyle.Background(t.Background())
if q.selectedNo {
noStyle = noStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
yesStyle = yesStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
noStyle = noStyle.Background(t.Primary()).Foreground(t.Background())
yesStyle = yesStyle.Background(t.Background()).Foreground(t.Primary())
} else {
yesStyle = yesStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
noStyle = noStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
yesStyle = yesStyle.Background(t.Primary()).Foreground(t.Background())
noStyle = noStyle.Background(t.Background()).Foreground(t.Primary())
}
yesButton := yesStyle.Padding(0, 1).Render("Yes")
@@ -104,7 +108,7 @@ func (q *quitDialogCmp) View() string {
buttons = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + buttons
}
content := styles.BaseStyle.Render(
content := baseStyle.Render(
lipgloss.JoinVertical(
lipgloss.Center,
question,
@@ -113,10 +117,10 @@ func (q *quitDialogCmp) View() string {
),
)
return styles.BaseStyle.Padding(1, 2).
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(styles.Background).
BorderForeground(styles.ForgroundDim).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Width(lipgloss.Width(content) + 4).
Render(content)
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/opencode-ai/opencode/internal/session"
"github.com/opencode-ai/opencode/internal/tui/layout"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/tui/util"
)
@@ -105,11 +106,14 @@ func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (s *sessionDialogCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
if len(s.sessions) == 0 {
return styles.BaseStyle.Padding(1, 2).
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(styles.Background).
BorderForeground(styles.ForgroundDim).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Width(40).
Render("No sessions available")
}
@@ -146,20 +150,20 @@ func (s *sessionDialogCmp) View() string {
for i := startIdx; i < endIdx; i++ {
sess := s.sessions[i]
itemStyle := styles.BaseStyle.Width(maxWidth)
itemStyle := baseStyle.Width(maxWidth)
if i == s.selectedIdx {
itemStyle = itemStyle.
Background(styles.PrimaryColor).
Foreground(styles.Background).
Background(t.Primary()).
Foreground(t.Background()).
Bold(true)
}
sessionItems = append(sessionItems, itemStyle.Padding(0, 1).Render(sess.Title))
}
title := styles.BaseStyle.
Foreground(styles.PrimaryColor).
title := baseStyle.
Foreground(t.Primary()).
Bold(true).
Width(maxWidth).
Padding(0, 1).
@@ -168,15 +172,15 @@ func (s *sessionDialogCmp) View() string {
content := lipgloss.JoinVertical(
lipgloss.Left,
title,
styles.BaseStyle.Width(maxWidth).Render(""),
styles.BaseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, sessionItems...)),
styles.BaseStyle.Width(maxWidth).Render(""),
baseStyle.Width(maxWidth).Render(""),
baseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, sessionItems...)),
baseStyle.Width(maxWidth).Render(""),
)
return styles.BaseStyle.Padding(1, 2).
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(styles.Background).
BorderForeground(styles.ForgroundDim).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Width(lipgloss.Width(content) + 4).
Render(content)
}

View File

@@ -0,0 +1,198 @@
package dialog
import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/opencode-ai/opencode/internal/tui/layout"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/tui/util"
)
// ThemeChangedMsg is sent when the theme is changed
type ThemeChangedMsg struct {
ThemeName string
}
// CloseThemeDialogMsg is sent when the theme dialog is closed
type CloseThemeDialogMsg struct{}
// ThemeDialog interface for the theme switching dialog
type ThemeDialog interface {
tea.Model
layout.Bindings
}
type themeDialogCmp struct {
themes []string
selectedIdx int
width int
height int
currentTheme string
}
type themeKeyMap struct {
Up key.Binding
Down key.Binding
Enter key.Binding
Escape key.Binding
J key.Binding
K key.Binding
}
var themeKeys = themeKeyMap{
Up: key.NewBinding(
key.WithKeys("up"),
key.WithHelp("↑", "previous theme"),
),
Down: key.NewBinding(
key.WithKeys("down"),
key.WithHelp("↓", "next theme"),
),
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "select theme"),
),
Escape: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "close"),
),
J: key.NewBinding(
key.WithKeys("j"),
key.WithHelp("j", "next theme"),
),
K: key.NewBinding(
key.WithKeys("k"),
key.WithHelp("k", "previous theme"),
),
}
func (t *themeDialogCmp) Init() tea.Cmd {
// Load available themes and update selectedIdx based on current theme
t.themes = theme.AvailableThemes()
t.currentTheme = theme.CurrentThemeName()
// Find the current theme in the list
for i, name := range t.themes {
if name == t.currentTheme {
t.selectedIdx = i
break
}
}
return nil
}
func (t *themeDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, themeKeys.Up) || key.Matches(msg, themeKeys.K):
if t.selectedIdx > 0 {
t.selectedIdx--
}
return t, nil
case key.Matches(msg, themeKeys.Down) || key.Matches(msg, themeKeys.J):
if t.selectedIdx < len(t.themes)-1 {
t.selectedIdx++
}
return t, nil
case key.Matches(msg, themeKeys.Enter):
if len(t.themes) > 0 {
previousTheme := theme.CurrentThemeName()
selectedTheme := t.themes[t.selectedIdx]
if previousTheme == selectedTheme {
return t, util.CmdHandler(CloseThemeDialogMsg{})
}
if err := theme.SetTheme(selectedTheme); err != nil {
return t, util.ReportError(err)
}
return t, util.CmdHandler(ThemeChangedMsg{
ThemeName: selectedTheme,
})
}
case key.Matches(msg, themeKeys.Escape):
return t, util.CmdHandler(CloseThemeDialogMsg{})
}
case tea.WindowSizeMsg:
t.width = msg.Width
t.height = msg.Height
}
return t, nil
}
func (t *themeDialogCmp) View() string {
currentTheme := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
if len(t.themes) == 0 {
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(currentTheme.Background()).
BorderForeground(currentTheme.TextMuted()).
Width(40).
Render("No themes available")
}
// Calculate max width needed for theme names
maxWidth := 40 // Minimum width
for _, themeName := range t.themes {
if len(themeName) > maxWidth-4 { // Account for padding
maxWidth = len(themeName) + 4
}
}
maxWidth = max(30, min(maxWidth, t.width-15)) // Limit width to avoid overflow
// Build the theme list
themeItems := make([]string, 0, len(t.themes))
for i, themeName := range t.themes {
itemStyle := baseStyle.Width(maxWidth)
if i == t.selectedIdx {
itemStyle = itemStyle.
Background(currentTheme.Primary()).
Foreground(currentTheme.Background()).
Bold(true)
}
themeItems = append(themeItems, itemStyle.Padding(0, 1).Render(themeName))
}
title := baseStyle.
Foreground(currentTheme.Primary()).
Bold(true).
Width(maxWidth).
Padding(0, 1).
Render("Select Theme")
content := lipgloss.JoinVertical(
lipgloss.Left,
title,
baseStyle.Width(maxWidth).Render(""),
baseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, themeItems...)),
baseStyle.Width(maxWidth).Render(""),
)
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(currentTheme.Background()).
BorderForeground(currentTheme.TextMuted()).
Width(lipgloss.Width(content) + 4).
Render(content)
}
func (t *themeDialogCmp) BindingKeys() []key.Binding {
return layout.KeyMapToSlice(themeKeys)
}
// NewThemeDialogCmp creates a new theme switching dialog
func NewThemeDialogCmp() ThemeDialog {
return &themeDialogCmp{
themes: []string{},
selectedIdx: 0,
currentTheme: "",
}
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/opencode-ai/opencode/internal/logging"
"github.com/opencode-ai/opencode/internal/tui/layout"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
)
type DetailComponent interface {
@@ -49,9 +50,10 @@ func (i *detailCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (i *detailCmp) updateContent() {
var content strings.Builder
t := theme.CurrentTheme()
// Format the header with timestamp and level
timeStyle := lipgloss.NewStyle().Foreground(styles.SubText0)
timeStyle := lipgloss.NewStyle().Foreground(t.TextMuted())
levelStyle := getLevelStyle(i.currentLog.Level)
header := lipgloss.JoinHorizontal(
@@ -65,7 +67,7 @@ func (i *detailCmp) updateContent() {
content.WriteString("\n\n")
// Message with styling
messageStyle := lipgloss.NewStyle().Bold(true).Foreground(styles.Text)
messageStyle := lipgloss.NewStyle().Bold(true).Foreground(t.Text())
content.WriteString(messageStyle.Render("Message:"))
content.WriteString("\n")
content.WriteString(lipgloss.NewStyle().Padding(0, 2).Render(i.currentLog.Message))
@@ -73,13 +75,13 @@ func (i *detailCmp) updateContent() {
// Attributes section
if len(i.currentLog.Attributes) > 0 {
attrHeaderStyle := lipgloss.NewStyle().Bold(true).Foreground(styles.Text)
attrHeaderStyle := lipgloss.NewStyle().Bold(true).Foreground(t.Text())
content.WriteString(attrHeaderStyle.Render("Attributes:"))
content.WriteString("\n")
// Create a table-like display for attributes
keyStyle := lipgloss.NewStyle().Foreground(styles.Primary).Bold(true)
valueStyle := lipgloss.NewStyle().Foreground(styles.Text)
keyStyle := lipgloss.NewStyle().Foreground(t.Primary()).Bold(true)
valueStyle := lipgloss.NewStyle().Foreground(t.Text())
for _, attr := range i.currentLog.Attributes {
attrLine := fmt.Sprintf("%s: %s",
@@ -96,23 +98,25 @@ func (i *detailCmp) updateContent() {
func getLevelStyle(level string) lipgloss.Style {
style := lipgloss.NewStyle().Bold(true)
t := theme.CurrentTheme()
switch strings.ToLower(level) {
case "info":
return style.Foreground(styles.Blue)
return style.Foreground(t.Info())
case "warn", "warning":
return style.Foreground(styles.Warning)
return style.Foreground(t.Warning())
case "error", "err":
return style.Foreground(styles.Error)
return style.Foreground(t.Error())
case "debug":
return style.Foreground(styles.Green)
return style.Foreground(t.Success())
default:
return style.Foreground(styles.Text)
return style.Foreground(t.Text())
}
}
func (i *detailCmp) View() string {
return styles.ForceReplaceBackgroundWithLipgloss(i.viewport.View(), styles.Background)
t := theme.CurrentTheme()
return styles.ForceReplaceBackgroundWithLipgloss(i.viewport.View(), t.Background())
}
func (i *detailCmp) GetSize() (int, int) {

View File

@@ -11,6 +11,7 @@ import (
"github.com/opencode-ai/opencode/internal/pubsub"
"github.com/opencode-ai/opencode/internal/tui/layout"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/tui/util"
)
@@ -61,7 +62,11 @@ func (i *tableCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (i *tableCmp) View() string {
return styles.ForceReplaceBackgroundWithLipgloss(i.table.View(), styles.Background)
t := theme.CurrentTheme()
defaultStyles := table.DefaultStyles()
defaultStyles.Selected = defaultStyles.Selected.Foreground(t.Primary())
i.table.SetStyles(defaultStyles)
return styles.ForceReplaceBackgroundWithLipgloss(i.table.View(), t.Background())
}
func (i *tableCmp) GetSize() (int, int) {
@@ -121,11 +126,9 @@ func NewLogsTable() TableComponent {
{Title: "Message", Width: 10},
{Title: "Attributes", Width: 10},
}
defaultStyles := table.DefaultStyles()
defaultStyles.Selected = defaultStyles.Selected.Foreground(styles.Primary)
tableModel := table.New(
table.WithColumns(columns),
table.WithStyles(defaultStyles),
)
tableModel.Focus()
return &tableCmp{

View File

@@ -0,0 +1,72 @@
package image
import (
"fmt"
"image"
"os"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/disintegration/imaging"
"github.com/lucasb-eyer/go-colorful"
)
func ValidateFileSize(filePath string, sizeLimit int64) (bool, error) {
fileInfo, err := os.Stat(filePath)
if err != nil {
return false, fmt.Errorf("error getting file info: %w", err)
}
if fileInfo.Size() > sizeLimit {
return true, nil
}
return false, nil
}
func ToString(width int, img image.Image) string {
img = imaging.Resize(img, width, 0, imaging.Lanczos)
b := img.Bounds()
imageWidth := b.Max.X
h := b.Max.Y
str := strings.Builder{}
for heightCounter := 0; heightCounter < h; heightCounter += 2 {
for x := range imageWidth {
c1, _ := colorful.MakeColor(img.At(x, heightCounter))
color1 := lipgloss.Color(c1.Hex())
var color2 lipgloss.Color
if heightCounter+1 < h {
c2, _ := colorful.MakeColor(img.At(x, heightCounter+1))
color2 = lipgloss.Color(c2.Hex())
} else {
color2 = color1
}
str.WriteString(lipgloss.NewStyle().Foreground(color1).
Background(color2).Render("▀"))
}
str.WriteString("\n")
}
return str.String()
}
func ImagePreview(width int, filename string) (string, error) {
imageContent, err := os.Open(filename)
if err != nil {
return "", err
}
defer imageContent.Close()
img, _, err := image.Decode(imageContent)
if err != nil {
return "", err
}
imageString := ToString(width, img)
return imageString, nil
}

View File

@@ -4,7 +4,7 @@ import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
)
type Container interface {
@@ -29,9 +29,6 @@ type container struct {
borderBottom bool
borderLeft bool
borderStyle lipgloss.Border
borderColor lipgloss.TerminalColor
backgroundColor lipgloss.TerminalColor
}
func (c *container) Init() tea.Cmd {
@@ -45,13 +42,12 @@ func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (c *container) View() string {
t := theme.CurrentTheme()
style := lipgloss.NewStyle()
width := c.width
height := c.height
// Apply background color if specified
if c.backgroundColor != nil {
style = style.Background(c.backgroundColor)
}
style = style.Background(t.Background())
// Apply border if any side is enabled
if c.borderTop || c.borderRight || c.borderBottom || c.borderLeft {
@@ -69,11 +65,7 @@ func (c *container) View() string {
width--
}
style = style.Border(c.borderStyle, c.borderTop, c.borderRight, c.borderBottom, c.borderLeft)
// Apply border color if specified
if c.borderColor != nil {
style = style.BorderBackground(c.backgroundColor).BorderForeground(c.borderColor)
}
style = style.BorderBackground(t.Background()).BorderForeground(t.BorderNormal())
}
style = style.
Width(width).
@@ -132,11 +124,10 @@ func (c *container) BindingKeys() []key.Binding {
type ContainerOption func(*container)
func NewContainer(content tea.Model, options ...ContainerOption) Container {
c := &container{
content: content,
borderColor: styles.BorderColor,
borderStyle: lipgloss.NormalBorder(),
backgroundColor: styles.Background,
content: content,
borderStyle: lipgloss.NormalBorder(),
}
for _, option := range options {
@@ -201,12 +192,6 @@ func WithBorderStyle(style lipgloss.Border) ContainerOption {
}
}
func WithBorderColor(color lipgloss.TerminalColor) ContainerOption {
return func(c *container) {
c.borderColor = color
}
}
func WithRoundedBorder() ContainerOption {
return WithBorderStyle(lipgloss.RoundedBorder())
}
@@ -218,9 +203,3 @@ func WithThickBorder() ContainerOption {
func WithDoubleBorder() ContainerOption {
return WithBorderStyle(lipgloss.DoubleBorder())
}
func WithBackgroundColor(color lipgloss.TerminalColor) ContainerOption {
return func(c *container) {
c.backgroundColor = color
}
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/muesli/reflow/truncate"
"github.com/muesli/termenv"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/tui/util"
)
@@ -43,12 +44,15 @@ func PlaceOverlay(
fgHeight := len(fgLines)
if shadow {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
var shadowbg string = ""
shadowchar := lipgloss.NewStyle().
Background(styles.BackgroundDarker).
Foreground(styles.Background).
Background(t.BackgroundDarker()).
Foreground(t.Background()).
Render("░")
bgchar := styles.BaseStyle.Render(" ")
bgchar := baseStyle.Render(" ")
for i := 0; i <= fgHeight; i++ {
if i == 0 {
shadowbg += bgchar + strings.Repeat(bgchar, fgWidth) + "\n"

View File

@@ -4,7 +4,7 @@ import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
)
type SplitPaneLayout interface {
@@ -29,8 +29,6 @@ type splitPaneLayout struct {
rightPanel Container
leftPanel Container
bottomPanel Container
backgroundColor lipgloss.TerminalColor
}
type SplitPaneOption func(*splitPaneLayout)
@@ -113,11 +111,13 @@ func (s *splitPaneLayout) View() string {
finalView = topSection
}
if s.backgroundColor != nil && finalView != "" {
if finalView != "" {
t := theme.CurrentTheme()
style := lipgloss.NewStyle().
Width(s.width).
Height(s.height).
Background(s.backgroundColor)
Background(t.Background())
return style.Render(finalView)
}
@@ -241,10 +241,10 @@ func (s *splitPaneLayout) BindingKeys() []key.Binding {
}
func NewSplitPane(options ...SplitPaneOption) SplitPaneLayout {
layout := &splitPaneLayout{
ratio: 0.7,
verticalRatio: 0.9, // Default 80% for top section, 20% for bottom
backgroundColor: styles.Background,
ratio: 0.7,
verticalRatio: 0.9, // Default 90% for top section, 10% for bottom
}
for _, option := range options {
option(layout)
@@ -270,12 +270,6 @@ func WithRatio(ratio float64) SplitPaneOption {
}
}
func WithSplitBackgroundColor(color lipgloss.TerminalColor) SplitPaneOption {
return func(s *splitPaneLayout) {
s.backgroundColor = color
}
}
func WithBottomPanel(panel Container) SplitPaneOption {
return func(s *splitPaneLayout) {
s.bottomPanel = panel

View File

@@ -6,8 +6,10 @@ import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/opencode-ai/opencode/internal/app"
"github.com/opencode-ai/opencode/internal/message"
"github.com/opencode-ai/opencode/internal/session"
"github.com/opencode-ai/opencode/internal/tui/components/chat"
"github.com/opencode-ai/opencode/internal/tui/components/dialog"
"github.com/opencode-ai/opencode/internal/tui/layout"
"github.com/opencode-ai/opencode/internal/tui/util"
)
@@ -52,7 +54,17 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmd := p.layout.SetSize(msg.Width, msg.Height)
cmds = append(cmds, cmd)
case chat.SendMsg:
cmd := p.sendMessage(msg.Text)
cmd := p.sendMessage(msg.Text, msg.Attachments)
if cmd != nil {
return p, cmd
}
case dialog.CommandRunCustomMsg:
// Check if the agent is busy before executing custom commands
if p.app.CoderAgent.IsBusy() {
return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
}
// Handle custom command execution
cmd := p.sendMessage(msg.Content, nil)
if cmd != nil {
return p, cmd
}
@@ -99,7 +111,7 @@ func (p *chatPage) clearSidebar() tea.Cmd {
return p.layout.ClearRightPanel()
}
func (p *chatPage) sendMessage(text string) tea.Cmd {
func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
var cmds []tea.Cmd
if p.session.ID == "" {
session, err := p.app.Sessions.Create(context.Background(), "New Session")
@@ -115,7 +127,10 @@ func (p *chatPage) sendMessage(text string) tea.Cmd {
cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
}
p.app.CoderAgent.Run(context.Background(), p.session.ID, text)
_, err := p.app.CoderAgent.Run(context.Background(), p.session.ID, text, attachments...)
if err != nil {
return util.ReportError(err)
}
return tea.Batch(cmds...)
}
@@ -134,6 +149,7 @@ func (p *chatPage) View() string {
func (p *chatPage) BindingKeys() []key.Binding {
bindings := layout.KeyMapToSlice(keyMap)
bindings = append(bindings, p.messages.BindingKeys()...)
bindings = append(bindings, p.editor.BindingKeys()...)
return bindings
}
@@ -142,7 +158,6 @@ func NewChatPage(app *app.App) tea.Model {
chat.NewMessagesCmp(app),
layout.WithPadding(1, 1, 0, 1),
)
editorContainer := layout.NewContainer(
chat.NewEditorCmp(app),
layout.WithBorder(true, false, false, false),

View File

@@ -42,7 +42,7 @@ func (p *logsPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (p *logsPage) View() string {
style := styles.BaseStyle.Width(p.width).Height(p.height)
style := styles.BaseStyle().Width(p.width).Height(p.height)
return style.Render(lipgloss.JoinVertical(lipgloss.Top,
p.table.View(),
p.details.View(),
@@ -77,7 +77,7 @@ func (p *logsPage) Init() tea.Cmd {
func NewLogsPage() LogPage {
return &logsPage{
table: layout.NewContainer(logs.NewLogsTable(), layout.WithBorderAll(), layout.WithBorderColor(styles.ForgroundDim)),
details: layout.NewContainer(logs.NewLogsDetails(), layout.WithBorderAll(), layout.WithBorderColor(styles.ForgroundDim)),
table: layout.NewContainer(logs.NewLogsTable(), layout.WithBorderAll()),
details: layout.NewContainer(logs.NewLogsDetails(), layout.WithBorderAll()),
}
}

View File

@@ -1,46 +0,0 @@
package styles
import (
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
)
func HuhTheme() *huh.Theme {
t := huh.ThemeBase()
t.Focused.Base = t.Focused.Base.BorderStyle(lipgloss.HiddenBorder())
t.Focused.Title = t.Focused.Title.Foreground(Text)
t.Focused.NoteTitle = t.Focused.NoteTitle.Foreground(Text)
t.Focused.Directory = t.Focused.Directory.Foreground(Text)
t.Focused.Description = t.Focused.Description.Foreground(SubText0)
t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(Red)
t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(Red)
t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(Blue)
t.Focused.NextIndicator = t.Focused.NextIndicator.Foreground(Blue)
t.Focused.PrevIndicator = t.Focused.PrevIndicator.Foreground(Blue)
t.Focused.Option = t.Focused.Option.Foreground(Text)
t.Focused.MultiSelectSelector = t.Focused.MultiSelectSelector.Foreground(Blue)
t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(Green)
t.Focused.SelectedPrefix = t.Focused.SelectedPrefix.Foreground(Green)
t.Focused.UnselectedPrefix = t.Focused.UnselectedPrefix.Foreground(Text)
t.Focused.UnselectedOption = t.Focused.UnselectedOption.Foreground(Text)
t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(Base).Background(Blue)
t.Focused.BlurredButton = t.Focused.BlurredButton.Foreground(Text).Background(Base)
t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(Teal)
t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(Overlay0)
t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(Blue)
t.Blurred = t.Focused
t.Blurred.Base = t.Blurred.Base.BorderStyle(lipgloss.HiddenBorder())
t.Help.Ellipsis = t.Help.Ellipsis.Foreground(SubText0)
t.Help.ShortKey = t.Help.ShortKey.Foreground(SubText0)
t.Help.ShortDesc = t.Help.ShortDesc.Foreground(Ovelay1)
t.Help.ShortSeparator = t.Help.ShortSeparator.Foreground(SubText0)
t.Help.FullKey = t.Help.FullKey.Foreground(SubText0)
t.Help.FullDesc = t.Help.FullDesc.Foreground(Ovelay1)
t.Help.FullSeparator = t.Help.FullSeparator.Foreground(SubText0)
return t
}

View File

@@ -3,11 +3,12 @@ package styles
const (
OpenCodeIcon string = "⌬"
CheckIcon string = "✓"
ErrorIcon string = "✖"
WarningIcon string = "⚠"
InfoIcon string = ""
HintIcon string = "i"
SpinnerIcon string = "..."
LoadingIcon string = "⟳"
)
CheckIcon string = "✓"
ErrorIcon string = "✖"
WarningIcon string = "⚠"
InfoIcon string = ""
HintIcon string = "i"
SpinnerIcon string = "..."
LoadingIcon string = "⟳"
DocumentIcon string = "🖼"
)

File diff suppressed because it is too large Load Diff

View File

@@ -1,177 +1,155 @@
package styles
import (
catppuccin "github.com/catppuccin/go"
"github.com/charmbracelet/lipgloss"
"github.com/opencode-ai/opencode/internal/tui/theme"
)
var (
light = catppuccin.Latte
dark = catppuccin.Mocha
ImageBakcground = "#212121"
)
// NEW STYLES
var (
Background = lipgloss.AdaptiveColor{
Dark: "#212121",
Light: "#212121",
}
BackgroundDim = lipgloss.AdaptiveColor{
Dark: "#2c2c2c",
Light: "#2c2c2c",
}
BackgroundDarker = lipgloss.AdaptiveColor{
Dark: "#181818",
Light: "#181818",
}
BorderColor = lipgloss.AdaptiveColor{
Dark: "#4b4c5c",
Light: "#4b4c5c",
}
// Style generation functions that use the current theme
Forground = lipgloss.AdaptiveColor{
Dark: "#d3d3d3",
Light: "#d3d3d3",
}
// BaseStyle returns the base style with background and foreground colors
func BaseStyle() lipgloss.Style {
t := theme.CurrentTheme()
return lipgloss.NewStyle().
Background(t.Background()).
Foreground(t.Text())
}
ForgroundMid = lipgloss.AdaptiveColor{
Dark: "#a0a0a0",
Light: "#a0a0a0",
}
// Regular returns a basic unstyled lipgloss.Style
func Regular() lipgloss.Style {
return lipgloss.NewStyle()
}
ForgroundDim = lipgloss.AdaptiveColor{
Dark: "#737373",
Light: "#737373",
}
// Bold returns a bold style
func Bold() lipgloss.Style {
return Regular().Bold(true)
}
BaseStyle = lipgloss.NewStyle().
Background(Background).
Foreground(Forground)
// Padded returns a style with horizontal padding
func Padded() lipgloss.Style {
return Regular().Padding(0, 1)
}
PrimaryColor = lipgloss.AdaptiveColor{
Dark: "#fab283",
Light: "#fab283",
}
)
// Border returns a style with a normal border
func Border() lipgloss.Style {
t := theme.CurrentTheme()
return Regular().
Border(lipgloss.NormalBorder()).
BorderForeground(t.BorderNormal())
}
var (
Regular = lipgloss.NewStyle()
Bold = Regular.Bold(true)
Padded = Regular.Padding(0, 1)
// ThickBorder returns a style with a thick border
func ThickBorder() lipgloss.Style {
t := theme.CurrentTheme()
return Regular().
Border(lipgloss.ThickBorder()).
BorderForeground(t.BorderNormal())
}
Border = Regular.Border(lipgloss.NormalBorder())
ThickBorder = Regular.Border(lipgloss.ThickBorder())
DoubleBorder = Regular.Border(lipgloss.DoubleBorder())
// DoubleBorder returns a style with a double border
func DoubleBorder() lipgloss.Style {
t := theme.CurrentTheme()
return Regular().
Border(lipgloss.DoubleBorder()).
BorderForeground(t.BorderNormal())
}
// Colors
White = lipgloss.Color("#ffffff")
Surface0 = lipgloss.AdaptiveColor{
Dark: dark.Surface0().Hex,
Light: light.Surface0().Hex,
}
// 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.BorderFocused())
}
Overlay0 = lipgloss.AdaptiveColor{
Dark: dark.Overlay0().Hex,
Light: light.Overlay0().Hex,
}
// 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.BorderDim())
}
Ovelay1 = lipgloss.AdaptiveColor{
Dark: dark.Overlay1().Hex,
Light: light.Overlay1().Hex,
}
// PrimaryColor returns the primary color from the current theme
func PrimaryColor() lipgloss.AdaptiveColor {
return theme.CurrentTheme().Primary()
}
Text = lipgloss.AdaptiveColor{
Dark: dark.Text().Hex,
Light: light.Text().Hex,
}
// SecondaryColor returns the secondary color from the current theme
func SecondaryColor() lipgloss.AdaptiveColor {
return theme.CurrentTheme().Secondary()
}
SubText0 = lipgloss.AdaptiveColor{
Dark: dark.Subtext0().Hex,
Light: light.Subtext0().Hex,
}
// AccentColor returns the accent color from the current theme
func AccentColor() lipgloss.AdaptiveColor {
return theme.CurrentTheme().Accent()
}
SubText1 = lipgloss.AdaptiveColor{
Dark: dark.Subtext1().Hex,
Light: light.Subtext1().Hex,
}
// ErrorColor returns the error color from the current theme
func ErrorColor() lipgloss.AdaptiveColor {
return theme.CurrentTheme().Error()
}
LightGrey = lipgloss.AdaptiveColor{
Dark: dark.Surface0().Hex,
Light: light.Surface0().Hex,
}
Grey = lipgloss.AdaptiveColor{
Dark: dark.Surface1().Hex,
Light: light.Surface1().Hex,
}
// WarningColor returns the warning color from the current theme
func WarningColor() lipgloss.AdaptiveColor {
return theme.CurrentTheme().Warning()
}
DarkGrey = lipgloss.AdaptiveColor{
Dark: dark.Surface2().Hex,
Light: light.Surface2().Hex,
}
// SuccessColor returns the success color from the current theme
func SuccessColor() lipgloss.AdaptiveColor {
return theme.CurrentTheme().Success()
}
Base = lipgloss.AdaptiveColor{
Dark: dark.Base().Hex,
Light: light.Base().Hex,
}
// InfoColor returns the info color from the current theme
func InfoColor() lipgloss.AdaptiveColor {
return theme.CurrentTheme().Info()
}
Crust = lipgloss.AdaptiveColor{
Dark: dark.Crust().Hex,
Light: light.Crust().Hex,
}
// TextColor returns the text color from the current theme
func TextColor() lipgloss.AdaptiveColor {
return theme.CurrentTheme().Text()
}
Blue = lipgloss.AdaptiveColor{
Dark: dark.Blue().Hex,
Light: light.Blue().Hex,
}
// TextMutedColor returns the muted text color from the current theme
func TextMutedColor() lipgloss.AdaptiveColor {
return theme.CurrentTheme().TextMuted()
}
Red = lipgloss.AdaptiveColor{
Dark: dark.Red().Hex,
Light: light.Red().Hex,
}
// TextEmphasizedColor returns the emphasized text color from the current theme
func TextEmphasizedColor() lipgloss.AdaptiveColor {
return theme.CurrentTheme().TextEmphasized()
}
Green = lipgloss.AdaptiveColor{
Dark: dark.Green().Hex,
Light: light.Green().Hex,
}
// BackgroundColor returns the background color from the current theme
func BackgroundColor() lipgloss.AdaptiveColor {
return theme.CurrentTheme().Background()
}
Mauve = lipgloss.AdaptiveColor{
Dark: dark.Mauve().Hex,
Light: light.Mauve().Hex,
}
// BackgroundSecondaryColor returns the secondary background color from the current theme
func BackgroundSecondaryColor() lipgloss.AdaptiveColor {
return theme.CurrentTheme().BackgroundSecondary()
}
Teal = lipgloss.AdaptiveColor{
Dark: dark.Teal().Hex,
Light: light.Teal().Hex,
}
// BackgroundDarkerColor returns the darker background color from the current theme
func BackgroundDarkerColor() lipgloss.AdaptiveColor {
return theme.CurrentTheme().BackgroundDarker()
}
Rosewater = lipgloss.AdaptiveColor{
Dark: dark.Rosewater().Hex,
Light: light.Rosewater().Hex,
}
// BorderNormalColor returns the normal border color from the current theme
func BorderNormalColor() lipgloss.AdaptiveColor {
return theme.CurrentTheme().BorderNormal()
}
Flamingo = lipgloss.AdaptiveColor{
Dark: dark.Flamingo().Hex,
Light: light.Flamingo().Hex,
}
// BorderFocusedColor returns the focused border color from the current theme
func BorderFocusedColor() lipgloss.AdaptiveColor {
return theme.CurrentTheme().BorderFocused()
}
Lavender = lipgloss.AdaptiveColor{
Dark: dark.Lavender().Hex,
Light: light.Lavender().Hex,
}
Peach = lipgloss.AdaptiveColor{
Dark: dark.Peach().Hex,
Light: light.Peach().Hex,
}
Yellow = lipgloss.AdaptiveColor{
Dark: dark.Yellow().Hex,
Light: light.Yellow().Hex,
}
Primary = Blue
Secondary = Mauve
Warning = Peach
Error = Red
)
// BorderDimColor returns the dim border color from the current theme
func BorderDimColor() lipgloss.AdaptiveColor {
return theme.CurrentTheme().BorderDim()
}

View File

@@ -0,0 +1,248 @@
package theme
import (
catppuccin "github.com/catppuccin/go"
"github.com/charmbracelet/lipgloss"
)
// CatppuccinTheme implements the Theme interface with Catppuccin colors.
// It provides both dark (Mocha) and light (Latte) variants.
type CatppuccinTheme struct {
BaseTheme
}
// NewCatppuccinTheme creates a new instance of the Catppuccin theme.
func NewCatppuccinTheme() *CatppuccinTheme {
// Get the Catppuccin palettes
mocha := catppuccin.Mocha
latte := catppuccin.Latte
theme := &CatppuccinTheme{}
// Base colors
theme.PrimaryColor = lipgloss.AdaptiveColor{
Dark: mocha.Blue().Hex,
Light: latte.Blue().Hex,
}
theme.SecondaryColor = lipgloss.AdaptiveColor{
Dark: mocha.Mauve().Hex,
Light: latte.Mauve().Hex,
}
theme.AccentColor = lipgloss.AdaptiveColor{
Dark: mocha.Peach().Hex,
Light: latte.Peach().Hex,
}
// Status colors
theme.ErrorColor = lipgloss.AdaptiveColor{
Dark: mocha.Red().Hex,
Light: latte.Red().Hex,
}
theme.WarningColor = lipgloss.AdaptiveColor{
Dark: mocha.Peach().Hex,
Light: latte.Peach().Hex,
}
theme.SuccessColor = lipgloss.AdaptiveColor{
Dark: mocha.Green().Hex,
Light: latte.Green().Hex,
}
theme.InfoColor = lipgloss.AdaptiveColor{
Dark: mocha.Blue().Hex,
Light: latte.Blue().Hex,
}
// Text colors
theme.TextColor = lipgloss.AdaptiveColor{
Dark: mocha.Text().Hex,
Light: latte.Text().Hex,
}
theme.TextMutedColor = lipgloss.AdaptiveColor{
Dark: mocha.Subtext0().Hex,
Light: latte.Subtext0().Hex,
}
theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
Dark: mocha.Lavender().Hex,
Light: latte.Lavender().Hex,
}
// Background colors
theme.BackgroundColor = lipgloss.AdaptiveColor{
Dark: "#212121", // From existing styles
Light: "#EEEEEE", // Light equivalent
}
theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
Dark: "#2c2c2c", // From existing styles
Light: "#E0E0E0", // Light equivalent
}
theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
Dark: "#181818", // From existing styles
Light: "#F5F5F5", // Light equivalent
}
// Border colors
theme.BorderNormalColor = lipgloss.AdaptiveColor{
Dark: "#4b4c5c", // From existing styles
Light: "#BDBDBD", // Light equivalent
}
theme.BorderFocusedColor = lipgloss.AdaptiveColor{
Dark: mocha.Blue().Hex,
Light: latte.Blue().Hex,
}
theme.BorderDimColor = lipgloss.AdaptiveColor{
Dark: mocha.Surface0().Hex,
Light: latte.Surface0().Hex,
}
// Diff view colors
theme.DiffAddedColor = lipgloss.AdaptiveColor{
Dark: "#478247", // From existing diff.go
Light: "#2E7D32", // Light equivalent
}
theme.DiffRemovedColor = lipgloss.AdaptiveColor{
Dark: "#7C4444", // From existing diff.go
Light: "#C62828", // Light equivalent
}
theme.DiffContextColor = lipgloss.AdaptiveColor{
Dark: "#a0a0a0", // From existing diff.go
Light: "#757575", // Light equivalent
}
theme.DiffHunkHeaderColor = lipgloss.AdaptiveColor{
Dark: "#a0a0a0", // From existing diff.go
Light: "#757575", // Light equivalent
}
theme.DiffHighlightAddedColor = lipgloss.AdaptiveColor{
Dark: "#DAFADA", // From existing diff.go
Light: "#A5D6A7", // Light equivalent
}
theme.DiffHighlightRemovedColor = lipgloss.AdaptiveColor{
Dark: "#FADADD", // From existing diff.go
Light: "#EF9A9A", // Light equivalent
}
theme.DiffAddedBgColor = lipgloss.AdaptiveColor{
Dark: "#303A30", // From existing diff.go
Light: "#E8F5E9", // Light equivalent
}
theme.DiffRemovedBgColor = lipgloss.AdaptiveColor{
Dark: "#3A3030", // From existing diff.go
Light: "#FFEBEE", // Light equivalent
}
theme.DiffContextBgColor = lipgloss.AdaptiveColor{
Dark: "#212121", // From existing diff.go
Light: "#F5F5F5", // Light equivalent
}
theme.DiffLineNumberColor = lipgloss.AdaptiveColor{
Dark: "#888888", // From existing diff.go
Light: "#9E9E9E", // Light equivalent
}
theme.DiffAddedLineNumberBgColor = lipgloss.AdaptiveColor{
Dark: "#293229", // From existing diff.go
Light: "#C8E6C9", // Light equivalent
}
theme.DiffRemovedLineNumberBgColor = lipgloss.AdaptiveColor{
Dark: "#332929", // From existing diff.go
Light: "#FFCDD2", // Light equivalent
}
// Markdown colors
theme.MarkdownTextColor = lipgloss.AdaptiveColor{
Dark: mocha.Text().Hex,
Light: latte.Text().Hex,
}
theme.MarkdownHeadingColor = lipgloss.AdaptiveColor{
Dark: mocha.Mauve().Hex,
Light: latte.Mauve().Hex,
}
theme.MarkdownLinkColor = lipgloss.AdaptiveColor{
Dark: mocha.Sky().Hex,
Light: latte.Sky().Hex,
}
theme.MarkdownLinkTextColor = lipgloss.AdaptiveColor{
Dark: mocha.Pink().Hex,
Light: latte.Pink().Hex,
}
theme.MarkdownCodeColor = lipgloss.AdaptiveColor{
Dark: mocha.Green().Hex,
Light: latte.Green().Hex,
}
theme.MarkdownBlockQuoteColor = lipgloss.AdaptiveColor{
Dark: mocha.Yellow().Hex,
Light: latte.Yellow().Hex,
}
theme.MarkdownEmphColor = lipgloss.AdaptiveColor{
Dark: mocha.Yellow().Hex,
Light: latte.Yellow().Hex,
}
theme.MarkdownStrongColor = lipgloss.AdaptiveColor{
Dark: mocha.Peach().Hex,
Light: latte.Peach().Hex,
}
theme.MarkdownHorizontalRuleColor = lipgloss.AdaptiveColor{
Dark: mocha.Overlay0().Hex,
Light: latte.Overlay0().Hex,
}
theme.MarkdownListItemColor = lipgloss.AdaptiveColor{
Dark: mocha.Blue().Hex,
Light: latte.Blue().Hex,
}
theme.MarkdownListEnumerationColor = lipgloss.AdaptiveColor{
Dark: mocha.Sky().Hex,
Light: latte.Sky().Hex,
}
theme.MarkdownImageColor = lipgloss.AdaptiveColor{
Dark: mocha.Sapphire().Hex,
Light: latte.Sapphire().Hex,
}
theme.MarkdownImageTextColor = lipgloss.AdaptiveColor{
Dark: mocha.Pink().Hex,
Light: latte.Pink().Hex,
}
theme.MarkdownCodeBlockColor = lipgloss.AdaptiveColor{
Dark: mocha.Text().Hex,
Light: latte.Text().Hex,
}
// Syntax highlighting colors
theme.SyntaxCommentColor = lipgloss.AdaptiveColor{
Dark: mocha.Overlay1().Hex,
Light: latte.Overlay1().Hex,
}
theme.SyntaxKeywordColor = lipgloss.AdaptiveColor{
Dark: mocha.Pink().Hex,
Light: latte.Pink().Hex,
}
theme.SyntaxFunctionColor = lipgloss.AdaptiveColor{
Dark: mocha.Green().Hex,
Light: latte.Green().Hex,
}
theme.SyntaxVariableColor = lipgloss.AdaptiveColor{
Dark: mocha.Sky().Hex,
Light: latte.Sky().Hex,
}
theme.SyntaxStringColor = lipgloss.AdaptiveColor{
Dark: mocha.Yellow().Hex,
Light: latte.Yellow().Hex,
}
theme.SyntaxNumberColor = lipgloss.AdaptiveColor{
Dark: mocha.Teal().Hex,
Light: latte.Teal().Hex,
}
theme.SyntaxTypeColor = lipgloss.AdaptiveColor{
Dark: mocha.Sky().Hex,
Light: latte.Sky().Hex,
}
theme.SyntaxOperatorColor = lipgloss.AdaptiveColor{
Dark: mocha.Pink().Hex,
Light: latte.Pink().Hex,
}
theme.SyntaxPunctuationColor = lipgloss.AdaptiveColor{
Dark: mocha.Text().Hex,
Light: latte.Text().Hex,
}
return theme
}
func init() {
// Register the Catppuccin theme with the theme manager
RegisterTheme("catppuccin", NewCatppuccinTheme())
}

View File

@@ -0,0 +1,274 @@
package theme
import (
"github.com/charmbracelet/lipgloss"
)
// DraculaTheme implements the Theme interface with Dracula colors.
// It provides both dark and light variants, though Dracula is primarily a dark theme.
type DraculaTheme struct {
BaseTheme
}
// NewDraculaTheme creates a new instance of the Dracula theme.
func NewDraculaTheme() *DraculaTheme {
// Dracula color palette
// Official colors from https://draculatheme.com/
darkBackground := "#282a36"
darkCurrentLine := "#44475a"
darkSelection := "#44475a"
darkForeground := "#f8f8f2"
darkComment := "#6272a4"
darkCyan := "#8be9fd"
darkGreen := "#50fa7b"
darkOrange := "#ffb86c"
darkPink := "#ff79c6"
darkPurple := "#bd93f9"
darkRed := "#ff5555"
darkYellow := "#f1fa8c"
darkBorder := "#44475a"
// Light mode approximation (Dracula is primarily a dark theme)
lightBackground := "#f8f8f2"
lightCurrentLine := "#e6e6e6"
lightSelection := "#d8d8d8"
lightForeground := "#282a36"
lightComment := "#6272a4"
lightCyan := "#0097a7"
lightGreen := "#388e3c"
lightOrange := "#f57c00"
lightPink := "#d81b60"
lightPurple := "#7e57c2"
lightRed := "#e53935"
lightYellow := "#fbc02d"
lightBorder := "#d8d8d8"
theme := &DraculaTheme{}
// Base colors
theme.PrimaryColor = lipgloss.AdaptiveColor{
Dark: darkPurple,
Light: lightPurple,
}
theme.SecondaryColor = lipgloss.AdaptiveColor{
Dark: darkPink,
Light: lightPink,
}
theme.AccentColor = lipgloss.AdaptiveColor{
Dark: darkCyan,
Light: lightCyan,
}
// Status colors
theme.ErrorColor = lipgloss.AdaptiveColor{
Dark: darkRed,
Light: lightRed,
}
theme.WarningColor = lipgloss.AdaptiveColor{
Dark: darkOrange,
Light: lightOrange,
}
theme.SuccessColor = lipgloss.AdaptiveColor{
Dark: darkGreen,
Light: lightGreen,
}
theme.InfoColor = lipgloss.AdaptiveColor{
Dark: darkCyan,
Light: lightCyan,
}
// Text colors
theme.TextColor = lipgloss.AdaptiveColor{
Dark: darkForeground,
Light: lightForeground,
}
theme.TextMutedColor = lipgloss.AdaptiveColor{
Dark: darkComment,
Light: lightComment,
}
theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
Dark: darkYellow,
Light: lightYellow,
}
// Background colors
theme.BackgroundColor = lipgloss.AdaptiveColor{
Dark: darkBackground,
Light: lightBackground,
}
theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
Dark: darkCurrentLine,
Light: lightCurrentLine,
}
theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
Dark: "#21222c", // Slightly darker than background
Light: "#ffffff", // Slightly lighter than background
}
// Border colors
theme.BorderNormalColor = lipgloss.AdaptiveColor{
Dark: darkBorder,
Light: lightBorder,
}
theme.BorderFocusedColor = lipgloss.AdaptiveColor{
Dark: darkPurple,
Light: lightPurple,
}
theme.BorderDimColor = lipgloss.AdaptiveColor{
Dark: darkSelection,
Light: lightSelection,
}
// Diff view colors
theme.DiffAddedColor = lipgloss.AdaptiveColor{
Dark: darkGreen,
Light: lightGreen,
}
theme.DiffRemovedColor = lipgloss.AdaptiveColor{
Dark: darkRed,
Light: lightRed,
}
theme.DiffContextColor = lipgloss.AdaptiveColor{
Dark: darkComment,
Light: lightComment,
}
theme.DiffHunkHeaderColor = lipgloss.AdaptiveColor{
Dark: darkPurple,
Light: lightPurple,
}
theme.DiffHighlightAddedColor = lipgloss.AdaptiveColor{
Dark: "#50fa7b",
Light: "#a5d6a7",
}
theme.DiffHighlightRemovedColor = lipgloss.AdaptiveColor{
Dark: "#ff5555",
Light: "#ef9a9a",
}
theme.DiffAddedBgColor = lipgloss.AdaptiveColor{
Dark: "#2c3b2c",
Light: "#e8f5e9",
}
theme.DiffRemovedBgColor = lipgloss.AdaptiveColor{
Dark: "#3b2c2c",
Light: "#ffebee",
}
theme.DiffContextBgColor = lipgloss.AdaptiveColor{
Dark: darkBackground,
Light: lightBackground,
}
theme.DiffLineNumberColor = lipgloss.AdaptiveColor{
Dark: darkComment,
Light: lightComment,
}
theme.DiffAddedLineNumberBgColor = lipgloss.AdaptiveColor{
Dark: "#253025",
Light: "#c8e6c9",
}
theme.DiffRemovedLineNumberBgColor = lipgloss.AdaptiveColor{
Dark: "#302525",
Light: "#ffcdd2",
}
// Markdown colors
theme.MarkdownTextColor = lipgloss.AdaptiveColor{
Dark: darkForeground,
Light: lightForeground,
}
theme.MarkdownHeadingColor = lipgloss.AdaptiveColor{
Dark: darkPink,
Light: lightPink,
}
theme.MarkdownLinkColor = lipgloss.AdaptiveColor{
Dark: darkPurple,
Light: lightPurple,
}
theme.MarkdownLinkTextColor = lipgloss.AdaptiveColor{
Dark: darkCyan,
Light: lightCyan,
}
theme.MarkdownCodeColor = lipgloss.AdaptiveColor{
Dark: darkGreen,
Light: lightGreen,
}
theme.MarkdownBlockQuoteColor = lipgloss.AdaptiveColor{
Dark: darkYellow,
Light: lightYellow,
}
theme.MarkdownEmphColor = lipgloss.AdaptiveColor{
Dark: darkYellow,
Light: lightYellow,
}
theme.MarkdownStrongColor = lipgloss.AdaptiveColor{
Dark: darkOrange,
Light: lightOrange,
}
theme.MarkdownHorizontalRuleColor = lipgloss.AdaptiveColor{
Dark: darkComment,
Light: lightComment,
}
theme.MarkdownListItemColor = lipgloss.AdaptiveColor{
Dark: darkPurple,
Light: lightPurple,
}
theme.MarkdownListEnumerationColor = lipgloss.AdaptiveColor{
Dark: darkCyan,
Light: lightCyan,
}
theme.MarkdownImageColor = lipgloss.AdaptiveColor{
Dark: darkPurple,
Light: lightPurple,
}
theme.MarkdownImageTextColor = lipgloss.AdaptiveColor{
Dark: darkCyan,
Light: lightCyan,
}
theme.MarkdownCodeBlockColor = lipgloss.AdaptiveColor{
Dark: darkForeground,
Light: lightForeground,
}
// Syntax highlighting colors
theme.SyntaxCommentColor = lipgloss.AdaptiveColor{
Dark: darkComment,
Light: lightComment,
}
theme.SyntaxKeywordColor = lipgloss.AdaptiveColor{
Dark: darkPink,
Light: lightPink,
}
theme.SyntaxFunctionColor = lipgloss.AdaptiveColor{
Dark: darkGreen,
Light: lightGreen,
}
theme.SyntaxVariableColor = lipgloss.AdaptiveColor{
Dark: darkOrange,
Light: lightOrange,
}
theme.SyntaxStringColor = lipgloss.AdaptiveColor{
Dark: darkYellow,
Light: lightYellow,
}
theme.SyntaxNumberColor = lipgloss.AdaptiveColor{
Dark: darkPurple,
Light: lightPurple,
}
theme.SyntaxTypeColor = lipgloss.AdaptiveColor{
Dark: darkCyan,
Light: lightCyan,
}
theme.SyntaxOperatorColor = lipgloss.AdaptiveColor{
Dark: darkPink,
Light: lightPink,
}
theme.SyntaxPunctuationColor = lipgloss.AdaptiveColor{
Dark: darkForeground,
Light: lightForeground,
}
return theme
}
func init() {
// Register the Dracula theme with the theme manager
RegisterTheme("dracula", NewDraculaTheme())
}

View File

@@ -0,0 +1,282 @@
package theme
import (
"github.com/charmbracelet/lipgloss"
)
// Flexoki color palette constants
const (
// Base colors
flexokiPaper = "#FFFCF0" // Paper (lightest)
flexokiBase50 = "#F2F0E5" // bg-2 (light)
flexokiBase100 = "#E6E4D9" // ui (light)
flexokiBase150 = "#DAD8CE" // ui-2 (light)
flexokiBase200 = "#CECDC3" // ui-3 (light)
flexokiBase300 = "#B7B5AC" // tx-3 (light)
flexokiBase500 = "#878580" // tx-2 (light)
flexokiBase600 = "#6F6E69" // tx (light)
flexokiBase700 = "#575653" // tx-3 (dark)
flexokiBase800 = "#403E3C" // ui-3 (dark)
flexokiBase850 = "#343331" // ui-2 (dark)
flexokiBase900 = "#282726" // ui (dark)
flexokiBase950 = "#1C1B1A" // bg-2 (dark)
flexokiBlack = "#100F0F" // bg (darkest)
// Accent colors - Light theme (600)
flexokiRed600 = "#AF3029"
flexokiOrange600 = "#BC5215"
flexokiYellow600 = "#AD8301"
flexokiGreen600 = "#66800B"
flexokiCyan600 = "#24837B"
flexokiBlue600 = "#205EA6"
flexokiPurple600 = "#5E409D"
flexokiMagenta600 = "#A02F6F"
// Accent colors - Dark theme (400)
flexokiRed400 = "#D14D41"
flexokiOrange400 = "#DA702C"
flexokiYellow400 = "#D0A215"
flexokiGreen400 = "#879A39"
flexokiCyan400 = "#3AA99F"
flexokiBlue400 = "#4385BE"
flexokiPurple400 = "#8B7EC8"
flexokiMagenta400 = "#CE5D97"
)
// FlexokiTheme implements the Theme interface with Flexoki colors.
// It provides both dark and light variants.
type FlexokiTheme struct {
BaseTheme
}
// NewFlexokiTheme creates a new instance of the Flexoki theme.
func NewFlexokiTheme() *FlexokiTheme {
theme := &FlexokiTheme{}
// Base colors
theme.PrimaryColor = lipgloss.AdaptiveColor{
Dark: flexokiBlue400,
Light: flexokiBlue600,
}
theme.SecondaryColor = lipgloss.AdaptiveColor{
Dark: flexokiPurple400,
Light: flexokiPurple600,
}
theme.AccentColor = lipgloss.AdaptiveColor{
Dark: flexokiOrange400,
Light: flexokiOrange600,
}
// Status colors
theme.ErrorColor = lipgloss.AdaptiveColor{
Dark: flexokiRed400,
Light: flexokiRed600,
}
theme.WarningColor = lipgloss.AdaptiveColor{
Dark: flexokiYellow400,
Light: flexokiYellow600,
}
theme.SuccessColor = lipgloss.AdaptiveColor{
Dark: flexokiGreen400,
Light: flexokiGreen600,
}
theme.InfoColor = lipgloss.AdaptiveColor{
Dark: flexokiCyan400,
Light: flexokiCyan600,
}
// Text colors
theme.TextColor = lipgloss.AdaptiveColor{
Dark: flexokiBase300,
Light: flexokiBase600,
}
theme.TextMutedColor = lipgloss.AdaptiveColor{
Dark: flexokiBase700,
Light: flexokiBase500,
}
theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
Dark: flexokiYellow400,
Light: flexokiYellow600,
}
// Background colors
theme.BackgroundColor = lipgloss.AdaptiveColor{
Dark: flexokiBlack,
Light: flexokiPaper,
}
theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
Dark: flexokiBase950,
Light: flexokiBase50,
}
theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
Dark: flexokiBase900,
Light: flexokiBase100,
}
// Border colors
theme.BorderNormalColor = lipgloss.AdaptiveColor{
Dark: flexokiBase900,
Light: flexokiBase100,
}
theme.BorderFocusedColor = lipgloss.AdaptiveColor{
Dark: flexokiBlue400,
Light: flexokiBlue600,
}
theme.BorderDimColor = lipgloss.AdaptiveColor{
Dark: flexokiBase850,
Light: flexokiBase150,
}
// Diff view colors
theme.DiffAddedColor = lipgloss.AdaptiveColor{
Dark: flexokiGreen400,
Light: flexokiGreen600,
}
theme.DiffRemovedColor = lipgloss.AdaptiveColor{
Dark: flexokiRed400,
Light: flexokiRed600,
}
theme.DiffContextColor = lipgloss.AdaptiveColor{
Dark: flexokiBase700,
Light: flexokiBase500,
}
theme.DiffHunkHeaderColor = lipgloss.AdaptiveColor{
Dark: flexokiBase700,
Light: flexokiBase500,
}
theme.DiffHighlightAddedColor = lipgloss.AdaptiveColor{
Dark: flexokiGreen400,
Light: flexokiGreen600,
}
theme.DiffHighlightRemovedColor = lipgloss.AdaptiveColor{
Dark: flexokiRed400,
Light: flexokiRed600,
}
theme.DiffAddedBgColor = lipgloss.AdaptiveColor{
Dark: "#1D2419", // Darker green background
Light: "#EFF2E2", // Light green background
}
theme.DiffRemovedBgColor = lipgloss.AdaptiveColor{
Dark: "#241919", // Darker red background
Light: "#F2E2E2", // Light red background
}
theme.DiffContextBgColor = lipgloss.AdaptiveColor{
Dark: flexokiBlack,
Light: flexokiPaper,
}
theme.DiffLineNumberColor = lipgloss.AdaptiveColor{
Dark: flexokiBase700,
Light: flexokiBase500,
}
theme.DiffAddedLineNumberBgColor = lipgloss.AdaptiveColor{
Dark: "#1A2017", // Slightly darker green
Light: "#E5EBD9", // Light green
}
theme.DiffRemovedLineNumberBgColor = lipgloss.AdaptiveColor{
Dark: "#201717", // Slightly darker red
Light: "#EBD9D9", // Light red
}
// Markdown colors
theme.MarkdownTextColor = lipgloss.AdaptiveColor{
Dark: flexokiBase300,
Light: flexokiBase600,
}
theme.MarkdownHeadingColor = lipgloss.AdaptiveColor{
Dark: flexokiYellow400,
Light: flexokiYellow600,
}
theme.MarkdownLinkColor = lipgloss.AdaptiveColor{
Dark: flexokiCyan400,
Light: flexokiCyan600,
}
theme.MarkdownLinkTextColor = lipgloss.AdaptiveColor{
Dark: flexokiMagenta400,
Light: flexokiMagenta600,
}
theme.MarkdownCodeColor = lipgloss.AdaptiveColor{
Dark: flexokiGreen400,
Light: flexokiGreen600,
}
theme.MarkdownBlockQuoteColor = lipgloss.AdaptiveColor{
Dark: flexokiCyan400,
Light: flexokiCyan600,
}
theme.MarkdownEmphColor = lipgloss.AdaptiveColor{
Dark: flexokiYellow400,
Light: flexokiYellow600,
}
theme.MarkdownStrongColor = lipgloss.AdaptiveColor{
Dark: flexokiOrange400,
Light: flexokiOrange600,
}
theme.MarkdownHorizontalRuleColor = lipgloss.AdaptiveColor{
Dark: flexokiBase800,
Light: flexokiBase200,
}
theme.MarkdownListItemColor = lipgloss.AdaptiveColor{
Dark: flexokiBlue400,
Light: flexokiBlue600,
}
theme.MarkdownListEnumerationColor = lipgloss.AdaptiveColor{
Dark: flexokiBlue400,
Light: flexokiBlue600,
}
theme.MarkdownImageColor = lipgloss.AdaptiveColor{
Dark: flexokiPurple400,
Light: flexokiPurple600,
}
theme.MarkdownImageTextColor = lipgloss.AdaptiveColor{
Dark: flexokiMagenta400,
Light: flexokiMagenta600,
}
theme.MarkdownCodeBlockColor = lipgloss.AdaptiveColor{
Dark: flexokiBase300,
Light: flexokiBase600,
}
// Syntax highlighting colors (based on Flexoki's mappings)
theme.SyntaxCommentColor = lipgloss.AdaptiveColor{
Dark: flexokiBase700, // tx-3
Light: flexokiBase300, // tx-3
}
theme.SyntaxKeywordColor = lipgloss.AdaptiveColor{
Dark: flexokiGreen400, // gr
Light: flexokiGreen600, // gr
}
theme.SyntaxFunctionColor = lipgloss.AdaptiveColor{
Dark: flexokiOrange400, // or
Light: flexokiOrange600, // or
}
theme.SyntaxVariableColor = lipgloss.AdaptiveColor{
Dark: flexokiBlue400, // bl
Light: flexokiBlue600, // bl
}
theme.SyntaxStringColor = lipgloss.AdaptiveColor{
Dark: flexokiCyan400, // cy
Light: flexokiCyan600, // cy
}
theme.SyntaxNumberColor = lipgloss.AdaptiveColor{
Dark: flexokiPurple400, // pu
Light: flexokiPurple600, // pu
}
theme.SyntaxTypeColor = lipgloss.AdaptiveColor{
Dark: flexokiYellow400, // ye
Light: flexokiYellow600, // ye
}
theme.SyntaxOperatorColor = lipgloss.AdaptiveColor{
Dark: flexokiBase500, // tx-2
Light: flexokiBase500, // tx-2
}
theme.SyntaxPunctuationColor = lipgloss.AdaptiveColor{
Dark: flexokiBase500, // tx-2
Light: flexokiBase500, // tx-2
}
return theme
}
func init() {
// Register the Flexoki theme with the theme manager
RegisterTheme("flexoki", NewFlexokiTheme())
}

View File

@@ -0,0 +1,302 @@
package theme
import (
"github.com/charmbracelet/lipgloss"
)
// Gruvbox color palette constants
const (
// Dark theme colors
gruvboxDarkBg0 = "#282828"
gruvboxDarkBg0Soft = "#32302f"
gruvboxDarkBg1 = "#3c3836"
gruvboxDarkBg2 = "#504945"
gruvboxDarkBg3 = "#665c54"
gruvboxDarkBg4 = "#7c6f64"
gruvboxDarkFg0 = "#fbf1c7"
gruvboxDarkFg1 = "#ebdbb2"
gruvboxDarkFg2 = "#d5c4a1"
gruvboxDarkFg3 = "#bdae93"
gruvboxDarkFg4 = "#a89984"
gruvboxDarkGray = "#928374"
gruvboxDarkRed = "#cc241d"
gruvboxDarkRedBright = "#fb4934"
gruvboxDarkGreen = "#98971a"
gruvboxDarkGreenBright = "#b8bb26"
gruvboxDarkYellow = "#d79921"
gruvboxDarkYellowBright = "#fabd2f"
gruvboxDarkBlue = "#458588"
gruvboxDarkBlueBright = "#83a598"
gruvboxDarkPurple = "#b16286"
gruvboxDarkPurpleBright = "#d3869b"
gruvboxDarkAqua = "#689d6a"
gruvboxDarkAquaBright = "#8ec07c"
gruvboxDarkOrange = "#d65d0e"
gruvboxDarkOrangeBright = "#fe8019"
// Light theme colors
gruvboxLightBg0 = "#fbf1c7"
gruvboxLightBg0Soft = "#f2e5bc"
gruvboxLightBg1 = "#ebdbb2"
gruvboxLightBg2 = "#d5c4a1"
gruvboxLightBg3 = "#bdae93"
gruvboxLightBg4 = "#a89984"
gruvboxLightFg0 = "#282828"
gruvboxLightFg1 = "#3c3836"
gruvboxLightFg2 = "#504945"
gruvboxLightFg3 = "#665c54"
gruvboxLightFg4 = "#7c6f64"
gruvboxLightGray = "#928374"
gruvboxLightRed = "#9d0006"
gruvboxLightRedBright = "#cc241d"
gruvboxLightGreen = "#79740e"
gruvboxLightGreenBright = "#98971a"
gruvboxLightYellow = "#b57614"
gruvboxLightYellowBright = "#d79921"
gruvboxLightBlue = "#076678"
gruvboxLightBlueBright = "#458588"
gruvboxLightPurple = "#8f3f71"
gruvboxLightPurpleBright = "#b16286"
gruvboxLightAqua = "#427b58"
gruvboxLightAquaBright = "#689d6a"
gruvboxLightOrange = "#af3a03"
gruvboxLightOrangeBright = "#d65d0e"
)
// GruvboxTheme implements the Theme interface with Gruvbox colors.
// It provides both dark and light variants.
type GruvboxTheme struct {
BaseTheme
}
// NewGruvboxTheme creates a new instance of the Gruvbox theme.
func NewGruvboxTheme() *GruvboxTheme {
theme := &GruvboxTheme{}
// Base colors
theme.PrimaryColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkBlueBright,
Light: gruvboxLightBlueBright,
}
theme.SecondaryColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkPurpleBright,
Light: gruvboxLightPurpleBright,
}
theme.AccentColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkOrangeBright,
Light: gruvboxLightOrangeBright,
}
// Status colors
theme.ErrorColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkRedBright,
Light: gruvboxLightRedBright,
}
theme.WarningColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkYellowBright,
Light: gruvboxLightYellowBright,
}
theme.SuccessColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkGreenBright,
Light: gruvboxLightGreenBright,
}
theme.InfoColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkBlueBright,
Light: gruvboxLightBlueBright,
}
// Text colors
theme.TextColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkFg1,
Light: gruvboxLightFg1,
}
theme.TextMutedColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkFg4,
Light: gruvboxLightFg4,
}
theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkYellowBright,
Light: gruvboxLightYellowBright,
}
// Background colors
theme.BackgroundColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkBg0,
Light: gruvboxLightBg0,
}
theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkBg1,
Light: gruvboxLightBg1,
}
theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkBg0Soft,
Light: gruvboxLightBg0Soft,
}
// Border colors
theme.BorderNormalColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkBg2,
Light: gruvboxLightBg2,
}
theme.BorderFocusedColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkBlueBright,
Light: gruvboxLightBlueBright,
}
theme.BorderDimColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkBg1,
Light: gruvboxLightBg1,
}
// Diff view colors
theme.DiffAddedColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkGreenBright,
Light: gruvboxLightGreenBright,
}
theme.DiffRemovedColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkRedBright,
Light: gruvboxLightRedBright,
}
theme.DiffContextColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkFg4,
Light: gruvboxLightFg4,
}
theme.DiffHunkHeaderColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkFg3,
Light: gruvboxLightFg3,
}
theme.DiffHighlightAddedColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkGreenBright,
Light: gruvboxLightGreenBright,
}
theme.DiffHighlightRemovedColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkRedBright,
Light: gruvboxLightRedBright,
}
theme.DiffAddedBgColor = lipgloss.AdaptiveColor{
Dark: "#3C4C3C", // Darker green background
Light: "#E8F5E9", // Light green background
}
theme.DiffRemovedBgColor = lipgloss.AdaptiveColor{
Dark: "#4C3C3C", // Darker red background
Light: "#FFEBEE", // Light red background
}
theme.DiffContextBgColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkBg0,
Light: gruvboxLightBg0,
}
theme.DiffLineNumberColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkFg4,
Light: gruvboxLightFg4,
}
theme.DiffAddedLineNumberBgColor = lipgloss.AdaptiveColor{
Dark: "#32432F", // Slightly darker green
Light: "#C8E6C9", // Light green
}
theme.DiffRemovedLineNumberBgColor = lipgloss.AdaptiveColor{
Dark: "#43322F", // Slightly darker red
Light: "#FFCDD2", // Light red
}
// Markdown colors
theme.MarkdownTextColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkFg1,
Light: gruvboxLightFg1,
}
theme.MarkdownHeadingColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkYellowBright,
Light: gruvboxLightYellowBright,
}
theme.MarkdownLinkColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkBlueBright,
Light: gruvboxLightBlueBright,
}
theme.MarkdownLinkTextColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkAquaBright,
Light: gruvboxLightAquaBright,
}
theme.MarkdownCodeColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkGreenBright,
Light: gruvboxLightGreenBright,
}
theme.MarkdownBlockQuoteColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkAquaBright,
Light: gruvboxLightAquaBright,
}
theme.MarkdownEmphColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkYellowBright,
Light: gruvboxLightYellowBright,
}
theme.MarkdownStrongColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkOrangeBright,
Light: gruvboxLightOrangeBright,
}
theme.MarkdownHorizontalRuleColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkBg3,
Light: gruvboxLightBg3,
}
theme.MarkdownListItemColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkBlueBright,
Light: gruvboxLightBlueBright,
}
theme.MarkdownListEnumerationColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkBlueBright,
Light: gruvboxLightBlueBright,
}
theme.MarkdownImageColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkPurpleBright,
Light: gruvboxLightPurpleBright,
}
theme.MarkdownImageTextColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkAquaBright,
Light: gruvboxLightAquaBright,
}
theme.MarkdownCodeBlockColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkFg1,
Light: gruvboxLightFg1,
}
// Syntax highlighting colors
theme.SyntaxCommentColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkGray,
Light: gruvboxLightGray,
}
theme.SyntaxKeywordColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkRedBright,
Light: gruvboxLightRedBright,
}
theme.SyntaxFunctionColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkGreenBright,
Light: gruvboxLightGreenBright,
}
theme.SyntaxVariableColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkBlueBright,
Light: gruvboxLightBlueBright,
}
theme.SyntaxStringColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkYellowBright,
Light: gruvboxLightYellowBright,
}
theme.SyntaxNumberColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkPurpleBright,
Light: gruvboxLightPurpleBright,
}
theme.SyntaxTypeColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkYellow,
Light: gruvboxLightYellow,
}
theme.SyntaxOperatorColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkAquaBright,
Light: gruvboxLightAquaBright,
}
theme.SyntaxPunctuationColor = lipgloss.AdaptiveColor{
Dark: gruvboxDarkFg1,
Light: gruvboxLightFg1,
}
return theme
}
func init() {
// Register the Gruvbox theme with the theme manager
RegisterTheme("gruvbox", NewGruvboxTheme())
}

View File

@@ -0,0 +1,118 @@
package theme
import (
"fmt"
"slices"
"strings"
"sync"
"github.com/alecthomas/chroma/v2/styles"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/logging"
)
// 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
}
// Global instance of the theme manager
var globalManager = &Manager{
themes: make(map[string]Theme),
currentName: "",
}
// RegisterTheme adds a new theme to the registry.
// If this is the first theme registered, it becomes the default.
func RegisterTheme(name string, theme Theme) {
globalManager.mu.Lock()
defer globalManager.mu.Unlock()
globalManager.themes[name] = theme
// If this is the first theme, make it the default
if globalManager.currentName == "" {
globalManager.currentName = name
}
}
// SetTheme changes the active theme to the one with the specified name.
// Returns an error if the theme doesn't exist.
func SetTheme(name string) error {
globalManager.mu.Lock()
defer globalManager.mu.Unlock()
delete(styles.Registry, "charm")
if _, exists := globalManager.themes[name]; !exists {
return fmt.Errorf("theme '%s' not found", name)
}
globalManager.currentName = name
// Update the config file using viper
if err := updateConfigTheme(name); err != nil {
// Log the error but don't fail the theme change
logging.Warn("Warning: Failed to update config file with new theme", "err", err)
}
return nil
}
// CurrentTheme returns the currently active theme.
// If no theme is set, it returns nil.
func CurrentTheme() Theme {
globalManager.mu.RLock()
defer globalManager.mu.RUnlock()
if globalManager.currentName == "" {
return nil
}
return globalManager.themes[globalManager.currentName]
}
// CurrentThemeName returns the name of the currently active theme.
func CurrentThemeName() string {
globalManager.mu.RLock()
defer globalManager.mu.RUnlock()
return globalManager.currentName
}
// AvailableThemes returns a list of all registered theme names.
func AvailableThemes() []string {
globalManager.mu.RLock()
defer globalManager.mu.RUnlock()
names := make([]string, 0, len(globalManager.themes))
for name := range globalManager.themes {
names = append(names, name)
}
slices.SortFunc(names, func(a, b string) int {
if a == "opencode" {
return -1
} else if b == "opencode" {
return 1
}
return strings.Compare(a, b)
})
return names
}
// GetTheme returns a specific theme by name.
// Returns nil if the theme doesn't exist.
func GetTheme(name string) Theme {
globalManager.mu.RLock()
defer globalManager.mu.RUnlock()
return globalManager.themes[name]
}
// updateConfigTheme updates the theme setting in the configuration file
func updateConfigTheme(themeName string) error {
// Use the config package to update the theme
return config.UpdateTheme(themeName)
}

View File

@@ -0,0 +1,273 @@
package theme
import (
"github.com/charmbracelet/lipgloss"
)
// MonokaiProTheme implements the Theme interface with Monokai Pro colors.
// It provides both dark and light variants.
type MonokaiProTheme struct {
BaseTheme
}
// NewMonokaiProTheme creates a new instance of the Monokai Pro theme.
func NewMonokaiProTheme() *MonokaiProTheme {
// Monokai Pro color palette (dark mode)
darkBackground := "#2d2a2e"
darkCurrentLine := "#403e41"
darkSelection := "#5b595c"
darkForeground := "#fcfcfa"
darkComment := "#727072"
darkRed := "#ff6188"
darkOrange := "#fc9867"
darkYellow := "#ffd866"
darkGreen := "#a9dc76"
darkCyan := "#78dce8"
darkBlue := "#ab9df2"
darkPurple := "#ab9df2"
darkBorder := "#403e41"
// Light mode colors (adapted from dark)
lightBackground := "#fafafa"
lightCurrentLine := "#f0f0f0"
lightSelection := "#e5e5e6"
lightForeground := "#2d2a2e"
lightComment := "#939293"
lightRed := "#f92672"
lightOrange := "#fd971f"
lightYellow := "#e6db74"
lightGreen := "#9bca65"
lightCyan := "#66d9ef"
lightBlue := "#7e75db"
lightPurple := "#ae81ff"
lightBorder := "#d3d3d3"
theme := &MonokaiProTheme{}
// Base colors
theme.PrimaryColor = lipgloss.AdaptiveColor{
Dark: darkCyan,
Light: lightCyan,
}
theme.SecondaryColor = lipgloss.AdaptiveColor{
Dark: darkPurple,
Light: lightPurple,
}
theme.AccentColor = lipgloss.AdaptiveColor{
Dark: darkOrange,
Light: lightOrange,
}
// Status colors
theme.ErrorColor = lipgloss.AdaptiveColor{
Dark: darkRed,
Light: lightRed,
}
theme.WarningColor = lipgloss.AdaptiveColor{
Dark: darkOrange,
Light: lightOrange,
}
theme.SuccessColor = lipgloss.AdaptiveColor{
Dark: darkGreen,
Light: lightGreen,
}
theme.InfoColor = lipgloss.AdaptiveColor{
Dark: darkBlue,
Light: lightBlue,
}
// Text colors
theme.TextColor = lipgloss.AdaptiveColor{
Dark: darkForeground,
Light: lightForeground,
}
theme.TextMutedColor = lipgloss.AdaptiveColor{
Dark: darkComment,
Light: lightComment,
}
theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
Dark: darkYellow,
Light: lightYellow,
}
// Background colors
theme.BackgroundColor = lipgloss.AdaptiveColor{
Dark: darkBackground,
Light: lightBackground,
}
theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
Dark: darkCurrentLine,
Light: lightCurrentLine,
}
theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
Dark: "#221f22", // Slightly darker than background
Light: "#ffffff", // Slightly lighter than background
}
// Border colors
theme.BorderNormalColor = lipgloss.AdaptiveColor{
Dark: darkBorder,
Light: lightBorder,
}
theme.BorderFocusedColor = lipgloss.AdaptiveColor{
Dark: darkCyan,
Light: lightCyan,
}
theme.BorderDimColor = lipgloss.AdaptiveColor{
Dark: darkSelection,
Light: lightSelection,
}
// Diff view colors
theme.DiffAddedColor = lipgloss.AdaptiveColor{
Dark: "#a9dc76",
Light: "#9bca65",
}
theme.DiffRemovedColor = lipgloss.AdaptiveColor{
Dark: "#ff6188",
Light: "#f92672",
}
theme.DiffContextColor = lipgloss.AdaptiveColor{
Dark: "#a0a0a0",
Light: "#757575",
}
theme.DiffHunkHeaderColor = lipgloss.AdaptiveColor{
Dark: "#a0a0a0",
Light: "#757575",
}
theme.DiffHighlightAddedColor = lipgloss.AdaptiveColor{
Dark: "#c2e7a9",
Light: "#c5e0b4",
}
theme.DiffHighlightRemovedColor = lipgloss.AdaptiveColor{
Dark: "#ff8ca6",
Light: "#ffb3c8",
}
theme.DiffAddedBgColor = lipgloss.AdaptiveColor{
Dark: "#3a4a35",
Light: "#e8f5e9",
}
theme.DiffRemovedBgColor = lipgloss.AdaptiveColor{
Dark: "#4a3439",
Light: "#ffebee",
}
theme.DiffContextBgColor = lipgloss.AdaptiveColor{
Dark: darkBackground,
Light: lightBackground,
}
theme.DiffLineNumberColor = lipgloss.AdaptiveColor{
Dark: "#888888",
Light: "#9e9e9e",
}
theme.DiffAddedLineNumberBgColor = lipgloss.AdaptiveColor{
Dark: "#2d3a28",
Light: "#c8e6c9",
}
theme.DiffRemovedLineNumberBgColor = lipgloss.AdaptiveColor{
Dark: "#3d2a2e",
Light: "#ffcdd2",
}
// Markdown colors
theme.MarkdownTextColor = lipgloss.AdaptiveColor{
Dark: darkForeground,
Light: lightForeground,
}
theme.MarkdownHeadingColor = lipgloss.AdaptiveColor{
Dark: darkPurple,
Light: lightPurple,
}
theme.MarkdownLinkColor = lipgloss.AdaptiveColor{
Dark: darkCyan,
Light: lightCyan,
}
theme.MarkdownLinkTextColor = lipgloss.AdaptiveColor{
Dark: darkBlue,
Light: lightBlue,
}
theme.MarkdownCodeColor = lipgloss.AdaptiveColor{
Dark: darkGreen,
Light: lightGreen,
}
theme.MarkdownBlockQuoteColor = lipgloss.AdaptiveColor{
Dark: darkYellow,
Light: lightYellow,
}
theme.MarkdownEmphColor = lipgloss.AdaptiveColor{
Dark: darkYellow,
Light: lightYellow,
}
theme.MarkdownStrongColor = lipgloss.AdaptiveColor{
Dark: darkOrange,
Light: lightOrange,
}
theme.MarkdownHorizontalRuleColor = lipgloss.AdaptiveColor{
Dark: darkComment,
Light: lightComment,
}
theme.MarkdownListItemColor = lipgloss.AdaptiveColor{
Dark: darkCyan,
Light: lightCyan,
}
theme.MarkdownListEnumerationColor = lipgloss.AdaptiveColor{
Dark: darkBlue,
Light: lightBlue,
}
theme.MarkdownImageColor = lipgloss.AdaptiveColor{
Dark: darkCyan,
Light: lightCyan,
}
theme.MarkdownImageTextColor = lipgloss.AdaptiveColor{
Dark: darkBlue,
Light: lightBlue,
}
theme.MarkdownCodeBlockColor = lipgloss.AdaptiveColor{
Dark: darkForeground,
Light: lightForeground,
}
// Syntax highlighting colors
theme.SyntaxCommentColor = lipgloss.AdaptiveColor{
Dark: darkComment,
Light: lightComment,
}
theme.SyntaxKeywordColor = lipgloss.AdaptiveColor{
Dark: darkRed,
Light: lightRed,
}
theme.SyntaxFunctionColor = lipgloss.AdaptiveColor{
Dark: darkGreen,
Light: lightGreen,
}
theme.SyntaxVariableColor = lipgloss.AdaptiveColor{
Dark: darkForeground,
Light: lightForeground,
}
theme.SyntaxStringColor = lipgloss.AdaptiveColor{
Dark: darkYellow,
Light: lightYellow,
}
theme.SyntaxNumberColor = lipgloss.AdaptiveColor{
Dark: darkPurple,
Light: lightPurple,
}
theme.SyntaxTypeColor = lipgloss.AdaptiveColor{
Dark: darkBlue,
Light: lightBlue,
}
theme.SyntaxOperatorColor = lipgloss.AdaptiveColor{
Dark: darkCyan,
Light: lightCyan,
}
theme.SyntaxPunctuationColor = lipgloss.AdaptiveColor{
Dark: darkForeground,
Light: lightForeground,
}
return theme
}
func init() {
// Register the Monokai Pro theme with the theme manager
RegisterTheme("monokai", NewMonokaiProTheme())
}

View File

@@ -0,0 +1,274 @@
package theme
import (
"github.com/charmbracelet/lipgloss"
)
// OneDarkTheme implements the Theme interface with Atom's One Dark colors.
// It provides both dark and light variants.
type OneDarkTheme struct {
BaseTheme
}
// NewOneDarkTheme creates a new instance of the One Dark theme.
func NewOneDarkTheme() *OneDarkTheme {
// One Dark color palette
// Dark mode colors from Atom One Dark
darkBackground := "#282c34"
darkCurrentLine := "#2c313c"
darkSelection := "#3e4451"
darkForeground := "#abb2bf"
darkComment := "#5c6370"
darkRed := "#e06c75"
darkOrange := "#d19a66"
darkYellow := "#e5c07b"
darkGreen := "#98c379"
darkCyan := "#56b6c2"
darkBlue := "#61afef"
darkPurple := "#c678dd"
darkBorder := "#3b4048"
// Light mode colors from Atom One Light
lightBackground := "#fafafa"
lightCurrentLine := "#f0f0f0"
lightSelection := "#e5e5e6"
lightForeground := "#383a42"
lightComment := "#a0a1a7"
lightRed := "#e45649"
lightOrange := "#da8548"
lightYellow := "#c18401"
lightGreen := "#50a14f"
lightCyan := "#0184bc"
lightBlue := "#4078f2"
lightPurple := "#a626a4"
lightBorder := "#d3d3d3"
theme := &OneDarkTheme{}
// Base colors
theme.PrimaryColor = lipgloss.AdaptiveColor{
Dark: darkBlue,
Light: lightBlue,
}
theme.SecondaryColor = lipgloss.AdaptiveColor{
Dark: darkPurple,
Light: lightPurple,
}
theme.AccentColor = lipgloss.AdaptiveColor{
Dark: darkOrange,
Light: lightOrange,
}
// Status colors
theme.ErrorColor = lipgloss.AdaptiveColor{
Dark: darkRed,
Light: lightRed,
}
theme.WarningColor = lipgloss.AdaptiveColor{
Dark: darkOrange,
Light: lightOrange,
}
theme.SuccessColor = lipgloss.AdaptiveColor{
Dark: darkGreen,
Light: lightGreen,
}
theme.InfoColor = lipgloss.AdaptiveColor{
Dark: darkBlue,
Light: lightBlue,
}
// Text colors
theme.TextColor = lipgloss.AdaptiveColor{
Dark: darkForeground,
Light: lightForeground,
}
theme.TextMutedColor = lipgloss.AdaptiveColor{
Dark: darkComment,
Light: lightComment,
}
theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
Dark: darkYellow,
Light: lightYellow,
}
// Background colors
theme.BackgroundColor = lipgloss.AdaptiveColor{
Dark: darkBackground,
Light: lightBackground,
}
theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
Dark: darkCurrentLine,
Light: lightCurrentLine,
}
theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
Dark: "#21252b", // Slightly darker than background
Light: "#ffffff", // Slightly lighter than background
}
// Border colors
theme.BorderNormalColor = lipgloss.AdaptiveColor{
Dark: darkBorder,
Light: lightBorder,
}
theme.BorderFocusedColor = lipgloss.AdaptiveColor{
Dark: darkBlue,
Light: lightBlue,
}
theme.BorderDimColor = lipgloss.AdaptiveColor{
Dark: darkSelection,
Light: lightSelection,
}
// Diff view colors
theme.DiffAddedColor = lipgloss.AdaptiveColor{
Dark: "#478247",
Light: "#2E7D32",
}
theme.DiffRemovedColor = lipgloss.AdaptiveColor{
Dark: "#7C4444",
Light: "#C62828",
}
theme.DiffContextColor = lipgloss.AdaptiveColor{
Dark: "#a0a0a0",
Light: "#757575",
}
theme.DiffHunkHeaderColor = lipgloss.AdaptiveColor{
Dark: "#a0a0a0",
Light: "#757575",
}
theme.DiffHighlightAddedColor = lipgloss.AdaptiveColor{
Dark: "#DAFADA",
Light: "#A5D6A7",
}
theme.DiffHighlightRemovedColor = lipgloss.AdaptiveColor{
Dark: "#FADADD",
Light: "#EF9A9A",
}
theme.DiffAddedBgColor = lipgloss.AdaptiveColor{
Dark: "#303A30",
Light: "#E8F5E9",
}
theme.DiffRemovedBgColor = lipgloss.AdaptiveColor{
Dark: "#3A3030",
Light: "#FFEBEE",
}
theme.DiffContextBgColor = lipgloss.AdaptiveColor{
Dark: darkBackground,
Light: lightBackground,
}
theme.DiffLineNumberColor = lipgloss.AdaptiveColor{
Dark: "#888888",
Light: "#9E9E9E",
}
theme.DiffAddedLineNumberBgColor = lipgloss.AdaptiveColor{
Dark: "#293229",
Light: "#C8E6C9",
}
theme.DiffRemovedLineNumberBgColor = lipgloss.AdaptiveColor{
Dark: "#332929",
Light: "#FFCDD2",
}
// Markdown colors
theme.MarkdownTextColor = lipgloss.AdaptiveColor{
Dark: darkForeground,
Light: lightForeground,
}
theme.MarkdownHeadingColor = lipgloss.AdaptiveColor{
Dark: darkPurple,
Light: lightPurple,
}
theme.MarkdownLinkColor = lipgloss.AdaptiveColor{
Dark: darkBlue,
Light: lightBlue,
}
theme.MarkdownLinkTextColor = lipgloss.AdaptiveColor{
Dark: darkCyan,
Light: lightCyan,
}
theme.MarkdownCodeColor = lipgloss.AdaptiveColor{
Dark: darkGreen,
Light: lightGreen,
}
theme.MarkdownBlockQuoteColor = lipgloss.AdaptiveColor{
Dark: darkYellow,
Light: lightYellow,
}
theme.MarkdownEmphColor = lipgloss.AdaptiveColor{
Dark: darkYellow,
Light: lightYellow,
}
theme.MarkdownStrongColor = lipgloss.AdaptiveColor{
Dark: darkOrange,
Light: lightOrange,
}
theme.MarkdownHorizontalRuleColor = lipgloss.AdaptiveColor{
Dark: darkComment,
Light: lightComment,
}
theme.MarkdownListItemColor = lipgloss.AdaptiveColor{
Dark: darkBlue,
Light: lightBlue,
}
theme.MarkdownListEnumerationColor = lipgloss.AdaptiveColor{
Dark: darkCyan,
Light: lightCyan,
}
theme.MarkdownImageColor = lipgloss.AdaptiveColor{
Dark: darkBlue,
Light: lightBlue,
}
theme.MarkdownImageTextColor = lipgloss.AdaptiveColor{
Dark: darkCyan,
Light: lightCyan,
}
theme.MarkdownCodeBlockColor = lipgloss.AdaptiveColor{
Dark: darkForeground,
Light: lightForeground,
}
// Syntax highlighting colors
theme.SyntaxCommentColor = lipgloss.AdaptiveColor{
Dark: darkComment,
Light: lightComment,
}
theme.SyntaxKeywordColor = lipgloss.AdaptiveColor{
Dark: darkPurple,
Light: lightPurple,
}
theme.SyntaxFunctionColor = lipgloss.AdaptiveColor{
Dark: darkBlue,
Light: lightBlue,
}
theme.SyntaxVariableColor = lipgloss.AdaptiveColor{
Dark: darkRed,
Light: lightRed,
}
theme.SyntaxStringColor = lipgloss.AdaptiveColor{
Dark: darkGreen,
Light: lightGreen,
}
theme.SyntaxNumberColor = lipgloss.AdaptiveColor{
Dark: darkOrange,
Light: lightOrange,
}
theme.SyntaxTypeColor = lipgloss.AdaptiveColor{
Dark: darkYellow,
Light: lightYellow,
}
theme.SyntaxOperatorColor = lipgloss.AdaptiveColor{
Dark: darkCyan,
Light: lightCyan,
}
theme.SyntaxPunctuationColor = lipgloss.AdaptiveColor{
Dark: darkForeground,
Light: lightForeground,
}
return theme
}
func init() {
// Register the One Dark theme with the theme manager
RegisterTheme("onedark", NewOneDarkTheme())
}

View File

@@ -0,0 +1,277 @@
package theme
import (
"github.com/charmbracelet/lipgloss"
)
// OpenCodeTheme implements the Theme interface with OpenCode brand colors.
// It provides both dark and light variants.
type OpenCodeTheme struct {
BaseTheme
}
// NewOpenCodeTheme creates a new instance of the OpenCode theme.
func NewOpenCodeTheme() *OpenCodeTheme {
// OpenCode color palette
// Dark mode colors
darkBackground := "#212121"
darkCurrentLine := "#252525"
darkSelection := "#303030"
darkForeground := "#e0e0e0"
darkComment := "#6a6a6a"
darkPrimary := "#fab283" // Primary orange/gold
darkSecondary := "#5c9cf5" // Secondary blue
darkAccent := "#9d7cd8" // Accent purple
darkRed := "#e06c75" // Error red
darkOrange := "#f5a742" // Warning orange
darkGreen := "#7fd88f" // Success green
darkCyan := "#56b6c2" // Info cyan
darkYellow := "#e5c07b" // Emphasized text
darkBorder := "#4b4c5c" // Border color
// Light mode colors
lightBackground := "#f8f8f8"
lightCurrentLine := "#f0f0f0"
lightSelection := "#e5e5e6"
lightForeground := "#2a2a2a"
lightComment := "#8a8a8a"
lightPrimary := "#3b7dd8" // Primary blue
lightSecondary := "#7b5bb6" // Secondary purple
lightAccent := "#d68c27" // Accent orange/gold
lightRed := "#d1383d" // Error red
lightOrange := "#d68c27" // Warning orange
lightGreen := "#3d9a57" // Success green
lightCyan := "#318795" // Info cyan
lightYellow := "#b0851f" // Emphasized text
lightBorder := "#d3d3d3" // Border color
theme := &OpenCodeTheme{}
// Base colors
theme.PrimaryColor = lipgloss.AdaptiveColor{
Dark: darkPrimary,
Light: lightPrimary,
}
theme.SecondaryColor = lipgloss.AdaptiveColor{
Dark: darkSecondary,
Light: lightSecondary,
}
theme.AccentColor = lipgloss.AdaptiveColor{
Dark: darkAccent,
Light: lightAccent,
}
// Status colors
theme.ErrorColor = lipgloss.AdaptiveColor{
Dark: darkRed,
Light: lightRed,
}
theme.WarningColor = lipgloss.AdaptiveColor{
Dark: darkOrange,
Light: lightOrange,
}
theme.SuccessColor = lipgloss.AdaptiveColor{
Dark: darkGreen,
Light: lightGreen,
}
theme.InfoColor = lipgloss.AdaptiveColor{
Dark: darkCyan,
Light: lightCyan,
}
// Text colors
theme.TextColor = lipgloss.AdaptiveColor{
Dark: darkForeground,
Light: lightForeground,
}
theme.TextMutedColor = lipgloss.AdaptiveColor{
Dark: darkComment,
Light: lightComment,
}
theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
Dark: darkYellow,
Light: lightYellow,
}
// Background colors
theme.BackgroundColor = lipgloss.AdaptiveColor{
Dark: darkBackground,
Light: lightBackground,
}
theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
Dark: darkCurrentLine,
Light: lightCurrentLine,
}
theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
Dark: "#121212", // Slightly darker than background
Light: "#ffffff", // Slightly lighter than background
}
// Border colors
theme.BorderNormalColor = lipgloss.AdaptiveColor{
Dark: darkBorder,
Light: lightBorder,
}
theme.BorderFocusedColor = lipgloss.AdaptiveColor{
Dark: darkPrimary,
Light: lightPrimary,
}
theme.BorderDimColor = lipgloss.AdaptiveColor{
Dark: darkSelection,
Light: lightSelection,
}
// Diff view colors
theme.DiffAddedColor = lipgloss.AdaptiveColor{
Dark: "#478247",
Light: "#2E7D32",
}
theme.DiffRemovedColor = lipgloss.AdaptiveColor{
Dark: "#7C4444",
Light: "#C62828",
}
theme.DiffContextColor = lipgloss.AdaptiveColor{
Dark: "#a0a0a0",
Light: "#757575",
}
theme.DiffHunkHeaderColor = lipgloss.AdaptiveColor{
Dark: "#a0a0a0",
Light: "#757575",
}
theme.DiffHighlightAddedColor = lipgloss.AdaptiveColor{
Dark: "#DAFADA",
Light: "#A5D6A7",
}
theme.DiffHighlightRemovedColor = lipgloss.AdaptiveColor{
Dark: "#FADADD",
Light: "#EF9A9A",
}
theme.DiffAddedBgColor = lipgloss.AdaptiveColor{
Dark: "#303A30",
Light: "#E8F5E9",
}
theme.DiffRemovedBgColor = lipgloss.AdaptiveColor{
Dark: "#3A3030",
Light: "#FFEBEE",
}
theme.DiffContextBgColor = lipgloss.AdaptiveColor{
Dark: darkBackground,
Light: lightBackground,
}
theme.DiffLineNumberColor = lipgloss.AdaptiveColor{
Dark: "#888888",
Light: "#9E9E9E",
}
theme.DiffAddedLineNumberBgColor = lipgloss.AdaptiveColor{
Dark: "#293229",
Light: "#C8E6C9",
}
theme.DiffRemovedLineNumberBgColor = lipgloss.AdaptiveColor{
Dark: "#332929",
Light: "#FFCDD2",
}
// Markdown colors
theme.MarkdownTextColor = lipgloss.AdaptiveColor{
Dark: darkForeground,
Light: lightForeground,
}
theme.MarkdownHeadingColor = lipgloss.AdaptiveColor{
Dark: darkSecondary,
Light: lightSecondary,
}
theme.MarkdownLinkColor = lipgloss.AdaptiveColor{
Dark: darkPrimary,
Light: lightPrimary,
}
theme.MarkdownLinkTextColor = lipgloss.AdaptiveColor{
Dark: darkCyan,
Light: lightCyan,
}
theme.MarkdownCodeColor = lipgloss.AdaptiveColor{
Dark: darkGreen,
Light: lightGreen,
}
theme.MarkdownBlockQuoteColor = lipgloss.AdaptiveColor{
Dark: darkYellow,
Light: lightYellow,
}
theme.MarkdownEmphColor = lipgloss.AdaptiveColor{
Dark: darkYellow,
Light: lightYellow,
}
theme.MarkdownStrongColor = lipgloss.AdaptiveColor{
Dark: darkAccent,
Light: lightAccent,
}
theme.MarkdownHorizontalRuleColor = lipgloss.AdaptiveColor{
Dark: darkComment,
Light: lightComment,
}
theme.MarkdownListItemColor = lipgloss.AdaptiveColor{
Dark: darkPrimary,
Light: lightPrimary,
}
theme.MarkdownListEnumerationColor = lipgloss.AdaptiveColor{
Dark: darkCyan,
Light: lightCyan,
}
theme.MarkdownImageColor = lipgloss.AdaptiveColor{
Dark: darkPrimary,
Light: lightPrimary,
}
theme.MarkdownImageTextColor = lipgloss.AdaptiveColor{
Dark: darkCyan,
Light: lightCyan,
}
theme.MarkdownCodeBlockColor = lipgloss.AdaptiveColor{
Dark: darkForeground,
Light: lightForeground,
}
// Syntax highlighting colors
theme.SyntaxCommentColor = lipgloss.AdaptiveColor{
Dark: darkComment,
Light: lightComment,
}
theme.SyntaxKeywordColor = lipgloss.AdaptiveColor{
Dark: darkSecondary,
Light: lightSecondary,
}
theme.SyntaxFunctionColor = lipgloss.AdaptiveColor{
Dark: darkPrimary,
Light: lightPrimary,
}
theme.SyntaxVariableColor = lipgloss.AdaptiveColor{
Dark: darkRed,
Light: lightRed,
}
theme.SyntaxStringColor = lipgloss.AdaptiveColor{
Dark: darkGreen,
Light: lightGreen,
}
theme.SyntaxNumberColor = lipgloss.AdaptiveColor{
Dark: darkAccent,
Light: lightAccent,
}
theme.SyntaxTypeColor = lipgloss.AdaptiveColor{
Dark: darkYellow,
Light: lightYellow,
}
theme.SyntaxOperatorColor = lipgloss.AdaptiveColor{
Dark: darkCyan,
Light: lightCyan,
}
theme.SyntaxPunctuationColor = lipgloss.AdaptiveColor{
Dark: darkForeground,
Light: lightForeground,
}
return theme
}
func init() {
// Register the OpenCode theme with the theme manager
RegisterTheme("opencode", NewOpenCodeTheme())
}

208
internal/tui/theme/theme.go Normal file
View File

@@ -0,0 +1,208 @@
package theme
import (
"github.com/charmbracelet/lipgloss"
)
// Theme defines the interface for all UI themes in the application.
// All colors must be defined as lipgloss.AdaptiveColor to support
// both light and dark terminal backgrounds.
type Theme interface {
// Base colors
Primary() lipgloss.AdaptiveColor
Secondary() lipgloss.AdaptiveColor
Accent() lipgloss.AdaptiveColor
// Status colors
Error() lipgloss.AdaptiveColor
Warning() lipgloss.AdaptiveColor
Success() lipgloss.AdaptiveColor
Info() lipgloss.AdaptiveColor
// Text colors
Text() lipgloss.AdaptiveColor
TextMuted() lipgloss.AdaptiveColor
TextEmphasized() lipgloss.AdaptiveColor
// Background colors
Background() lipgloss.AdaptiveColor
BackgroundSecondary() lipgloss.AdaptiveColor
BackgroundDarker() lipgloss.AdaptiveColor
// Border colors
BorderNormal() lipgloss.AdaptiveColor
BorderFocused() lipgloss.AdaptiveColor
BorderDim() lipgloss.AdaptiveColor
// Diff view colors
DiffAdded() lipgloss.AdaptiveColor
DiffRemoved() lipgloss.AdaptiveColor
DiffContext() lipgloss.AdaptiveColor
DiffHunkHeader() lipgloss.AdaptiveColor
DiffHighlightAdded() lipgloss.AdaptiveColor
DiffHighlightRemoved() lipgloss.AdaptiveColor
DiffAddedBg() lipgloss.AdaptiveColor
DiffRemovedBg() lipgloss.AdaptiveColor
DiffContextBg() lipgloss.AdaptiveColor
DiffLineNumber() lipgloss.AdaptiveColor
DiffAddedLineNumberBg() lipgloss.AdaptiveColor
DiffRemovedLineNumberBg() lipgloss.AdaptiveColor
// Markdown colors
MarkdownText() lipgloss.AdaptiveColor
MarkdownHeading() lipgloss.AdaptiveColor
MarkdownLink() lipgloss.AdaptiveColor
MarkdownLinkText() lipgloss.AdaptiveColor
MarkdownCode() lipgloss.AdaptiveColor
MarkdownBlockQuote() lipgloss.AdaptiveColor
MarkdownEmph() lipgloss.AdaptiveColor
MarkdownStrong() lipgloss.AdaptiveColor
MarkdownHorizontalRule() lipgloss.AdaptiveColor
MarkdownListItem() lipgloss.AdaptiveColor
MarkdownListEnumeration() lipgloss.AdaptiveColor
MarkdownImage() lipgloss.AdaptiveColor
MarkdownImageText() lipgloss.AdaptiveColor
MarkdownCodeBlock() lipgloss.AdaptiveColor
// Syntax highlighting colors
SyntaxComment() lipgloss.AdaptiveColor
SyntaxKeyword() lipgloss.AdaptiveColor
SyntaxFunction() lipgloss.AdaptiveColor
SyntaxVariable() lipgloss.AdaptiveColor
SyntaxString() lipgloss.AdaptiveColor
SyntaxNumber() lipgloss.AdaptiveColor
SyntaxType() lipgloss.AdaptiveColor
SyntaxOperator() lipgloss.AdaptiveColor
SyntaxPunctuation() lipgloss.AdaptiveColor
}
// BaseTheme provides a default implementation of the Theme interface
// that can be embedded in concrete theme implementations.
type BaseTheme struct {
// Base colors
PrimaryColor lipgloss.AdaptiveColor
SecondaryColor lipgloss.AdaptiveColor
AccentColor lipgloss.AdaptiveColor
// Status colors
ErrorColor lipgloss.AdaptiveColor
WarningColor lipgloss.AdaptiveColor
SuccessColor lipgloss.AdaptiveColor
InfoColor lipgloss.AdaptiveColor
// Text colors
TextColor lipgloss.AdaptiveColor
TextMutedColor lipgloss.AdaptiveColor
TextEmphasizedColor lipgloss.AdaptiveColor
// Background colors
BackgroundColor lipgloss.AdaptiveColor
BackgroundSecondaryColor lipgloss.AdaptiveColor
BackgroundDarkerColor lipgloss.AdaptiveColor
// Border colors
BorderNormalColor lipgloss.AdaptiveColor
BorderFocusedColor lipgloss.AdaptiveColor
BorderDimColor lipgloss.AdaptiveColor
// Diff view colors
DiffAddedColor lipgloss.AdaptiveColor
DiffRemovedColor lipgloss.AdaptiveColor
DiffContextColor lipgloss.AdaptiveColor
DiffHunkHeaderColor lipgloss.AdaptiveColor
DiffHighlightAddedColor lipgloss.AdaptiveColor
DiffHighlightRemovedColor lipgloss.AdaptiveColor
DiffAddedBgColor lipgloss.AdaptiveColor
DiffRemovedBgColor lipgloss.AdaptiveColor
DiffContextBgColor lipgloss.AdaptiveColor
DiffLineNumberColor lipgloss.AdaptiveColor
DiffAddedLineNumberBgColor lipgloss.AdaptiveColor
DiffRemovedLineNumberBgColor lipgloss.AdaptiveColor
// Markdown colors
MarkdownTextColor lipgloss.AdaptiveColor
MarkdownHeadingColor lipgloss.AdaptiveColor
MarkdownLinkColor lipgloss.AdaptiveColor
MarkdownLinkTextColor lipgloss.AdaptiveColor
MarkdownCodeColor lipgloss.AdaptiveColor
MarkdownBlockQuoteColor lipgloss.AdaptiveColor
MarkdownEmphColor lipgloss.AdaptiveColor
MarkdownStrongColor lipgloss.AdaptiveColor
MarkdownHorizontalRuleColor lipgloss.AdaptiveColor
MarkdownListItemColor lipgloss.AdaptiveColor
MarkdownListEnumerationColor lipgloss.AdaptiveColor
MarkdownImageColor lipgloss.AdaptiveColor
MarkdownImageTextColor lipgloss.AdaptiveColor
MarkdownCodeBlockColor lipgloss.AdaptiveColor
// Syntax highlighting colors
SyntaxCommentColor lipgloss.AdaptiveColor
SyntaxKeywordColor lipgloss.AdaptiveColor
SyntaxFunctionColor lipgloss.AdaptiveColor
SyntaxVariableColor lipgloss.AdaptiveColor
SyntaxStringColor lipgloss.AdaptiveColor
SyntaxNumberColor lipgloss.AdaptiveColor
SyntaxTypeColor lipgloss.AdaptiveColor
SyntaxOperatorColor lipgloss.AdaptiveColor
SyntaxPunctuationColor lipgloss.AdaptiveColor
}
// Implement the Theme interface for BaseTheme
func (t *BaseTheme) Primary() lipgloss.AdaptiveColor { return t.PrimaryColor }
func (t *BaseTheme) Secondary() lipgloss.AdaptiveColor { return t.SecondaryColor }
func (t *BaseTheme) Accent() lipgloss.AdaptiveColor { return t.AccentColor }
func (t *BaseTheme) Error() lipgloss.AdaptiveColor { return t.ErrorColor }
func (t *BaseTheme) Warning() lipgloss.AdaptiveColor { return t.WarningColor }
func (t *BaseTheme) Success() lipgloss.AdaptiveColor { return t.SuccessColor }
func (t *BaseTheme) Info() lipgloss.AdaptiveColor { return t.InfoColor }
func (t *BaseTheme) Text() lipgloss.AdaptiveColor { return t.TextColor }
func (t *BaseTheme) TextMuted() lipgloss.AdaptiveColor { return t.TextMutedColor }
func (t *BaseTheme) TextEmphasized() lipgloss.AdaptiveColor { return t.TextEmphasizedColor }
func (t *BaseTheme) Background() lipgloss.AdaptiveColor { return t.BackgroundColor }
func (t *BaseTheme) BackgroundSecondary() lipgloss.AdaptiveColor { return t.BackgroundSecondaryColor }
func (t *BaseTheme) BackgroundDarker() lipgloss.AdaptiveColor { return t.BackgroundDarkerColor }
func (t *BaseTheme) BorderNormal() lipgloss.AdaptiveColor { return t.BorderNormalColor }
func (t *BaseTheme) BorderFocused() lipgloss.AdaptiveColor { return t.BorderFocusedColor }
func (t *BaseTheme) BorderDim() lipgloss.AdaptiveColor { return t.BorderDimColor }
func (t *BaseTheme) DiffAdded() lipgloss.AdaptiveColor { return t.DiffAddedColor }
func (t *BaseTheme) DiffRemoved() lipgloss.AdaptiveColor { return t.DiffRemovedColor }
func (t *BaseTheme) DiffContext() lipgloss.AdaptiveColor { return t.DiffContextColor }
func (t *BaseTheme) DiffHunkHeader() lipgloss.AdaptiveColor { return t.DiffHunkHeaderColor }
func (t *BaseTheme) DiffHighlightAdded() lipgloss.AdaptiveColor { return t.DiffHighlightAddedColor }
func (t *BaseTheme) DiffHighlightRemoved() lipgloss.AdaptiveColor { return t.DiffHighlightRemovedColor }
func (t *BaseTheme) DiffAddedBg() lipgloss.AdaptiveColor { return t.DiffAddedBgColor }
func (t *BaseTheme) DiffRemovedBg() lipgloss.AdaptiveColor { return t.DiffRemovedBgColor }
func (t *BaseTheme) DiffContextBg() lipgloss.AdaptiveColor { return t.DiffContextBgColor }
func (t *BaseTheme) DiffLineNumber() lipgloss.AdaptiveColor { return t.DiffLineNumberColor }
func (t *BaseTheme) DiffAddedLineNumberBg() lipgloss.AdaptiveColor { return t.DiffAddedLineNumberBgColor }
func (t *BaseTheme) DiffRemovedLineNumberBg() lipgloss.AdaptiveColor { return t.DiffRemovedLineNumberBgColor }
func (t *BaseTheme) MarkdownText() lipgloss.AdaptiveColor { return t.MarkdownTextColor }
func (t *BaseTheme) MarkdownHeading() lipgloss.AdaptiveColor { return t.MarkdownHeadingColor }
func (t *BaseTheme) MarkdownLink() lipgloss.AdaptiveColor { return t.MarkdownLinkColor }
func (t *BaseTheme) MarkdownLinkText() lipgloss.AdaptiveColor { return t.MarkdownLinkTextColor }
func (t *BaseTheme) MarkdownCode() lipgloss.AdaptiveColor { return t.MarkdownCodeColor }
func (t *BaseTheme) MarkdownBlockQuote() lipgloss.AdaptiveColor { return t.MarkdownBlockQuoteColor }
func (t *BaseTheme) MarkdownEmph() lipgloss.AdaptiveColor { return t.MarkdownEmphColor }
func (t *BaseTheme) MarkdownStrong() lipgloss.AdaptiveColor { return t.MarkdownStrongColor }
func (t *BaseTheme) MarkdownHorizontalRule() lipgloss.AdaptiveColor { return t.MarkdownHorizontalRuleColor }
func (t *BaseTheme) MarkdownListItem() lipgloss.AdaptiveColor { return t.MarkdownListItemColor }
func (t *BaseTheme) MarkdownListEnumeration() lipgloss.AdaptiveColor { return t.MarkdownListEnumerationColor }
func (t *BaseTheme) MarkdownImage() lipgloss.AdaptiveColor { return t.MarkdownImageColor }
func (t *BaseTheme) MarkdownImageText() lipgloss.AdaptiveColor { return t.MarkdownImageTextColor }
func (t *BaseTheme) MarkdownCodeBlock() lipgloss.AdaptiveColor { return t.MarkdownCodeBlockColor }
func (t *BaseTheme) SyntaxComment() lipgloss.AdaptiveColor { return t.SyntaxCommentColor }
func (t *BaseTheme) SyntaxKeyword() lipgloss.AdaptiveColor { return t.SyntaxKeywordColor }
func (t *BaseTheme) SyntaxFunction() lipgloss.AdaptiveColor { return t.SyntaxFunctionColor }
func (t *BaseTheme) SyntaxVariable() lipgloss.AdaptiveColor { return t.SyntaxVariableColor }
func (t *BaseTheme) SyntaxString() lipgloss.AdaptiveColor { return t.SyntaxStringColor }
func (t *BaseTheme) SyntaxNumber() lipgloss.AdaptiveColor { return t.SyntaxNumberColor }
func (t *BaseTheme) SyntaxType() lipgloss.AdaptiveColor { return t.SyntaxTypeColor }
func (t *BaseTheme) SyntaxOperator() lipgloss.AdaptiveColor { return t.SyntaxOperatorColor }
func (t *BaseTheme) SyntaxPunctuation() lipgloss.AdaptiveColor { return t.SyntaxPunctuationColor }

View File

@@ -0,0 +1,89 @@
package theme
import (
"testing"
)
func TestThemeRegistration(t *testing.T) {
// Get list of available themes
availableThemes := AvailableThemes()
// Check if "catppuccin" theme is registered
catppuccinFound := false
for _, themeName := range availableThemes {
if themeName == "catppuccin" {
catppuccinFound = true
break
}
}
if !catppuccinFound {
t.Errorf("Catppuccin theme is not registered")
}
// Check if "gruvbox" theme is registered
gruvboxFound := false
for _, themeName := range availableThemes {
if themeName == "gruvbox" {
gruvboxFound = true
break
}
}
if !gruvboxFound {
t.Errorf("Gruvbox theme is not registered")
}
// Check if "monokai" theme is registered
monokaiFound := false
for _, themeName := range availableThemes {
if themeName == "monokai" {
monokaiFound = true
break
}
}
if !monokaiFound {
t.Errorf("Monokai theme is not registered")
}
// Try to get the themes and make sure they're not nil
catppuccin := GetTheme("catppuccin")
if catppuccin == nil {
t.Errorf("Catppuccin theme is nil")
}
gruvbox := GetTheme("gruvbox")
if gruvbox == nil {
t.Errorf("Gruvbox theme is nil")
}
monokai := GetTheme("monokai")
if monokai == nil {
t.Errorf("Monokai theme is nil")
}
// Test switching theme
originalTheme := CurrentThemeName()
err := SetTheme("gruvbox")
if err != nil {
t.Errorf("Failed to set theme to gruvbox: %v", err)
}
if CurrentThemeName() != "gruvbox" {
t.Errorf("Theme not properly switched to gruvbox")
}
err = SetTheme("monokai")
if err != nil {
t.Errorf("Failed to set theme to monokai: %v", err)
}
if CurrentThemeName() != "monokai" {
t.Errorf("Theme not properly switched to monokai")
}
// Switch back to original theme
_ = SetTheme(originalTheme)
}

View File

@@ -0,0 +1,274 @@
package theme
import (
"github.com/charmbracelet/lipgloss"
)
// TokyoNightTheme implements the Theme interface with Tokyo Night colors.
// It provides both dark and light variants.
type TokyoNightTheme struct {
BaseTheme
}
// NewTokyoNightTheme creates a new instance of the Tokyo Night theme.
func NewTokyoNightTheme() *TokyoNightTheme {
// Tokyo Night color palette
// Dark mode colors
darkBackground := "#222436"
darkCurrentLine := "#1e2030"
darkSelection := "#2f334d"
darkForeground := "#c8d3f5"
darkComment := "#636da6"
darkRed := "#ff757f"
darkOrange := "#ff966c"
darkYellow := "#ffc777"
darkGreen := "#c3e88d"
darkCyan := "#86e1fc"
darkBlue := "#82aaff"
darkPurple := "#c099ff"
darkBorder := "#3b4261"
// Light mode colors (Tokyo Night Day)
lightBackground := "#e1e2e7"
lightCurrentLine := "#d5d6db"
lightSelection := "#c8c9ce"
lightForeground := "#3760bf"
lightComment := "#848cb5"
lightRed := "#f52a65"
lightOrange := "#b15c00"
lightYellow := "#8c6c3e"
lightGreen := "#587539"
lightCyan := "#007197"
lightBlue := "#2e7de9"
lightPurple := "#9854f1"
lightBorder := "#a8aecb"
theme := &TokyoNightTheme{}
// Base colors
theme.PrimaryColor = lipgloss.AdaptiveColor{
Dark: darkBlue,
Light: lightBlue,
}
theme.SecondaryColor = lipgloss.AdaptiveColor{
Dark: darkPurple,
Light: lightPurple,
}
theme.AccentColor = lipgloss.AdaptiveColor{
Dark: darkOrange,
Light: lightOrange,
}
// Status colors
theme.ErrorColor = lipgloss.AdaptiveColor{
Dark: darkRed,
Light: lightRed,
}
theme.WarningColor = lipgloss.AdaptiveColor{
Dark: darkOrange,
Light: lightOrange,
}
theme.SuccessColor = lipgloss.AdaptiveColor{
Dark: darkGreen,
Light: lightGreen,
}
theme.InfoColor = lipgloss.AdaptiveColor{
Dark: darkBlue,
Light: lightBlue,
}
// Text colors
theme.TextColor = lipgloss.AdaptiveColor{
Dark: darkForeground,
Light: lightForeground,
}
theme.TextMutedColor = lipgloss.AdaptiveColor{
Dark: darkComment,
Light: lightComment,
}
theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
Dark: darkYellow,
Light: lightYellow,
}
// Background colors
theme.BackgroundColor = lipgloss.AdaptiveColor{
Dark: darkBackground,
Light: lightBackground,
}
theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
Dark: darkCurrentLine,
Light: lightCurrentLine,
}
theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
Dark: "#191B29", // Darker background from palette
Light: "#f0f0f5", // Slightly lighter than background
}
// Border colors
theme.BorderNormalColor = lipgloss.AdaptiveColor{
Dark: darkBorder,
Light: lightBorder,
}
theme.BorderFocusedColor = lipgloss.AdaptiveColor{
Dark: darkBlue,
Light: lightBlue,
}
theme.BorderDimColor = lipgloss.AdaptiveColor{
Dark: darkSelection,
Light: lightSelection,
}
// Diff view colors
theme.DiffAddedColor = lipgloss.AdaptiveColor{
Dark: "#4fd6be", // teal from palette
Light: "#1e725c",
}
theme.DiffRemovedColor = lipgloss.AdaptiveColor{
Dark: "#c53b53", // red1 from palette
Light: "#c53b53",
}
theme.DiffContextColor = lipgloss.AdaptiveColor{
Dark: "#828bb8", // fg_dark from palette
Light: "#7086b5",
}
theme.DiffHunkHeaderColor = lipgloss.AdaptiveColor{
Dark: "#828bb8", // fg_dark from palette
Light: "#7086b5",
}
theme.DiffHighlightAddedColor = lipgloss.AdaptiveColor{
Dark: "#b8db87", // git.add from palette
Light: "#4db380",
}
theme.DiffHighlightRemovedColor = lipgloss.AdaptiveColor{
Dark: "#e26a75", // git.delete from palette
Light: "#f52a65",
}
theme.DiffAddedBgColor = lipgloss.AdaptiveColor{
Dark: "#20303b",
Light: "#d5e5d5",
}
theme.DiffRemovedBgColor = lipgloss.AdaptiveColor{
Dark: "#37222c",
Light: "#f7d8db",
}
theme.DiffContextBgColor = lipgloss.AdaptiveColor{
Dark: darkBackground,
Light: lightBackground,
}
theme.DiffLineNumberColor = lipgloss.AdaptiveColor{
Dark: "#545c7e", // dark3 from palette
Light: "#848cb5",
}
theme.DiffAddedLineNumberBgColor = lipgloss.AdaptiveColor{
Dark: "#1b2b34",
Light: "#c5d5c5",
}
theme.DiffRemovedLineNumberBgColor = lipgloss.AdaptiveColor{
Dark: "#2d1f26",
Light: "#e7c8cb",
}
// Markdown colors
theme.MarkdownTextColor = lipgloss.AdaptiveColor{
Dark: darkForeground,
Light: lightForeground,
}
theme.MarkdownHeadingColor = lipgloss.AdaptiveColor{
Dark: darkPurple,
Light: lightPurple,
}
theme.MarkdownLinkColor = lipgloss.AdaptiveColor{
Dark: darkBlue,
Light: lightBlue,
}
theme.MarkdownLinkTextColor = lipgloss.AdaptiveColor{
Dark: darkCyan,
Light: lightCyan,
}
theme.MarkdownCodeColor = lipgloss.AdaptiveColor{
Dark: darkGreen,
Light: lightGreen,
}
theme.MarkdownBlockQuoteColor = lipgloss.AdaptiveColor{
Dark: darkYellow,
Light: lightYellow,
}
theme.MarkdownEmphColor = lipgloss.AdaptiveColor{
Dark: darkYellow,
Light: lightYellow,
}
theme.MarkdownStrongColor = lipgloss.AdaptiveColor{
Dark: darkOrange,
Light: lightOrange,
}
theme.MarkdownHorizontalRuleColor = lipgloss.AdaptiveColor{
Dark: darkComment,
Light: lightComment,
}
theme.MarkdownListItemColor = lipgloss.AdaptiveColor{
Dark: darkBlue,
Light: lightBlue,
}
theme.MarkdownListEnumerationColor = lipgloss.AdaptiveColor{
Dark: darkCyan,
Light: lightCyan,
}
theme.MarkdownImageColor = lipgloss.AdaptiveColor{
Dark: darkBlue,
Light: lightBlue,
}
theme.MarkdownImageTextColor = lipgloss.AdaptiveColor{
Dark: darkCyan,
Light: lightCyan,
}
theme.MarkdownCodeBlockColor = lipgloss.AdaptiveColor{
Dark: darkForeground,
Light: lightForeground,
}
// Syntax highlighting colors
theme.SyntaxCommentColor = lipgloss.AdaptiveColor{
Dark: darkComment,
Light: lightComment,
}
theme.SyntaxKeywordColor = lipgloss.AdaptiveColor{
Dark: darkPurple,
Light: lightPurple,
}
theme.SyntaxFunctionColor = lipgloss.AdaptiveColor{
Dark: darkBlue,
Light: lightBlue,
}
theme.SyntaxVariableColor = lipgloss.AdaptiveColor{
Dark: darkRed,
Light: lightRed,
}
theme.SyntaxStringColor = lipgloss.AdaptiveColor{
Dark: darkGreen,
Light: lightGreen,
}
theme.SyntaxNumberColor = lipgloss.AdaptiveColor{
Dark: darkOrange,
Light: lightOrange,
}
theme.SyntaxTypeColor = lipgloss.AdaptiveColor{
Dark: darkYellow,
Light: lightYellow,
}
theme.SyntaxOperatorColor = lipgloss.AdaptiveColor{
Dark: darkCyan,
Light: lightCyan,
}
theme.SyntaxPunctuationColor = lipgloss.AdaptiveColor{
Dark: darkForeground,
Light: lightForeground,
}
return theme
}
func init() {
// Register the Tokyo Night theme with the theme manager
RegisterTheme("tokyonight", NewTokyoNightTheme())
}

276
internal/tui/theme/tron.go Normal file
View File

@@ -0,0 +1,276 @@
package theme
import (
"github.com/charmbracelet/lipgloss"
)
// TronTheme implements the Theme interface with Tron-inspired colors.
// It provides both dark and light variants, though Tron is primarily a dark theme.
type TronTheme struct {
BaseTheme
}
// NewTronTheme creates a new instance of the Tron theme.
func NewTronTheme() *TronTheme {
// Tron color palette
// Inspired by the Tron movie's neon aesthetic
darkBackground := "#0c141f"
darkCurrentLine := "#1a2633"
darkSelection := "#1a2633"
darkForeground := "#caf0ff"
darkComment := "#4d6b87"
darkCyan := "#00d9ff"
darkBlue := "#007fff"
darkOrange := "#ff9000"
darkPink := "#ff00a0"
darkPurple := "#b73fff"
darkRed := "#ff3333"
darkYellow := "#ffcc00"
darkGreen := "#00ff8f"
darkBorder := "#1a2633"
// Light mode approximation
lightBackground := "#f0f8ff"
lightCurrentLine := "#e0f0ff"
lightSelection := "#d0e8ff"
lightForeground := "#0c141f"
lightComment := "#4d6b87"
lightCyan := "#0097b3"
lightBlue := "#0066cc"
lightOrange := "#cc7300"
lightPink := "#cc0080"
lightPurple := "#9932cc"
lightRed := "#cc2929"
lightYellow := "#cc9900"
lightGreen := "#00cc72"
lightBorder := "#d0e8ff"
theme := &TronTheme{}
// Base colors
theme.PrimaryColor = lipgloss.AdaptiveColor{
Dark: darkCyan,
Light: lightCyan,
}
theme.SecondaryColor = lipgloss.AdaptiveColor{
Dark: darkBlue,
Light: lightBlue,
}
theme.AccentColor = lipgloss.AdaptiveColor{
Dark: darkOrange,
Light: lightOrange,
}
// Status colors
theme.ErrorColor = lipgloss.AdaptiveColor{
Dark: darkRed,
Light: lightRed,
}
theme.WarningColor = lipgloss.AdaptiveColor{
Dark: darkOrange,
Light: lightOrange,
}
theme.SuccessColor = lipgloss.AdaptiveColor{
Dark: darkGreen,
Light: lightGreen,
}
theme.InfoColor = lipgloss.AdaptiveColor{
Dark: darkCyan,
Light: lightCyan,
}
// Text colors
theme.TextColor = lipgloss.AdaptiveColor{
Dark: darkForeground,
Light: lightForeground,
}
theme.TextMutedColor = lipgloss.AdaptiveColor{
Dark: darkComment,
Light: lightComment,
}
theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
Dark: darkYellow,
Light: lightYellow,
}
// Background colors
theme.BackgroundColor = lipgloss.AdaptiveColor{
Dark: darkBackground,
Light: lightBackground,
}
theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
Dark: darkCurrentLine,
Light: lightCurrentLine,
}
theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
Dark: "#070d14", // Slightly darker than background
Light: "#ffffff", // Slightly lighter than background
}
// Border colors
theme.BorderNormalColor = lipgloss.AdaptiveColor{
Dark: darkBorder,
Light: lightBorder,
}
theme.BorderFocusedColor = lipgloss.AdaptiveColor{
Dark: darkCyan,
Light: lightCyan,
}
theme.BorderDimColor = lipgloss.AdaptiveColor{
Dark: darkSelection,
Light: lightSelection,
}
// Diff view colors
theme.DiffAddedColor = lipgloss.AdaptiveColor{
Dark: darkGreen,
Light: lightGreen,
}
theme.DiffRemovedColor = lipgloss.AdaptiveColor{
Dark: darkRed,
Light: lightRed,
}
theme.DiffContextColor = lipgloss.AdaptiveColor{
Dark: darkComment,
Light: lightComment,
}
theme.DiffHunkHeaderColor = lipgloss.AdaptiveColor{
Dark: darkBlue,
Light: lightBlue,
}
theme.DiffHighlightAddedColor = lipgloss.AdaptiveColor{
Dark: "#00ff8f",
Light: "#a5d6a7",
}
theme.DiffHighlightRemovedColor = lipgloss.AdaptiveColor{
Dark: "#ff3333",
Light: "#ef9a9a",
}
theme.DiffAddedBgColor = lipgloss.AdaptiveColor{
Dark: "#0a2a1a",
Light: "#e8f5e9",
}
theme.DiffRemovedBgColor = lipgloss.AdaptiveColor{
Dark: "#2a0a0a",
Light: "#ffebee",
}
theme.DiffContextBgColor = lipgloss.AdaptiveColor{
Dark: darkBackground,
Light: lightBackground,
}
theme.DiffLineNumberColor = lipgloss.AdaptiveColor{
Dark: darkComment,
Light: lightComment,
}
theme.DiffAddedLineNumberBgColor = lipgloss.AdaptiveColor{
Dark: "#082015",
Light: "#c8e6c9",
}
theme.DiffRemovedLineNumberBgColor = lipgloss.AdaptiveColor{
Dark: "#200808",
Light: "#ffcdd2",
}
// Markdown colors
theme.MarkdownTextColor = lipgloss.AdaptiveColor{
Dark: darkForeground,
Light: lightForeground,
}
theme.MarkdownHeadingColor = lipgloss.AdaptiveColor{
Dark: darkCyan,
Light: lightCyan,
}
theme.MarkdownLinkColor = lipgloss.AdaptiveColor{
Dark: darkBlue,
Light: lightBlue,
}
theme.MarkdownLinkTextColor = lipgloss.AdaptiveColor{
Dark: darkCyan,
Light: lightCyan,
}
theme.MarkdownCodeColor = lipgloss.AdaptiveColor{
Dark: darkGreen,
Light: lightGreen,
}
theme.MarkdownBlockQuoteColor = lipgloss.AdaptiveColor{
Dark: darkYellow,
Light: lightYellow,
}
theme.MarkdownEmphColor = lipgloss.AdaptiveColor{
Dark: darkYellow,
Light: lightYellow,
}
theme.MarkdownStrongColor = lipgloss.AdaptiveColor{
Dark: darkOrange,
Light: lightOrange,
}
theme.MarkdownHorizontalRuleColor = lipgloss.AdaptiveColor{
Dark: darkComment,
Light: lightComment,
}
theme.MarkdownListItemColor = lipgloss.AdaptiveColor{
Dark: darkBlue,
Light: lightBlue,
}
theme.MarkdownListEnumerationColor = lipgloss.AdaptiveColor{
Dark: darkCyan,
Light: lightCyan,
}
theme.MarkdownImageColor = lipgloss.AdaptiveColor{
Dark: darkBlue,
Light: lightBlue,
}
theme.MarkdownImageTextColor = lipgloss.AdaptiveColor{
Dark: darkCyan,
Light: lightCyan,
}
theme.MarkdownCodeBlockColor = lipgloss.AdaptiveColor{
Dark: darkForeground,
Light: lightForeground,
}
// Syntax highlighting colors
theme.SyntaxCommentColor = lipgloss.AdaptiveColor{
Dark: darkComment,
Light: lightComment,
}
theme.SyntaxKeywordColor = lipgloss.AdaptiveColor{
Dark: darkCyan,
Light: lightCyan,
}
theme.SyntaxFunctionColor = lipgloss.AdaptiveColor{
Dark: darkGreen,
Light: lightGreen,
}
theme.SyntaxVariableColor = lipgloss.AdaptiveColor{
Dark: darkOrange,
Light: lightOrange,
}
theme.SyntaxStringColor = lipgloss.AdaptiveColor{
Dark: darkYellow,
Light: lightYellow,
}
theme.SyntaxNumberColor = lipgloss.AdaptiveColor{
Dark: darkBlue,
Light: lightBlue,
}
theme.SyntaxTypeColor = lipgloss.AdaptiveColor{
Dark: darkPurple,
Light: lightPurple,
}
theme.SyntaxOperatorColor = lipgloss.AdaptiveColor{
Dark: darkPink,
Light: lightPink,
}
theme.SyntaxPunctuationColor = lipgloss.AdaptiveColor{
Dark: darkForeground,
Light: lightForeground,
}
return theme
}
func init() {
// Register the Tron theme with the theme manager
RegisterTheme("tron", NewTronTheme())
}

View File

@@ -3,6 +3,7 @@ package tui
import (
"context"
"fmt"
"strings"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
@@ -26,9 +27,15 @@ type keyMap struct {
Help key.Binding
SwitchSession key.Binding
Commands key.Binding
Filepicker key.Binding
Models key.Binding
SwitchTheme key.Binding
}
const (
quitKey = "q"
)
var keys = keyMap{
Logs: key.NewBinding(
key.WithKeys("ctrl+l"),
@@ -45,19 +52,27 @@ var keys = keyMap{
),
SwitchSession: key.NewBinding(
key.WithKeys("ctrl+a"),
key.WithHelp("ctrl+a", "switch session"),
key.WithKeys("ctrl+s"),
key.WithHelp("ctrl+s", "switch session"),
),
Commands: key.NewBinding(
key.WithKeys("ctrl+k"),
key.WithHelp("ctrl+k", "commands"),
),
Filepicker: key.NewBinding(
key.WithKeys("ctrl+f"),
key.WithHelp("ctrl+f", "select files to upload"),
),
Models: key.NewBinding(
key.WithKeys("ctrl+o"),
key.WithHelp("ctrl+o", "model selection"),
),
SwitchTheme: key.NewBinding(
key.WithKeys("ctrl+t"),
key.WithHelp("ctrl+t", "switch theme"),
),
}
var helpEsc = key.NewBinding(
@@ -71,8 +86,8 @@ var returnKey = key.NewBinding(
)
var logsKeyReturnKey = key.NewBinding(
key.WithKeys("backspace", "q"),
key.WithHelp("backspace/q", "go back"),
key.WithKeys("esc", "backspace", quitKey),
key.WithHelp("esc/q", "go back"),
)
type appModel struct {
@@ -105,6 +120,15 @@ type appModel struct {
showInitDialog bool
initDialog dialog.InitDialogCmp
showFilepicker bool
filepicker dialog.FilepickerCmp
showThemeDialog bool
themeDialog dialog.ThemeDialog
showArgumentsDialog bool
argumentsDialog dialog.ArgumentsDialogCmp
}
func (a appModel) Init() tea.Cmd {
@@ -126,6 +150,9 @@ func (a appModel) Init() tea.Cmd {
cmds = append(cmds, cmd)
cmd = a.initDialog.Init()
cmds = append(cmds, cmd)
cmd = a.filepicker.Init()
cmd = a.themeDialog.Init()
cmds = append(cmds, cmd)
// Check if we should show the init dialog
cmds = append(cmds, func() tea.Msg {
@@ -171,7 +198,18 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.commandDialog = command.(dialog.CommandDialog)
cmds = append(cmds, commandCmd)
filepicker, filepickerCmd := a.filepicker.Update(msg)
a.filepicker = filepicker.(dialog.FilepickerCmp)
cmds = append(cmds, filepickerCmd)
a.initDialog.SetSize(msg.Width, msg.Height)
if a.showArgumentsDialog {
a.argumentsDialog.SetSize(msg.Width, msg.Height)
args, argsCmd := a.argumentsDialog.Update(msg)
a.argumentsDialog = args.(dialog.ArgumentsDialogCmp)
cmds = append(cmds, argsCmd, a.argumentsDialog.Init())
}
return a, tea.Batch(cmds...)
// Status
@@ -255,6 +293,15 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.showCommandDialog = false
return a, nil
case dialog.CloseThemeDialogMsg:
a.showThemeDialog = false
return a, nil
case dialog.ThemeChangedMsg:
a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
a.showThemeDialog = false
return a, tea.Batch(cmd, util.ReportInfo("Theme changed to: "+msg.ThemeName))
case dialog.CloseModelDialogMsg:
a.showModelDialog = false
return a, nil
@@ -310,9 +357,39 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, msg.Command.Handler(msg.Command)
}
return a, util.ReportInfo("Command selected: " + msg.Command.Title)
case dialog.ShowArgumentsDialogMsg:
// Show arguments dialog
a.argumentsDialog = dialog.NewArgumentsDialogCmp(msg.CommandID, msg.Content)
a.showArgumentsDialog = true
return a, a.argumentsDialog.Init()
case dialog.CloseArgumentsDialogMsg:
// Close arguments dialog
a.showArgumentsDialog = false
// If submitted, replace $ARGUMENTS and run the command
if msg.Submit {
// Replace $ARGUMENTS with the provided arguments
content := strings.ReplaceAll(msg.Content, "$ARGUMENTS", msg.Arguments)
// Execute the command with arguments
return a, util.CmdHandler(dialog.CommandRunCustomMsg{
Content: content,
})
}
return a, nil
case tea.KeyMsg:
// If arguments dialog is open, let it handle the key press first
if a.showArgumentsDialog {
args, cmd := a.argumentsDialog.Update(msg)
a.argumentsDialog = args.(dialog.ArgumentsDialogCmp)
return a, cmd
}
switch {
case key.Matches(msg, keys.Quit):
a.showQuit = !a.showQuit
if a.showHelp {
@@ -324,9 +401,16 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if a.showCommandDialog {
a.showCommandDialog = false
}
if a.showFilepicker {
a.showFilepicker = false
a.filepicker.ToggleFilepicker(a.showFilepicker)
}
if a.showModelDialog {
a.showModelDialog = false
}
if a.showArgumentsDialog {
a.showArgumentsDialog = false
}
return a, nil
case key.Matches(msg, keys.SwitchSession):
if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showCommandDialog {
@@ -344,7 +428,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return a, nil
case key.Matches(msg, keys.Commands):
if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog {
if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showThemeDialog && !a.showFilepicker {
// Show commands dialog
if len(a.commands) == 0 {
return a, util.ReportWarn("No commands available")
@@ -359,32 +443,49 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.showModelDialog = false
return a, nil
}
if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
a.showModelDialog = true
return a, nil
}
return a, nil
case key.Matches(msg, logsKeyReturnKey):
if a.currentPage == page.LogsPage {
return a, a.moveToPage(page.ChatPage)
case key.Matches(msg, keys.SwitchTheme):
if !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
// Show theme switcher dialog
a.showThemeDialog = true
// Theme list is dynamically loaded by the dialog component
return a, a.themeDialog.Init()
}
case key.Matches(msg, returnKey):
if a.showQuit {
a.showQuit = !a.showQuit
return a, nil
}
if a.showHelp {
a.showHelp = !a.showHelp
return a, nil
}
if a.showInitDialog {
a.showInitDialog = false
// Mark the project as initialized without running the command
if err := config.MarkProjectInitialized(); err != nil {
return a, util.ReportError(err)
return a, nil
case key.Matches(msg, returnKey) || key.Matches(msg):
if msg.String() == quitKey {
if a.currentPage == page.LogsPage {
return a, a.moveToPage(page.ChatPage)
}
} else if !a.filepicker.IsCWDFocused() {
if a.showQuit {
a.showQuit = !a.showQuit
return a, nil
}
if a.showHelp {
a.showHelp = !a.showHelp
return a, nil
}
if a.showInitDialog {
a.showInitDialog = false
// Mark the project as initialized without running the command
if err := config.MarkProjectInitialized(); err != nil {
return a, util.ReportError(err)
}
return a, nil
}
if a.showFilepicker {
a.showFilepicker = false
a.filepicker.ToggleFilepicker(a.showFilepicker)
return a, nil
}
if a.currentPage == page.LogsPage {
return a, a.moveToPage(page.ChatPage)
}
return a, nil
}
case key.Matches(msg, keys.Logs):
return a, a.moveToPage(page.LogsPage)
@@ -402,10 +503,28 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.showHelp = !a.showHelp
return a, nil
}
case key.Matches(msg, keys.Filepicker):
a.showFilepicker = !a.showFilepicker
a.filepicker.ToggleFilepicker(a.showFilepicker)
return a, nil
}
default:
f, filepickerCmd := a.filepicker.Update(msg)
a.filepicker = f.(dialog.FilepickerCmp)
cmds = append(cmds, filepickerCmd)
}
if a.showFilepicker {
f, filepickerCmd := a.filepicker.Update(msg)
a.filepicker = f.(dialog.FilepickerCmp)
cmds = append(cmds, filepickerCmd)
// Only block key messages send all other messages down
if _, ok := msg.(tea.KeyMsg); ok {
return a, tea.Batch(cmds...)
}
}
if a.showQuit {
q, quitCmd := a.quit.Update(msg)
a.quit = q.(dialog.QuitDialog)
@@ -465,6 +584,16 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
if a.showThemeDialog {
d, themeCmd := a.themeDialog.Update(msg)
a.themeDialog = d.(dialog.ThemeDialog)
cmds = append(cmds, themeCmd)
// Only block key messages send all other messages down
if _, ok := msg.(tea.KeyMsg); ok {
return a, tea.Batch(cmds...)
}
}
s, _ := a.status.Update(msg)
a.status = s.(core.StatusCmp)
a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
@@ -482,6 +611,7 @@ func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
// For now we don't move to any page if the agent is busy
return util.ReportWarn("Agent is busy, please wait...")
}
var cmds []tea.Cmd
if _, ok := a.loadedPages[pageID]; !ok {
cmd := a.pages[pageID].Init()
@@ -522,10 +652,26 @@ func (a appModel) View() string {
)
}
if a.showFilepicker {
overlay := a.filepicker.View()
row := lipgloss.Height(appView) / 2
row -= lipgloss.Height(overlay) / 2
col := lipgloss.Width(appView) / 2
col -= lipgloss.Width(overlay) / 2
appView = layout.PlaceOverlay(
col,
row,
overlay,
appView,
true,
)
}
if !a.app.CoderAgent.IsBusy() {
a.status.SetHelpMsg("ctrl+? help")
a.status.SetHelpWidgetMsg("ctrl+? help")
} else {
a.status.SetHelpMsg("? help")
a.status.SetHelpWidgetMsg("? help")
}
if a.showHelp {
@@ -629,6 +775,36 @@ func (a appModel) View() string {
)
}
if a.showThemeDialog {
overlay := a.themeDialog.View()
row := lipgloss.Height(appView) / 2
row -= lipgloss.Height(overlay) / 2
col := lipgloss.Width(appView) / 2
col -= lipgloss.Width(overlay) / 2
appView = layout.PlaceOverlay(
col,
row,
overlay,
appView,
true,
)
}
if a.showArgumentsDialog {
overlay := a.argumentsDialog.View()
row := lipgloss.Height(appView) / 2
row -= lipgloss.Height(overlay) / 2
col := lipgloss.Width(appView) / 2
col -= lipgloss.Width(overlay) / 2
appView = layout.PlaceOverlay(
col,
row,
overlay,
appView,
true,
)
}
return appView
}
@@ -645,12 +821,14 @@ func New(app *app.App) tea.Model {
modelDialog: dialog.NewModelDialogCmp(),
permissions: dialog.NewPermissionDialogCmp(),
initDialog: dialog.NewInitDialogCmp(),
themeDialog: dialog.NewThemeDialogCmp(),
app: app,
commands: []dialog.Command{},
pages: map[page.PageID]tea.Model{
page.ChatPage: page.NewChatPage(app),
page.LogsPage: page.NewLogsPage(),
},
filepicker: dialog.NewFilepickerCmp(app),
}
model.RegisterCommand(dialog.Command{
@@ -672,5 +850,16 @@ If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (
)
},
})
// Load custom commands
customCommands, err := dialog.LoadCustomCommands()
if err != nil {
logging.Warn("Failed to load custom commands", "error", err)
} else {
for _, cmd := range customCommands {
model.RegisterCommand(cmd)
}
}
return model
}

View File

@@ -12,75 +12,79 @@
"model": {
"description": "Model ID for the agent",
"enum": [
"bedrock.claude-3.7-sonnet",
"claude-3-haiku",
"claude-3.7-sonnet",
"claude-3.5-haiku",
"o3",
"azure.o3",
"gpt-4.5-preview",
"azure.gpt-4.5-preview",
"o1-pro",
"o4-mini",
"azure.o4-mini",
"gpt-4.1",
"azure.gpt-4.1",
"o3-mini",
"azure.o3-mini",
"gpt-4.1-nano",
"azure.gpt-4.1-nano",
"gpt-4o-mini",
"o1-pro",
"azure.gpt-4o-mini",
"o1",
"azure.o1",
"gemini-2.5-flash",
"qwen-qwq",
"meta-llama/llama-4-maverick-17b-128e-instruct",
"claude-3-opus",
"gpt-4o",
"azure.gpt-4o",
"gemini-2.0-flash-lite",
"openrouter.gpt-4.1-mini",
"openrouter.o1-mini",
"bedrock.claude-3.7-sonnet",
"meta-llama/llama-4-scout-17b-16e-instruct",
"openrouter.gpt-4o-mini",
"gemini-2.0-flash",
"deepseek-r1-distill-llama-70b",
"llama-3.3-70b-versatile",
"claude-3.5-sonnet",
"o1-mini",
"azure.o1-mini",
"gpt-4.1-mini",
"azure.gpt-4.1-mini",
"gemini-2.5",
"meta-llama/llama-4-scout-17b-16e-instruct",
"openrouter.deepseek-chat-free",
"openrouter.deepseek-r1-free",
"openrouter.gpt-4.1",
"openrouter.gpt-4.1-mini",
"openrouter.gpt-4.1-nano",
"openrouter.claude-3.7-sonnet",
"openrouter.gpt-4.5-preview",
"openrouter.gpt-4o",
"openrouter.gpt-4o-mini",
"azure.o3-mini",
"openrouter.claude-3.5-haiku",
"azure.o1-mini",
"openrouter.o1",
"openrouter.o1-pro",
"openrouter.o1-mini",
"openrouter.o3",
"openrouter.o3-mini",
"openrouter.gemini-2.5",
"llama-3.3-70b-versatile",
"gpt-4.5-preview",
"openrouter.claude-3-opus",
"openrouter.claude-3.5-sonnet",
"o4-mini",
"gemini-2.0-flash-lite",
"azure.gpt-4.5-preview",
"openrouter.gpt-4o",
"o1",
"azure.gpt-4o",
"openrouter.gpt-4.1-nano",
"o3",
"gpt-4.1",
"azure.o1",
"claude-3-haiku",
"claude-3-opus",
"gpt-4.1-mini",
"openrouter.o4-mini",
"openrouter.gemini-2.5-flash",
"openrouter.gemini-2.5",
"openrouter.claude-3.5-sonnet",
"claude-3.5-haiku",
"o3-mini",
"azure.o3",
"gpt-4o",
"azure.gpt-4.1",
"openrouter.claude-3-haiku",
"openrouter.claude-3.7-sonnet",
"openrouter.claude-3.5-haiku",
"openrouter.claude-3-opus"
"gpt-4.1-nano",
"azure.gpt-4.1-nano",
"claude-3.7-sonnet",
"gemini-2.5",
"azure.o4-mini",
"o1-mini",
"qwen-qwq",
"meta-llama/llama-4-maverick-17b-128e-instruct",
"openrouter.gpt-4.1",
"openrouter.o1-pro",
"openrouter.o3",
"claude-3.5-sonnet",
"gemini-2.5-flash",
"azure.gpt-4.1-mini",
"openrouter.o3-mini"
],
"type": "string"
},
"reasoningEffort": {
"description": "Reasoning effort for models that support it (OpenAI, Anthropic)",
"enum": ["low", "medium", "high"],
"enum": [
"low",
"medium",
"high"
],
"type": "string"
}
},
"required": ["model"],
"required": [
"model"
],
"type": "object"
}
},
@@ -98,75 +102,79 @@
"model": {
"description": "Model ID for the agent",
"enum": [
"bedrock.claude-3.7-sonnet",
"claude-3-haiku",
"claude-3.7-sonnet",
"claude-3.5-haiku",
"o3",
"azure.o3",
"gpt-4.5-preview",
"azure.gpt-4.5-preview",
"o1-pro",
"o4-mini",
"azure.o4-mini",
"gpt-4.1",
"azure.gpt-4.1",
"o3-mini",
"azure.o3-mini",
"gpt-4.1-nano",
"azure.gpt-4.1-nano",
"gpt-4o-mini",
"o1-pro",
"azure.gpt-4o-mini",
"o1",
"azure.o1",
"gemini-2.5-flash",
"qwen-qwq",
"meta-llama/llama-4-maverick-17b-128e-instruct",
"claude-3-opus",
"gpt-4o",
"azure.gpt-4o",
"gemini-2.0-flash-lite",
"openrouter.gpt-4.1-mini",
"openrouter.o1-mini",
"bedrock.claude-3.7-sonnet",
"meta-llama/llama-4-scout-17b-16e-instruct",
"openrouter.gpt-4o-mini",
"gemini-2.0-flash",
"deepseek-r1-distill-llama-70b",
"llama-3.3-70b-versatile",
"claude-3.5-sonnet",
"o1-mini",
"azure.o1-mini",
"gpt-4.1-mini",
"azure.gpt-4.1-mini",
"gemini-2.5",
"meta-llama/llama-4-scout-17b-16e-instruct",
"openrouter.deepseek-chat-free",
"openrouter.deepseek-r1-free",
"openrouter.gpt-4.1",
"openrouter.gpt-4.1-mini",
"openrouter.gpt-4.1-nano",
"openrouter.claude-3.7-sonnet",
"openrouter.gpt-4.5-preview",
"openrouter.gpt-4o",
"openrouter.gpt-4o-mini",
"azure.o3-mini",
"openrouter.claude-3.5-haiku",
"azure.o1-mini",
"openrouter.o1",
"openrouter.o1-pro",
"openrouter.o1-mini",
"openrouter.o3",
"openrouter.o3-mini",
"openrouter.gemini-2.5",
"llama-3.3-70b-versatile",
"gpt-4.5-preview",
"openrouter.claude-3-opus",
"openrouter.claude-3.5-sonnet",
"o4-mini",
"gemini-2.0-flash-lite",
"azure.gpt-4.5-preview",
"openrouter.gpt-4o",
"o1",
"azure.gpt-4o",
"openrouter.gpt-4.1-nano",
"o3",
"gpt-4.1",
"azure.o1",
"claude-3-haiku",
"claude-3-opus",
"gpt-4.1-mini",
"openrouter.o4-mini",
"openrouter.gemini-2.5-flash",
"openrouter.gemini-2.5",
"openrouter.claude-3.5-sonnet",
"claude-3.5-haiku",
"o3-mini",
"azure.o3",
"gpt-4o",
"azure.gpt-4.1",
"openrouter.claude-3-haiku",
"openrouter.claude-3.7-sonnet",
"openrouter.claude-3.5-haiku",
"openrouter.claude-3-opus"
"gpt-4.1-nano",
"azure.gpt-4.1-nano",
"claude-3.7-sonnet",
"gemini-2.5",
"azure.o4-mini",
"o1-mini",
"qwen-qwq",
"meta-llama/llama-4-maverick-17b-128e-instruct",
"openrouter.gpt-4.1",
"openrouter.o1-pro",
"openrouter.o3",
"claude-3.5-sonnet",
"gemini-2.5-flash",
"azure.gpt-4.1-mini",
"openrouter.o3-mini"
],
"type": "string"
},
"reasoningEffort": {
"description": "Reasoning effort for models that support it (OpenAI, Anthropic)",
"enum": ["low", "medium", "high"],
"enum": [
"low",
"medium",
"high"
],
"type": "string"
}
},
"required": ["model"],
"required": [
"model"
],
"type": "object"
},
"description": "Agent configurations",
@@ -212,7 +220,9 @@
"type": "string"
}
},
"required": ["directory"],
"required": [
"directory"
],
"type": "object"
},
"debug": {
@@ -250,7 +260,9 @@
"type": "object"
}
},
"required": ["command"],
"required": [
"command"
],
"type": "object"
},
"description": "Language Server Protocol configurations",
@@ -288,7 +300,10 @@
"type": {
"default": "stdio",
"description": "Type of MCP server",
"enum": ["stdio", "sse"],
"enum": [
"stdio",
"sse"
],
"type": "string"
},
"url": {
@@ -296,7 +311,9 @@
"type": "string"
}
},
"required": ["command"],
"required": [
"command"
],
"type": "object"
},
"description": "Model Control Protocol server configurations",
@@ -322,9 +339,9 @@
"openai",
"gemini",
"groq",
"openrouter",
"bedrock",
"azure",
"openrouter"
"azure"
],
"type": "string"
}
@@ -334,6 +351,28 @@
"description": "LLM provider configurations",
"type": "object"
},
"tui": {
"description": "Terminal User Interface configuration",
"properties": {
"theme": {
"default": "opencode",
"description": "TUI theme name",
"enum": [
"opencode",
"catppuccin",
"dracula",
"flexoki",
"gruvbox",
"monokai",
"onedark",
"tokyonight",
"tron"
],
"type": "string"
}
},
"type": "object"
},
"wd": {
"description": "Working directory for the application",
"type": "string"