Compare commits

..

8 Commits

Author SHA1 Message Date
Kujtim Hoxha 44bf46209e update aur 2025-05-14 23:11:56 +02:00
Kujtim Hoxha b04faf0a3a update readme 2025-05-14 22:59:28 +02:00
Kujtim Hoxha 90084ce43d Context Window Warning (#152)
* context window warning & compact command

* auto compact

* fix permissions

* update readme

* fix 3.5 context window

* small update

* remove unused interface

* remove unused msg
2025-05-09 19:30:57 +02:00
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
19 changed files with 1125 additions and 235 deletions
+1 -1
View File
@@ -41,7 +41,7 @@ aurs:
- "kujtimiihoxha <kujtimii.h@gmail.com>"
license: "MIT"
private_key: "{{ .Env.AUR_KEY }}"
git_url: "ssh://aur@aur.archlinux.org/opencode-bin.git"
git_url: "ssh://aur@aur.archlinux.org/opencode-ai.git"
provides:
- opencode
conflicts:
+97 -15
View File
@@ -26,10 +26,10 @@ OpenCode is a Go-based CLI application that brings AI assistance to your termina
```bash
# Install the latest version
curl -fsSL https://opencode.ai/install | bash
curl -fsSL https://raw.githubusercontent.com/opencode-ai/opencode/refs/heads/main/install | bash
# Install a specific version
curl -fsSL https://opencode.ai/install | VERSION=0.1.0 bash
curl -fsSL https://raw.githubusercontent.com/opencode-ai/opencode/refs/heads/main/install | VERSION=0.1.0 bash
```
### Using Homebrew (macOS and Linux)
@@ -38,16 +38,6 @@ curl -fsSL https://opencode.ai/install | VERSION=0.1.0 bash
brew install opencode-ai/tap/opencode
```
### Using AUR (Arch Linux)
```bash
# Using yay
yay -S opencode-bin
# Using paru
paru -S opencode-bin
```
### Using Go
```bash
@@ -62,12 +52,29 @@ OpenCode looks for configuration in the following locations:
- `$XDG_CONFIG_HOME/opencode/.opencode.json`
- `./.opencode.json` (local directory)
### Auto Compact Feature
OpenCode includes an auto compact feature that automatically summarizes your conversation when it approaches the model's context window limit. When enabled (default setting), this feature:
- Monitors token usage during your conversation
- Automatically triggers summarization when usage reaches 95% of the model's context window
- Creates a new session with the summary, allowing you to continue your work without losing context
- Helps prevent "out of context" errors that can occur with long conversations
You can enable or disable this feature in your configuration file:
```json
{
"autoCompact": true // default is true
}
```
### Environment Variables
You can configure OpenCode using environment variables:
| Environment Variable | Purpose |
|----------------------------|--------------------------------------------------------|
| -------------------------- | ------------------------------------------------------ |
| `ANTHROPIC_API_KEY` | For Claude models |
| `OPENAI_API_KEY` | For OpenAI models |
| `GEMINI_API_KEY` | For Google Gemini models |
@@ -79,7 +86,6 @@ You can configure OpenCode using environment variables:
| `AZURE_OPENAI_API_KEY` | For Azure OpenAI models (optional when using Entra ID) |
| `AZURE_OPENAI_API_VERSION` | For Azure OpenAI models |
### Configuration File Structure
```json
@@ -134,7 +140,8 @@ You can configure OpenCode using environment variables:
}
},
"debug": false,
"debugLSP": false
"debugLSP": false,
"autoCompact": true
}
```
@@ -318,6 +325,81 @@ 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.
### Built-in Commands
OpenCode includes several built-in commands:
| Command | Description |
| ------------------ | --------------------------------------------------------------------------------------------------- |
| Initialize Project | Creates or updates the OpenCode.md memory file with project-specific information |
| Compact Session | Manually triggers the summarization of the current session, creating a new session with the summary |
## 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.
+1
View File
@@ -218,6 +218,7 @@ func setupSubscriptions(app *app.App, parentCtx context.Context) (chan tea.Msg,
setupSubscriber(ctx, &wg, "sessions", app.Sessions.Subscribe, ch)
setupSubscriber(ctx, &wg, "messages", app.Messages.Subscribe, ch)
setupSubscriber(ctx, &wg, "permissions", app.Permissions.Subscribe, ch)
setupSubscriber(ctx, &wg, "coderAgent", app.CoderAgent.Subscribe, ch)
cleanupFunc := func() {
logging.Info("Cancelling all subscriptions")
+3 -9
View File
@@ -18,7 +18,6 @@ require (
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
@@ -32,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
@@ -111,7 +108,6 @@ 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
@@ -120,13 +116,11 @@ require (
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
+4 -18
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=
@@ -123,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=
@@ -137,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=
@@ -254,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=
@@ -295,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=
@@ -337,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=
+22 -9
View File
@@ -36,9 +36,10 @@ type MCPServer struct {
type AgentName string
const (
AgentCoder AgentName = "coder"
AgentTask AgentName = "task"
AgentTitle AgentName = "title"
AgentCoder AgentName = "coder"
AgentSummarizer AgentName = "summarizer"
AgentTask AgentName = "task"
AgentTitle AgentName = "title"
)
// Agent defines configuration for different LLM models and their token limits.
@@ -84,6 +85,7 @@ type Config struct {
DebugLSP bool `json:"debugLSP,omitempty"`
ContextPaths []string `json:"contextPaths,omitempty"`
TUI TUIConfig `json:"tui"`
AutoCompact bool `json:"autoCompact,omitempty"`
}
// Application constants
@@ -213,6 +215,7 @@ func setDefaults(debug bool) {
viper.SetDefault("data.directory", defaultDataDirectory)
viper.SetDefault("contextPaths", defaultContextPaths)
viper.SetDefault("tui.theme", "opencode")
viper.SetDefault("autoCompact", true)
if debug {
viper.SetDefault("debug", true)
@@ -260,47 +263,54 @@ func setProviderDefaults() {
// 7. Azure
// Anthropic configuration
if viper.Get("providers.anthropic.apiKey") != "" {
if key := viper.GetString("providers.anthropic.apiKey"); strings.TrimSpace(key) != "" {
viper.SetDefault("agents.coder.model", models.Claude37Sonnet)
viper.SetDefault("agents.summarizer.model", models.Claude37Sonnet)
viper.SetDefault("agents.task.model", models.Claude37Sonnet)
viper.SetDefault("agents.title.model", models.Claude37Sonnet)
return
}
// OpenAI configuration
if viper.Get("providers.openai.apiKey") != "" {
if key := viper.GetString("providers.openai.apiKey"); strings.TrimSpace(key) != "" {
viper.SetDefault("agents.coder.model", models.GPT41)
viper.SetDefault("agents.summarizer.model", models.GPT41)
viper.SetDefault("agents.task.model", models.GPT41Mini)
viper.SetDefault("agents.title.model", models.GPT41Mini)
return
}
// Google Gemini configuration
if viper.Get("providers.google.gemini.apiKey") != "" {
if key := viper.GetString("providers.gemini.apiKey"); strings.TrimSpace(key) != "" {
viper.SetDefault("agents.coder.model", models.Gemini25)
viper.SetDefault("agents.summarizer.model", models.Gemini25)
viper.SetDefault("agents.task.model", models.Gemini25Flash)
viper.SetDefault("agents.title.model", models.Gemini25Flash)
return
}
// Groq configuration
if viper.Get("providers.groq.apiKey") != "" {
if key := viper.GetString("providers.groq.apiKey"); strings.TrimSpace(key) != "" {
viper.SetDefault("agents.coder.model", models.QWENQwq)
viper.SetDefault("agents.summarizer.model", models.QWENQwq)
viper.SetDefault("agents.task.model", models.QWENQwq)
viper.SetDefault("agents.title.model", models.QWENQwq)
return
}
// OpenRouter configuration
if viper.Get("providers.openrouter.apiKey") != "" {
if key := viper.GetString("providers.openrouter.apiKey"); strings.TrimSpace(key) != "" {
viper.SetDefault("agents.coder.model", models.OpenRouterClaude37Sonnet)
viper.SetDefault("agents.summarizer.model", models.OpenRouterClaude37Sonnet)
viper.SetDefault("agents.task.model", models.OpenRouterClaude37Sonnet)
viper.SetDefault("agents.title.model", models.OpenRouterClaude35Haiku)
return
}
if viper.Get("providers.xai.apiKey") != "" {
// XAI configuration
if key := viper.GetString("providers.xai.apiKey"); strings.TrimSpace(key) != "" {
viper.SetDefault("agents.coder.model", models.XAIGrok3Beta)
viper.SetDefault("agents.summarizer.model", models.XAIGrok3Beta)
viper.SetDefault("agents.task.model", models.XAIGrok3Beta)
viper.SetDefault("agents.title.model", models.XAiGrok3MiniFastBeta)
return
@@ -309,13 +319,16 @@ func setProviderDefaults() {
// AWS Bedrock configuration
if hasAWSCredentials() {
viper.SetDefault("agents.coder.model", models.BedrockClaude37Sonnet)
viper.SetDefault("agents.summarizer.model", models.BedrockClaude37Sonnet)
viper.SetDefault("agents.task.model", models.BedrockClaude37Sonnet)
viper.SetDefault("agents.title.model", models.BedrockClaude37Sonnet)
return
}
// Azure OpenAI configuration
if os.Getenv("AZURE_OPENAI_ENDPOINT") != "" {
viper.SetDefault("agents.coder.model", models.AzureGPT41)
viper.SetDefault("agents.summarizer.model", models.AzureGPT41)
viper.SetDefault("agents.task.model", models.AzureGPT41Mini)
viper.SetDefault("agents.title.model", models.AzureGPT41Mini)
return
+3 -5
View File
@@ -69,11 +69,11 @@ func (b *agentTool) Run(ctx context.Context, call tools.ToolCall) (tools.ToolRes
return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", err)
}
result := <-done
if result.Err() != nil {
return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", result.Err())
if result.Error != nil {
return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", result.Error)
}
response := result.Response()
response := result.Message
if response.Role != message.Assistant {
return tools.NewTextErrorResponse("no response"), nil
}
@@ -88,8 +88,6 @@ func (b *agentTool) Run(ctx context.Context, call tools.ToolCall) (tools.ToolRes
}
parentSession.Cost += updatedSession.Cost
parentSession.PromptTokens += updatedSession.PromptTokens
parentSession.CompletionTokens += updatedSession.CompletionTokens
_, err = b.sessions.Save(ctx, parentSession)
if err != nil {
+216 -22
View File
@@ -15,6 +15,7 @@ import (
"github.com/opencode-ai/opencode/internal/logging"
"github.com/opencode-ai/opencode/internal/message"
"github.com/opencode-ai/opencode/internal/permission"
"github.com/opencode-ai/opencode/internal/pubsub"
"github.com/opencode-ai/opencode/internal/session"
)
@@ -24,35 +25,46 @@ var (
ErrSessionBusy = errors.New("session is currently processing another request")
)
type AgentEventType string
const (
AgentEventTypeError AgentEventType = "error"
AgentEventTypeResponse AgentEventType = "response"
AgentEventTypeSummarize AgentEventType = "summarize"
)
type AgentEvent struct {
message message.Message
err error
}
Type AgentEventType
Message message.Message
Error error
func (e *AgentEvent) Err() error {
return e.err
}
func (e *AgentEvent) Response() message.Message {
return e.message
// When summarizing
SessionID string
Progress string
Done bool
}
type Service interface {
pubsub.Suscriber[AgentEvent]
Model() models.Model
Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error)
Cancel(sessionID string)
IsSessionBusy(sessionID string) bool
IsBusy() bool
Update(agentName config.AgentName, modelID models.ModelID) (models.Model, error)
Summarize(ctx context.Context, sessionID string) error
}
type agent struct {
*pubsub.Broker[AgentEvent]
sessions session.Service
messages message.Service
tools []tools.BaseTool
provider provider.Provider
titleProvider provider.Provider
titleProvider provider.Provider
summarizeProvider provider.Provider
activeRequests sync.Map
}
@@ -75,26 +87,48 @@ func NewAgent(
return nil, err
}
}
var summarizeProvider provider.Provider
if agentName == config.AgentCoder {
summarizeProvider, err = createAgentProvider(config.AgentSummarizer)
if err != nil {
return nil, err
}
}
agent := &agent{
provider: agentProvider,
messages: messages,
sessions: sessions,
tools: agentTools,
titleProvider: titleProvider,
activeRequests: sync.Map{},
Broker: pubsub.NewBroker[AgentEvent](),
provider: agentProvider,
messages: messages,
sessions: sessions,
tools: agentTools,
titleProvider: titleProvider,
summarizeProvider: summarizeProvider,
activeRequests: sync.Map{},
}
return agent, nil
}
func (a *agent) Model() models.Model {
return a.provider.Model()
}
func (a *agent) Cancel(sessionID string) {
// Cancel regular requests
if cancelFunc, exists := a.activeRequests.LoadAndDelete(sessionID); exists {
if cancel, ok := cancelFunc.(context.CancelFunc); ok {
logging.InfoPersist(fmt.Sprintf("Request cancellation initiated for session: %s", sessionID))
cancel()
}
}
// Also check for summarize requests
if cancelFunc, exists := a.activeRequests.LoadAndDelete(sessionID + "-summarize"); exists {
if cancel, ok := cancelFunc.(context.CancelFunc); ok {
logging.InfoPersist(fmt.Sprintf("Summarize cancellation initiated for session: %s", sessionID))
cancel()
}
}
}
func (a *agent) IsBusy() bool {
@@ -154,7 +188,8 @@ func (a *agent) generateTitle(ctx context.Context, sessionID string, content str
func (a *agent) err(err error) AgentEvent {
return AgentEvent{
err: err,
Type: AgentEventTypeError,
Error: err,
}
}
@@ -180,12 +215,13 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string, attac
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(result.Err().Error())
if result.Error != nil && !errors.Is(result.Error, ErrRequestCancelled) && !errors.Is(result.Error, context.Canceled) {
logging.ErrorPersist(result.Error.Error())
}
logging.Debug("Request completed", "sessionID", sessionID)
a.activeRequests.Delete(sessionID)
cancel()
a.Publish(pubsub.CreatedEvent, result)
events <- result
close(events)
}()
@@ -241,7 +277,9 @@ func (a *agent) processGeneration(ctx context.Context, sessionID, content string
continue
}
return AgentEvent{
message: agentMessage,
Type: AgentEventTypeResponse,
Message: agentMessage,
Done: true,
}
}
}
@@ -432,8 +470,8 @@ func (a *agent) TrackUsage(ctx context.Context, sessionID string, model models.M
model.CostPer1MOut/1e6*float64(usage.OutputTokens)
sess.Cost += cost
sess.CompletionTokens += usage.OutputTokens
sess.PromptTokens += usage.InputTokens
sess.CompletionTokens = usage.OutputTokens + usage.CacheReadTokens
sess.PromptTokens = usage.InputTokens + usage.CacheCreationTokens
_, err = a.sessions.Save(ctx, sess)
if err != nil {
@@ -461,6 +499,162 @@ func (a *agent) Update(agentName config.AgentName, modelID models.ModelID) (mode
return a.provider.Model(), nil
}
func (a *agent) Summarize(ctx context.Context, sessionID string) error {
if a.summarizeProvider == nil {
return fmt.Errorf("summarize provider not available")
}
// Check if session is busy
if a.IsSessionBusy(sessionID) {
return ErrSessionBusy
}
// Create a new context with cancellation
summarizeCtx, cancel := context.WithCancel(ctx)
// Store the cancel function in activeRequests to allow cancellation
a.activeRequests.Store(sessionID+"-summarize", cancel)
go func() {
defer a.activeRequests.Delete(sessionID + "-summarize")
defer cancel()
event := AgentEvent{
Type: AgentEventTypeSummarize,
Progress: "Starting summarization...",
}
a.Publish(pubsub.CreatedEvent, event)
// Get all messages from the session
msgs, err := a.messages.List(summarizeCtx, sessionID)
if err != nil {
event = AgentEvent{
Type: AgentEventTypeError,
Error: fmt.Errorf("failed to list messages: %w", err),
Done: true,
}
a.Publish(pubsub.CreatedEvent, event)
return
}
if len(msgs) == 0 {
event = AgentEvent{
Type: AgentEventTypeError,
Error: fmt.Errorf("no messages to summarize"),
Done: true,
}
a.Publish(pubsub.CreatedEvent, event)
return
}
event = AgentEvent{
Type: AgentEventTypeSummarize,
Progress: "Analyzing conversation...",
}
a.Publish(pubsub.CreatedEvent, event)
// Add a system message to guide the summarization
summarizePrompt := "Provide a detailed but concise summary of our conversation above. Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next."
// Create a new message with the summarize prompt
promptMsg := message.Message{
Role: message.User,
Parts: []message.ContentPart{message.TextContent{Text: summarizePrompt}},
}
// Append the prompt to the messages
msgsWithPrompt := append(msgs, promptMsg)
event = AgentEvent{
Type: AgentEventTypeSummarize,
Progress: "Generating summary...",
}
a.Publish(pubsub.CreatedEvent, event)
// Send the messages to the summarize provider
response, err := a.summarizeProvider.SendMessages(
summarizeCtx,
msgsWithPrompt,
make([]tools.BaseTool, 0),
)
if err != nil {
event = AgentEvent{
Type: AgentEventTypeError,
Error: fmt.Errorf("failed to summarize: %w", err),
Done: true,
}
a.Publish(pubsub.CreatedEvent, event)
return
}
summary := strings.TrimSpace(response.Content)
if summary == "" {
event = AgentEvent{
Type: AgentEventTypeError,
Error: fmt.Errorf("empty summary returned"),
Done: true,
}
a.Publish(pubsub.CreatedEvent, event)
return
}
event = AgentEvent{
Type: AgentEventTypeSummarize,
Progress: "Creating new session...",
}
a.Publish(pubsub.CreatedEvent, event)
oldSession, err := a.sessions.Get(summarizeCtx, sessionID)
if err != nil {
event = AgentEvent{
Type: AgentEventTypeError,
Error: fmt.Errorf("failed to get session: %w", err),
Done: true,
}
a.Publish(pubsub.CreatedEvent, event)
return
}
// Create a new session with the summary
newSession, err := a.sessions.Create(summarizeCtx, oldSession.Title+" - Continuation")
if err != nil {
event = AgentEvent{
Type: AgentEventTypeError,
Error: fmt.Errorf("failed to create new session: %w", err),
Done: true,
}
a.Publish(pubsub.CreatedEvent, event)
return
}
// Create a message in the new session with the summary
_, err = a.messages.Create(summarizeCtx, newSession.ID, message.CreateMessageParams{
Role: message.Assistant,
Parts: []message.ContentPart{message.TextContent{Text: summary}},
Model: a.summarizeProvider.Model().ID,
})
if err != nil {
event = AgentEvent{
Type: AgentEventTypeError,
Error: fmt.Errorf("failed to create summary message: %w", err),
Done: true,
}
a.Publish(pubsub.CreatedEvent, event)
return
}
event = AgentEvent{
Type: AgentEventTypeSummarize,
SessionID: newSession.ID,
Progress: "Summary complete",
Done: true,
}
a.Publish(pubsub.CreatedEvent, event)
// Send final success event with the new session ID
}()
return nil
}
func createAgentProvider(agentName config.AgentName) (provider.Provider, error) {
cfg := config.Get()
agentConfig, ok := cfg.Agents[agentName]
+2
View File
@@ -21,6 +21,8 @@ func GetAgentPrompt(agentName config.AgentName, provider models.ModelProvider) s
basePrompt = TitlePrompt(provider)
case config.AgentTask:
basePrompt = TaskPrompt(provider)
case config.AgentSummarizer:
basePrompt = SummarizerPrompt(provider)
default:
basePrompt = "You are a helpful assistant"
}
+16
View File
@@ -0,0 +1,16 @@
package prompt
import "github.com/opencode-ai/opencode/internal/llm/models"
func SummarizerPrompt(_ models.ModelProvider) string {
return `You are a helpful AI assistant tasked with summarizing conversations.
When asked to summarize, provide a detailed but concise summary of the conversation.
Focus on information that would be helpful for continuing the conversation, including:
- What was done
- What is currently being worked on
- Which files are being modified
- What needs to be done next
Your summary should be comprehensive enough to provide context but concise enough to be quickly understood.`
}
+69 -70
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,11 +55,14 @@ 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.Text(msg.Content().String()))
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.ImageData(imageFormat[1], binaryContent.Data))
parts = append(parts, &genai.Part{InlineData: &genai.Blob{
MIMEType: imageFormat[1],
Data: binaryContent.Data,
}})
}
history = append(history, &genai.Content{
Parts: parts,
@@ -70,19 +71,21 @@ func (g *geminiClient) convertMessages(messages []message.Message) []*genai.Cont
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,
},
})
}
}
@@ -110,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",
})
}
@@ -157,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)
@@ -178,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)
@@ -210,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,
@@ -244,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)
@@ -265,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)
@@ -273,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{}
@@ -285,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 {
@@ -318,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,
@@ -328,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,
@@ -421,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",
})
+33 -28
View File
@@ -21,7 +21,6 @@ import (
type StatusCmp interface {
tea.Model
SetHelpWidgetMsg(string)
}
type statusCmp struct {
@@ -74,11 +73,9 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var helpWidget = ""
// getHelpWidget returns the help widget with current theme colors
func getHelpWidget(helpText string) string {
func getHelpWidget() string {
t := theme.CurrentTheme()
if helpText == "" {
helpText = "ctrl+? help"
}
helpText := "ctrl+? help"
return styles.Padded().
Background(t.TextMuted()).
@@ -87,7 +84,7 @@ func getHelpWidget(helpText string) string {
Render(helpText)
}
func formatTokensAndCost(tokens int64, cost float64) string {
func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
// Format tokens in human-readable format (e.g., 110K, 1.2M)
var formattedTokens string
switch {
@@ -110,32 +107,48 @@ func formatTokensAndCost(tokens int64, cost float64) string {
// Format cost with $ symbol and 2 decimal places
formattedCost := fmt.Sprintf("$%.2f", cost)
return fmt.Sprintf("Tokens: %s, Cost: %s", formattedTokens, formattedCost)
percentage := (float64(tokens) / float64(contextWindow)) * 100
if percentage > 80 {
// add the warning icon and percentage
formattedTokens = fmt.Sprintf("%s(%d%%)", styles.WarningIcon, int(percentage))
}
return fmt.Sprintf("Context: %s, Cost: %s", formattedTokens, formattedCost)
}
func (m statusCmp) View() string {
t := theme.CurrentTheme()
modelID := config.Get().Agents[config.AgentCoder].Model
model := models.SupportedModels[modelID]
// Initialize the help widget
status := getHelpWidget("")
status := getHelpWidget()
tokenInfoWidth := 0
if m.session.ID != "" {
tokens := formatTokensAndCost(m.session.PromptTokens+m.session.CompletionTokens, m.session.Cost)
totalTokens := m.session.PromptTokens + m.session.CompletionTokens
tokens := formatTokensAndCost(totalTokens, model.ContextWindow, m.session.Cost)
tokensStyle := styles.Padded().
Background(t.Text()).
Foreground(t.BackgroundSecondary()).
Render(tokens)
status += tokensStyle
Foreground(t.BackgroundSecondary())
percentage := (float64(totalTokens) / float64(model.ContextWindow)) * 100
if percentage > 80 {
tokensStyle = tokensStyle.Background(t.Warning())
}
tokenInfoWidth = lipgloss.Width(tokens) + 2
status += tokensStyle.Render(tokens)
}
diagnostics := styles.Padded().
Background(t.BackgroundDarker()).
Render(m.projectDiagnostics())
availableWidht := max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-tokenInfoWidth)
if m.info.Msg != "" {
infoStyle := styles.Padded().
Foreground(t.Background()).
Width(m.availableFooterMsgWidth(diagnostics))
Width(availableWidht)
switch m.info.Type {
case util.InfoTypeInfo:
@@ -146,18 +159,18 @@ func (m statusCmp) View() string {
infoStyle = infoStyle.Background(t.Error())
}
infoWidth := availableWidht - 10
// Truncate message if it's longer than available width
msg := m.info.Msg
availWidth := m.availableFooterMsgWidth(diagnostics) - 10
if len(msg) > availWidth && availWidth > 0 {
msg = msg[:availWidth] + "..."
if len(msg) > infoWidth && infoWidth > 0 {
msg = msg[:infoWidth] + "..."
}
status += infoStyle.Render(msg)
} else {
status += styles.Padded().
Foreground(t.Text()).
Background(t.BackgroundSecondary()).
Width(m.availableFooterMsgWidth(diagnostics)).
Width(availableWidht).
Render("")
}
@@ -245,12 +258,10 @@ func (m *statusCmp) projectDiagnostics() string {
return strings.Join(diagnostics, " ")
}
func (m statusCmp) availableFooterMsgWidth(diagnostics string) int {
tokens := ""
func (m statusCmp) availableFooterMsgWidth(diagnostics, tokenInfo string) int {
tokensWidth := 0
if m.session.ID != "" {
tokens = formatTokensAndCost(m.session.PromptTokens+m.session.CompletionTokens, m.session.Cost)
tokensWidth = lipgloss.Width(tokens) + 2
tokensWidth = lipgloss.Width(tokenInfo) + 2
}
return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-tokensWidth)
}
@@ -272,14 +283,8 @@ func (m statusCmp) model() string {
Render(model.Name)
}
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("")
helpWidget = getHelpWidget()
return &statusCmp{
messageTTL: 10 * time.Second,
+173
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
}
@@ -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
}
+29 -35
View File
@@ -127,6 +127,9 @@ func (f *filepickerCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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()
@@ -165,7 +168,6 @@ func (f *filepickerCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
isPathDir = f.dirs[f.cursor].IsDir()
}
if isPathDir {
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
@@ -216,9 +218,6 @@ func (f *filepickerCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
f.getCurrentFileBelowCursor()
}
}
if f.cwd.Focused() {
f.cwd, cmd = f.cwd.Update(msg)
}
return f, cmd
}
@@ -228,37 +227,35 @@ func (f *filepickerCmp) addAttachmentToMessage() (tea.Model, tea.Cmd) {
logging.ErrorPersist(fmt.Sprintf("Model %s doesn't support attachments", modeInfo.Name))
return f, nil
}
if isExtSupported(f.dirs[f.cursor].Name()) {
f.selectedFile = f.dirs[f.cursor].Name()
selectedFilePath := filepath.Join(f.cwdDetails.directory, "/", f.selectedFile)
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 := f.selectedFile
attachment := message.Attachment{FilePath: selectedFilePath, FileName: fileName, MimeType: mimeType, Content: content}
f.selectedFile = ""
return f, util.CmdHandler(AttachmentAddedMsg{attachment})
}
if !isExtSupported(f.selectedFile) {
selectedFilePath := f.selectedFile
if !isExtSupported(selectedFilePath) {
logging.ErrorPersist("Unsupported file")
return f, nil
}
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 {
@@ -305,11 +302,8 @@ func (f *filepickerCmp) View() string {
}
if file.IsDir() {
filename = filename + "/"
} else if isExtSupported(file.Name()) {
filename = filename
} else {
filename = filename
}
// No need to reassign filename if it's not changing
files = append(files, itemStyle.Padding(0, 1).Render(filename))
}
+40 -12
View File
@@ -2,6 +2,8 @@ package dialog
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
@@ -13,7 +15,6 @@ import (
"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
@@ -150,7 +151,7 @@ func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
func (p *permissionDialogCmp) renderButtons() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
allowStyle := baseStyle
allowSessionStyle := baseStyle
denyStyle := baseStyle
@@ -196,7 +197,7 @@ func (p *permissionDialogCmp) renderButtons() string {
func (p *permissionDialogCmp) renderHeader() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
toolKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("Tool")
toolValue := baseStyle.
Foreground(t.Text()).
@@ -229,9 +230,36 @@ func (p *permissionDialogCmp) renderHeader() string {
case tools.BashToolName:
headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Command"))
case tools.EditToolName:
headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Diff"))
params := p.permission.Params.(tools.EditPermissionsParams)
fileKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("File")
filePath := baseStyle.
Foreground(t.Text()).
Width(p.width - lipgloss.Width(fileKey)).
Render(fmt.Sprintf(": %s", params.FilePath))
headerParts = append(headerParts,
lipgloss.JoinHorizontal(
lipgloss.Left,
fileKey,
filePath,
),
baseStyle.Render(strings.Repeat(" ", p.width)),
)
case tools.WriteToolName:
headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Diff"))
params := p.permission.Params.(tools.WritePermissionsParams)
fileKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("File")
filePath := baseStyle.
Foreground(t.Text()).
Width(p.width - lipgloss.Width(fileKey)).
Render(fmt.Sprintf(": %s", params.FilePath))
headerParts = append(headerParts,
lipgloss.JoinHorizontal(
lipgloss.Left,
fileKey,
filePath,
),
baseStyle.Render(strings.Repeat(" ", p.width)),
)
case tools.FetchToolName:
headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("URL"))
}
@@ -242,13 +270,13 @@ func (p *permissionDialogCmp) renderHeader() string {
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 := styles.GetMarkdownRenderer(p.width-10)
r := styles.GetMarkdownRenderer(p.width - 10)
s, err := r.Render(content)
return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
})
@@ -302,13 +330,13 @@ 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 := styles.GetMarkdownRenderer(p.width-10)
r := styles.GetMarkdownRenderer(p.width - 10)
s, err := r.Render(content)
return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
})
@@ -325,12 +353,12 @@ func (p *permissionDialogCmp) renderFetchContent() string {
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 := styles.GetMarkdownRenderer(p.width-10)
r := styles.GetMarkdownRenderer(p.width - 10)
s, err := r.Render(content)
return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
})
@@ -358,7 +386,7 @@ func (p *permissionDialogCmp) styleViewport() string {
func (p *permissionDialogCmp) render() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
title := baseStyle.
Bold(true).
Width(p.width - 4).
+11
View File
@@ -9,6 +9,7 @@ import (
"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"
)
@@ -57,6 +58,16 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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
}
case chat.SessionSelectedMsg:
if p.session.ID == "" {
cmd := p.setSidebar()
+198 -11
View File
@@ -3,20 +3,24 @@ package tui
import (
"context"
"fmt"
"strings"
"github.com/charmbracelet/bubbles/key"
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/llm/agent"
"github.com/opencode-ai/opencode/internal/logging"
"github.com/opencode-ai/opencode/internal/permission"
"github.com/opencode-ai/opencode/internal/pubsub"
"github.com/opencode-ai/opencode/internal/session"
"github.com/opencode-ai/opencode/internal/tui/components/chat"
"github.com/opencode-ai/opencode/internal/tui/components/core"
"github.com/opencode-ai/opencode/internal/tui/components/dialog"
"github.com/opencode-ai/opencode/internal/tui/layout"
"github.com/opencode-ai/opencode/internal/tui/page"
"github.com/opencode-ai/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/tui/util"
)
@@ -31,6 +35,8 @@ type keyMap struct {
SwitchTheme key.Binding
}
type startCompactSessionMsg struct{}
const (
quitKey = "q"
)
@@ -90,13 +96,14 @@ var logsKeyReturnKey = key.NewBinding(
)
type appModel struct {
width, height int
currentPage page.PageID
previousPage page.PageID
pages map[page.PageID]tea.Model
loadedPages map[page.PageID]bool
status core.StatusCmp
app *app.App
width, height int
currentPage page.PageID
previousPage page.PageID
pages map[page.PageID]tea.Model
loadedPages map[page.PageID]bool
status core.StatusCmp
app *app.App
selectedSession session.Session
showPermissions bool
permissions dialog.PermissionDialogCmp
@@ -125,6 +132,12 @@ type appModel struct {
showThemeDialog bool
themeDialog dialog.ThemeDialog
showArgumentsDialog bool
argumentsDialog dialog.ArgumentsDialogCmp
isCompacting bool
compactingMessage string
}
func (a appModel) Init() tea.Cmd {
@@ -147,6 +160,7 @@ func (a appModel) Init() tea.Cmd {
cmd = a.initDialog.Init()
cmds = append(cmds, cmd)
cmd = a.filepicker.Init()
cmds = append(cmds, cmd)
cmd = a.themeDialog.Init()
cmds = append(cmds, cmd)
@@ -200,6 +214,13 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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
case util.InfoMsg:
@@ -282,6 +303,70 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.showCommandDialog = false
return a, nil
case startCompactSessionMsg:
// Start compacting the current session
a.isCompacting = true
a.compactingMessage = "Starting summarization..."
if a.selectedSession.ID == "" {
a.isCompacting = false
return a, util.ReportWarn("No active session to summarize")
}
// Start the summarization process
return a, func() tea.Msg {
ctx := context.Background()
a.app.CoderAgent.Summarize(ctx, a.selectedSession.ID)
return nil
}
case pubsub.Event[agent.AgentEvent]:
payload := msg.Payload
if payload.Error != nil {
a.isCompacting = false
return a, util.ReportError(payload.Error)
}
a.compactingMessage = payload.Progress
if payload.Done && payload.Type == agent.AgentEventTypeSummarize {
a.isCompacting = false
if payload.SessionID != "" {
// Switch to the new session
return a, func() tea.Msg {
sessions, err := a.app.Sessions.List(context.Background())
if err != nil {
return util.InfoMsg{
Type: util.InfoTypeError,
Msg: "Failed to list sessions: " + err.Error(),
}
}
for _, s := range sessions {
if s.ID == payload.SessionID {
return dialog.SessionSelectedMsg{Session: s}
}
}
return util.InfoMsg{
Type: util.InfoTypeError,
Msg: "Failed to find new session",
}
}
}
return a, util.ReportInfo("Session summarization complete")
} else if payload.Done && payload.Type == agent.AgentEventTypeResponse && a.selectedSession.ID != "" {
model := a.app.CoderAgent.Model()
contextWindow := model.ContextWindow
tokens := a.selectedSession.CompletionTokens + a.selectedSession.PromptTokens
if (tokens >= int64(float64(contextWindow)*0.95)) && config.Get().AutoCompact {
return a, util.CmdHandler(startCompactSessionMsg{})
}
}
// Continue listening for events
return a, nil
case dialog.CloseThemeDialogMsg:
a.showThemeDialog = false
return a, nil
@@ -331,7 +416,13 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, nil
case chat.SessionSelectedMsg:
a.selectedSession = msg
a.sessionDialog.SetSelectedSession(msg.ID)
case pubsub.Event[session.Session]:
if msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == a.selectedSession.ID {
a.selectedSession = msg.Payload
}
case dialog.SessionSelectedMsg:
a.showSessionDialog = false
if a.currentPage == page.ChatPage {
@@ -347,7 +438,36 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
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):
@@ -368,6 +488,9 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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 {
@@ -563,6 +686,15 @@ func (a *appModel) RegisterCommand(cmd dialog.Command) {
a.commands = append(a.commands, cmd)
}
func (a *appModel) findCommand(id string) (dialog.Command, bool) {
for _, cmd := range a.commands {
if cmd.ID == id {
return cmd, true
}
}
return dialog.Command{}, false
}
func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
if a.app.CoderAgent.IsBusy() {
// For now we don't move to any page if the agent is busy
@@ -625,10 +757,29 @@ func (a appModel) View() string {
}
if !a.app.CoderAgent.IsBusy() {
a.status.SetHelpWidgetMsg("ctrl+? help")
} else {
a.status.SetHelpWidgetMsg("? help")
// Show compacting status overlay
if a.isCompacting {
t := theme.CurrentTheme()
style := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(t.BorderFocused()).
BorderBackground(t.Background()).
Padding(1, 2).
Background(t.Background()).
Foreground(t.Text())
overlay := style.Render("Summarizing\n" + a.compactingMessage)
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.showHelp {
@@ -747,6 +898,21 @@ func (a appModel) View() string {
)
}
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
}
@@ -792,5 +958,26 @@ If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (
)
},
})
model.RegisterCommand(dialog.Command{
ID: "compact",
Title: "Compact Session",
Description: "Summarize the current session and create a new one with the summary",
Handler: func(cmd dialog.Command) tea.Cmd {
return func() tea.Msg {
return startCompactSessionMsg{}
}
},
})
// 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
}
+41
View File
@@ -0,0 +1,41 @@
#!/bin/bash
# Script to check for hidden/invisible characters in Go files
# This helps detect potential prompt injection attempts
echo "Checking Go files for hidden characters..."
# Find all Go files in the repository
go_files=$(find . -name "*.go" -type f)
# Counter for files with hidden characters
files_with_hidden=0
for file in $go_files; do
# Check for specific Unicode hidden characters that could be used for prompt injection
# This excludes normal whitespace like tabs and newlines
# Looking for:
# - Zero-width spaces (U+200B)
# - Zero-width non-joiners (U+200C)
# - Zero-width joiners (U+200D)
# - Left-to-right/right-to-left marks (U+200E, U+200F)
# - Bidirectional overrides (U+202A-U+202E)
# - Byte order mark (U+FEFF)
if hexdump -C "$file" | grep -E 'e2 80 8b|e2 80 8c|e2 80 8d|e2 80 8e|e2 80 8f|e2 80 aa|e2 80 ab|e2 80 ac|e2 80 ad|e2 80 ae|ef bb bf' > /dev/null 2>&1; then
echo "Hidden characters found in: $file"
# Show the file with potential issues
echo " Hexdump showing suspicious characters:"
hexdump -C "$file" | grep -E 'e2 80 8b|e2 80 8c|e2 80 8d|e2 80 8e|e2 80 8f|e2 80 aa|e2 80 ab|e2 80 ac|e2 80 ad|e2 80 ae|ef bb bf' | head -10
files_with_hidden=$((files_with_hidden + 1))
fi
done
if [ $files_with_hidden -eq 0 ]; then
echo "No hidden characters found in any Go files."
else
echo "Found hidden characters in $files_with_hidden Go file(s)."
fi
exit $files_with_hidden # Exit with number of affected files as status code