├── .github └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── .goreleaser.yml ├── .opencode.json ├── CONTEXT.md ├── LICENSE ├── README.md ├── cmd ├── non_interactive_mode.go ├── root.go ├── root_test.go └── schema │ ├── README.md │ └── main.go ├── go.mod ├── go.sum ├── install ├── internal ├── app │ ├── app.go │ └── lsp.go ├── completions │ └── files-folders.go ├── config │ ├── config.go │ └── init.go ├── db │ ├── connect.go │ ├── db.go │ ├── embed.go │ ├── files.sql.go │ ├── logs.sql.go │ ├── messages.sql.go │ ├── migrations │ │ └── 20250513000000_initial.sql │ ├── models.go │ ├── querier.go │ ├── sessions.sql.go │ └── sql │ │ ├── files.sql │ │ ├── logs.sql │ │ ├── messages.sql │ │ └── sessions.sql ├── diff │ ├── diff.go │ ├── diff_test.go │ └── patch.go ├── fileutil │ └── fileutil.go ├── format │ ├── format.go │ └── format_test.go ├── history │ └── history.go ├── llm │ ├── agent │ │ ├── agent-tool.go │ │ ├── agent.go │ │ ├── mcp-tools.go │ │ └── tools.go │ ├── models │ │ ├── anthropic.go │ │ ├── azure.go │ │ ├── bedrock.go │ │ ├── gemini.go │ │ ├── groq.go │ │ ├── models.go │ │ ├── openai.go │ │ ├── openrouter.go │ │ ├── vertexai.go │ │ └── xai.go │ ├── prompt │ │ ├── primary.go │ │ ├── prompt.go │ │ ├── prompt_test.go │ │ ├── task.go │ │ └── title.go │ ├── provider │ │ ├── anthropic.go │ │ ├── azure.go │ │ ├── bedrock.go │ │ ├── gemini.go │ │ ├── openai.go │ │ ├── openai_completion.go │ │ ├── openai_response.go │ │ ├── provider.go │ │ └── vertexai.go │ └── tools │ │ ├── bash.go │ │ ├── batch.go │ │ ├── batch_test.go │ │ ├── edit.go │ │ ├── fetch.go │ │ ├── file.go │ │ ├── glob.go │ │ ├── grep.go │ │ ├── ls.go │ │ ├── ls_test.go │ │ ├── lsp_code_action.go │ │ ├── lsp_definition.go │ │ ├── lsp_diagnostics.go │ │ ├── lsp_doc_symbols.go │ │ ├── lsp_references.go │ │ ├── lsp_workspace_symbols.go │ │ ├── patch.go │ │ ├── shell │ │ └── shell.go │ │ ├── tools.go │ │ ├── view.go │ │ └── write.go ├── logging │ └── logging.go ├── lsp │ ├── client.go │ ├── discovery │ │ ├── integration.go │ │ ├── language.go │ │ └── server.go │ ├── handlers.go │ ├── language.go │ ├── methods.go │ ├── protocol.go │ ├── protocol │ │ ├── LICENSE │ │ ├── interface.go │ │ ├── pattern_interfaces.go │ │ ├── tables.go │ │ ├── tsdocument-changes.go │ │ ├── tsjson.go │ │ ├── tsprotocol.go │ │ └── uri.go │ ├── transport.go │ ├── util │ │ └── edit.go │ └── watcher │ │ └── watcher.go ├── message │ ├── attachment.go │ ├── content.go │ └── message.go ├── permission │ └── permission.go ├── pubsub │ ├── broker.go │ ├── broker_test.go │ └── events.go ├── session │ └── session.go ├── status │ └── status.go ├── tui │ ├── components │ │ ├── chat │ │ │ ├── chat.go │ │ │ ├── editor.go │ │ │ ├── message.go │ │ │ ├── messages.go │ │ │ └── sidebar.go │ │ ├── core │ │ │ └── status.go │ │ ├── dialog │ │ │ ├── arguments.go │ │ │ ├── commands.go │ │ │ ├── complete.go │ │ │ ├── custom_commands.go │ │ │ ├── custom_commands_test.go │ │ │ ├── filepicker.go │ │ │ ├── help.go │ │ │ ├── init.go │ │ │ ├── models.go │ │ │ ├── permission.go │ │ │ ├── quit.go │ │ │ ├── session.go │ │ │ ├── theme.go │ │ │ └── tools.go │ │ ├── logs │ │ │ ├── details.go │ │ │ └── table.go │ │ ├── spinner │ │ │ ├── spinner.go │ │ │ └── spinner_test.go │ │ └── util │ │ │ └── simple-list.go │ ├── image │ │ ├── clipboard_unix.go │ │ ├── clipboard_windows.go │ │ └── images.go │ ├── layout │ │ ├── container.go │ │ ├── layout.go │ │ ├── overlay.go │ │ └── split.go │ ├── page │ │ ├── chat.go │ │ ├── logs.go │ │ └── page.go │ ├── state │ │ └── state.go │ ├── styles │ │ ├── background.go │ │ ├── icons.go │ │ ├── markdown.go │ │ └── styles.go │ ├── theme │ │ ├── ayu.go │ │ ├── catppuccin.go │ │ ├── dracula.go │ │ ├── flexoki.go │ │ ├── gruvbox.go │ │ ├── manager.go │ │ ├── monokai.go │ │ ├── nord.go │ │ ├── onedark.go │ │ ├── opencode.go │ │ ├── theme.go │ │ ├── theme_test.go │ │ ├── tokyonight.go │ │ └── tron.go │ ├── tui.go │ └── util │ │ └── util.go └── version │ └── version.go ├── main.go ├── opencode-schema.json ├── screenshot.png ├── scripts ├── release └── snapshot ├── sqlc.yaml └── www ├── .gitignore ├── README.md ├── astro.config.mjs ├── bun.lock ├── package.json ├── public ├── favicon.svg └── social-share.png ├── src ├── assets │ ├── lander │ │ ├── check.svg │ │ └── copy.svg │ ├── logo-dark.svg │ └── logo-light.svg ├── components │ ├── Hero.astro │ └── Lander.astro ├── content.config.ts └── content │ └── docs │ ├── docs │ ├── cli.mdx │ ├── config.mdx │ ├── index.mdx │ ├── lsp-servers.mdx │ ├── mcp-servers.mdx │ ├── models.mdx │ ├── shortcuts.mdx │ └── themes.mdx │ └── index.mdx └── tsconfig.json /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - dev 8 | 9 | concurrency: ${{ github.workflow }}-${{ github.ref }} 10 | 11 | permissions: 12 | contents: write 13 | packages: write 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v3 20 | with: 21 | fetch-depth: 0 22 | 23 | - run: git fetch --force --tags 24 | 25 | - uses: actions/setup-go@v5 26 | with: 27 | go-version: ">=1.23.2" 28 | cache: true 29 | cache-dependency-path: go.sum 30 | 31 | - run: go mod download 32 | 33 | - uses: goreleaser/goreleaser-action@v6 34 | with: 35 | distribution: goreleaser 36 | version: latest 37 | args: build --snapshot --clean 38 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: 7 | - "*" 8 | 9 | concurrency: ${{ github.workflow }}-${{ github.ref }} 10 | 11 | permissions: 12 | contents: write 13 | packages: write 14 | 15 | jobs: 16 | goreleaser: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v3 20 | with: 21 | fetch-depth: 0 22 | 23 | - run: git fetch --force --tags 24 | 25 | - uses: actions/setup-go@v5 26 | with: 27 | go-version: ">=1.23.2" 28 | cache: true 29 | cache-dependency-path: go.sum 30 | 31 | - run: go mod download 32 | 33 | - uses: goreleaser/goreleaser-action@v6 34 | with: 35 | distribution: goreleaser 36 | version: latest 37 | args: release --clean 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }} 40 | AUR_KEY: ${{ secrets.AUR_KEY }} 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | # Go workspace file 18 | go.work 19 | 20 | # IDE specific files 21 | .idea/ 22 | .vscode/ 23 | *.swp 24 | *.swo 25 | 26 | # OS specific files 27 | .DS_Store 28 | .DS_Store? 29 | ._* 30 | .Spotlight-V100 31 | .Trashes 32 | ehthumbs.db 33 | Thumbs.db 34 | *.log 35 | 36 | # Binary output directory 37 | /bin/ 38 | /dist/ 39 | 40 | # Local environment variables 41 | .env 42 | .env.local 43 | 44 | .opencode/ 45 | # ignore locally built binary 46 | opencode* 47 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | project_name: opencode 3 | before: 4 | hooks: 5 | builds: 6 | - env: 7 | - CGO_ENABLED=0 8 | goos: 9 | - linux 10 | - darwin 11 | goarch: 12 | - amd64 13 | - arm64 14 | ldflags: 15 | - -s -w -X github.com/sst/opencode/internal/version.Version={{.Version}} 16 | main: ./main.go 17 | 18 | archives: 19 | - format: tar.gz 20 | name_template: >- 21 | opencode- 22 | {{- if eq .Os "darwin" }}mac- 23 | {{- else if eq .Os "windows" }}windows- 24 | {{- else if eq .Os "linux" }}linux-{{end}} 25 | {{- if eq .Arch "amd64" }}x86_64 26 | {{- else if eq .Arch "#86" }}i386 27 | {{- else }}{{ .Arch }}{{ end }} 28 | {{- if .Arm }}v{{ .Arm }}{{ end }} 29 | format_overrides: 30 | - goos: windows 31 | format: zip 32 | checksum: 33 | name_template: "checksums.txt" 34 | snapshot: 35 | name_template: "0.0.0-{{ .Timestamp }}" 36 | aurs: 37 | - name: opencode 38 | homepage: "https://github.com/sst/opencode" 39 | description: "terminal based agent that can build anything" 40 | maintainers: 41 | - "dax" 42 | - "adam" 43 | license: "MIT" 44 | private_key: "{{ .Env.AUR_KEY }}" 45 | git_url: "ssh://aur@aur.archlinux.org/opencode-bin.git" 46 | provides: 47 | - opencode 48 | conflicts: 49 | - opencode 50 | package: |- 51 | install -Dm755 ./opencode "${pkgdir}/usr/bin/opencode" 52 | brews: 53 | - repository: 54 | owner: sst 55 | name: homebrew-tap 56 | nfpms: 57 | - maintainer: kujtimiihoxha 58 | description: terminal based agent that can build anything 59 | formats: 60 | - deb 61 | - rpm 62 | file_name_template: >- 63 | {{ .ProjectName }}- 64 | {{- if eq .Os "darwin" }}mac 65 | {{- else }}{{ .Os }}{{ end }}-{{ .Arch }} 66 | 67 | changelog: 68 | sort: asc 69 | filters: 70 | exclude: 71 | - "^docs:" 72 | - "^doc:" 73 | - "^test:" 74 | - "^ci:" 75 | - "^ignore:" 76 | - "^example:" 77 | - "^wip:" 78 | -------------------------------------------------------------------------------- /.opencode.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./opencode-schema.json" 3 | } 4 | -------------------------------------------------------------------------------- /CONTEXT.md: -------------------------------------------------------------------------------- 1 | # OpenCode Development Context 2 | 3 | ## Build Commands 4 | - Build: `go build` 5 | - Run: `go run main.go` 6 | - Test: `go test ./...` 7 | - Test single package: `go test ./internal/package/...` 8 | - Test single test: `go test ./internal/package -run TestName` 9 | - Verbose test: `go test -v ./...` 10 | - Coverage: `go test -cover ./...` 11 | - Lint: `go vet ./...` 12 | - Format: `go fmt ./...` 13 | - Build snapshot: `./scripts/snapshot` 14 | 15 | ## Code Style 16 | - Use Go 1.24+ features 17 | - Follow standard Go formatting (gofmt) 18 | - Use table-driven tests with t.Parallel() when possible 19 | - Error handling: check errors immediately, return early 20 | - Naming: CamelCase for exported, camelCase for unexported 21 | - Imports: standard library first, then external, then internal 22 | - Use context.Context for cancellation and timeouts 23 | - Prefer interfaces for dependencies to enable testing 24 | - Use testify for assertions in tests -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Kujtim Hoxha 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /cmd/root_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "os" 7 | "testing" 8 | ) 9 | 10 | func TestCheckStdinPipe(t *testing.T) { 11 | // Save original stdin 12 | origStdin := os.Stdin 13 | 14 | // Restore original stdin when test completes 15 | defer func() { 16 | os.Stdin = origStdin 17 | }() 18 | 19 | // Test case 1: Data is piped in 20 | t.Run("WithPipedData", func(t *testing.T) { 21 | // Create a pipe 22 | r, w, err := os.Pipe() 23 | if err != nil { 24 | t.Fatalf("Failed to create pipe: %v", err) 25 | } 26 | 27 | // Replace stdin with our pipe 28 | os.Stdin = r 29 | 30 | // Write test data to the pipe 31 | testData := "test piped input" 32 | go func() { 33 | defer w.Close() 34 | w.Write([]byte(testData)) 35 | }() 36 | 37 | // Call the function 38 | data, hasPiped := checkStdinPipe() 39 | 40 | // Check results 41 | if !hasPiped { 42 | t.Error("Expected hasPiped to be true, got false") 43 | } 44 | if data != testData { 45 | t.Errorf("Expected data to be %q, got %q", testData, data) 46 | } 47 | }) 48 | 49 | // Test case 2: No data is piped in (simulated terminal) 50 | t.Run("WithoutPipedData", func(t *testing.T) { 51 | // Create a temporary file to simulate a terminal 52 | tmpFile, err := os.CreateTemp("", "terminal-sim") 53 | if err != nil { 54 | t.Fatalf("Failed to create temp file: %v", err) 55 | } 56 | defer os.Remove(tmpFile.Name()) 57 | defer tmpFile.Close() 58 | 59 | // Open the file for reading 60 | f, err := os.Open(tmpFile.Name()) 61 | if err != nil { 62 | t.Fatalf("Failed to open temp file: %v", err) 63 | } 64 | defer f.Close() 65 | 66 | // Replace stdin with our file 67 | os.Stdin = f 68 | 69 | // Call the function 70 | data, hasPiped := checkStdinPipe() 71 | 72 | // Check results 73 | if hasPiped { 74 | t.Error("Expected hasPiped to be false, got true") 75 | } 76 | if data != "" { 77 | t.Errorf("Expected data to be empty, got %q", data) 78 | } 79 | }) 80 | } 81 | 82 | // This is a mock implementation for testing since we can't easily mock os.Stdin.Stat() 83 | // in a way that would return the correct Mode() for our test cases 84 | func mockCheckStdinPipe(reader io.Reader, isPipe bool) (string, bool) { 85 | if !isPipe { 86 | return "", false 87 | } 88 | 89 | data, err := io.ReadAll(reader) 90 | if err != nil { 91 | return "", false 92 | } 93 | 94 | if len(data) > 0 { 95 | return string(data), true 96 | } 97 | return "", false 98 | } 99 | 100 | func TestMockCheckStdinPipe(t *testing.T) { 101 | // Test with data 102 | t.Run("WithData", func(t *testing.T) { 103 | testData := "test data" 104 | reader := bytes.NewBufferString(testData) 105 | 106 | data, hasPiped := mockCheckStdinPipe(reader, true) 107 | 108 | if !hasPiped { 109 | t.Error("Expected hasPiped to be true, got false") 110 | } 111 | if data != testData { 112 | t.Errorf("Expected data to be %q, got %q", testData, data) 113 | } 114 | }) 115 | 116 | // Test without data 117 | t.Run("WithoutData", func(t *testing.T) { 118 | reader := bytes.NewBufferString("") 119 | 120 | data, hasPiped := mockCheckStdinPipe(reader, true) 121 | 122 | if hasPiped { 123 | t.Error("Expected hasPiped to be false, got true") 124 | } 125 | if data != "" { 126 | t.Errorf("Expected data to be empty, got %q", data) 127 | } 128 | }) 129 | 130 | // Test not a pipe 131 | t.Run("NotAPipe", func(t *testing.T) { 132 | reader := bytes.NewBufferString("data that should be ignored") 133 | 134 | data, hasPiped := mockCheckStdinPipe(reader, false) 135 | 136 | if hasPiped { 137 | t.Error("Expected hasPiped to be false, got true") 138 | } 139 | if data != "" { 140 | t.Errorf("Expected data to be empty, got %q", data) 141 | } 142 | }) 143 | } -------------------------------------------------------------------------------- /cmd/schema/README.md: -------------------------------------------------------------------------------- 1 | # OpenCode Configuration Schema Generator 2 | 3 | This tool generates a JSON Schema for the OpenCode configuration file. The schema can be used to validate configuration files and provide autocompletion in editors that support JSON Schema. 4 | 5 | ## Usage 6 | 7 | ```bash 8 | go run cmd/schema/main.go > opencode-schema.json 9 | ``` 10 | 11 | This will generate a JSON Schema file that can be used to validate configuration files. 12 | 13 | ## Schema Features 14 | 15 | The generated schema includes: 16 | 17 | - All configuration options with descriptions 18 | - Default values where applicable 19 | - Validation for enum values (e.g., model IDs, provider types) 20 | - Required fields 21 | - Type checking 22 | 23 | ## Using the Schema 24 | 25 | You can use the generated schema in several ways: 26 | 27 | 1. **Editor Integration**: Many editors (VS Code, JetBrains IDEs, etc.) support JSON Schema for validation and autocompletion. You can configure your editor to use the generated schema for `.opencode.json` files. 28 | 29 | 2. **Validation Tools**: You can use tools like [jsonschema](https://github.com/Julian/jsonschema) to validate your configuration files against the schema. 30 | 31 | 3. **Documentation**: The schema serves as documentation for the configuration options. 32 | 33 | ## Example Configuration 34 | 35 | Here's an example configuration that conforms to the schema: 36 | 37 | ```json 38 | { 39 | "data": { 40 | "directory": ".opencode" 41 | }, 42 | "debug": false, 43 | "providers": { 44 | "anthropic": { 45 | "apiKey": "your-api-key" 46 | } 47 | }, 48 | "agents": { 49 | "primary": { 50 | "model": "claude-3.7-sonnet", 51 | "maxTokens": 5000, 52 | "reasoningEffort": "medium" 53 | }, 54 | "task": { 55 | "model": "claude-3.7-sonnet", 56 | "maxTokens": 5000 57 | }, 58 | "title": { 59 | "model": "claude-3.7-sonnet", 60 | "maxTokens": 80 61 | } 62 | } 63 | } 64 | ``` 65 | 66 | -------------------------------------------------------------------------------- /internal/app/app.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "maps" 7 | "sync" 8 | "time" 9 | 10 | "log/slog" 11 | 12 | "github.com/sst/opencode/internal/config" 13 | "github.com/sst/opencode/internal/fileutil" 14 | "github.com/sst/opencode/internal/history" 15 | "github.com/sst/opencode/internal/llm/agent" 16 | "github.com/sst/opencode/internal/logging" 17 | "github.com/sst/opencode/internal/lsp" 18 | "github.com/sst/opencode/internal/message" 19 | "github.com/sst/opencode/internal/permission" 20 | "github.com/sst/opencode/internal/session" 21 | "github.com/sst/opencode/internal/status" 22 | "github.com/sst/opencode/internal/tui/theme" 23 | ) 24 | 25 | type App struct { 26 | CurrentSession *session.Session 27 | Logs logging.Service 28 | Sessions session.Service 29 | Messages message.Service 30 | History history.Service 31 | Permissions permission.Service 32 | Status status.Service 33 | 34 | PrimaryAgent agent.Service 35 | 36 | LSPClients map[string]*lsp.Client 37 | 38 | clientsMutex sync.RWMutex 39 | 40 | watcherCancelFuncs []context.CancelFunc 41 | cancelFuncsMutex sync.Mutex 42 | watcherWG sync.WaitGroup 43 | 44 | // UI state 45 | filepickerOpen bool 46 | completionDialogOpen bool 47 | } 48 | 49 | func New(ctx context.Context, conn *sql.DB) (*App, error) { 50 | err := logging.InitService(conn) 51 | if err != nil { 52 | slog.Error("Failed to initialize logging service", "error", err) 53 | return nil, err 54 | } 55 | err = session.InitService(conn) 56 | if err != nil { 57 | slog.Error("Failed to initialize session service", "error", err) 58 | return nil, err 59 | } 60 | err = message.InitService(conn) 61 | if err != nil { 62 | slog.Error("Failed to initialize message service", "error", err) 63 | return nil, err 64 | } 65 | err = history.InitService(conn) 66 | if err != nil { 67 | slog.Error("Failed to initialize history service", "error", err) 68 | return nil, err 69 | } 70 | err = permission.InitService() 71 | if err != nil { 72 | slog.Error("Failed to initialize permission service", "error", err) 73 | return nil, err 74 | } 75 | err = status.InitService() 76 | if err != nil { 77 | slog.Error("Failed to initialize status service", "error", err) 78 | return nil, err 79 | } 80 | fileutil.Init() 81 | 82 | app := &App{ 83 | CurrentSession: &session.Session{}, 84 | Logs: logging.GetService(), 85 | Sessions: session.GetService(), 86 | Messages: message.GetService(), 87 | History: history.GetService(), 88 | Permissions: permission.GetService(), 89 | Status: status.GetService(), 90 | LSPClients: make(map[string]*lsp.Client), 91 | } 92 | 93 | // Initialize theme based on configuration 94 | app.initTheme() 95 | 96 | // Initialize LSP clients in the background 97 | go app.initLSPClients(ctx) 98 | 99 | app.PrimaryAgent, err = agent.NewAgent( 100 | config.AgentPrimary, 101 | app.Sessions, 102 | app.Messages, 103 | agent.PrimaryAgentTools( 104 | app.Permissions, 105 | app.Sessions, 106 | app.Messages, 107 | app.History, 108 | app.LSPClients, 109 | ), 110 | ) 111 | if err != nil { 112 | slog.Error("Failed to create primary agent", "error", err) 113 | return nil, err 114 | } 115 | 116 | return app, nil 117 | } 118 | 119 | // initTheme sets the application theme based on the configuration 120 | func (app *App) initTheme() { 121 | cfg := config.Get() 122 | if cfg == nil || cfg.TUI.Theme == "" { 123 | return // Use default theme 124 | } 125 | 126 | // Try to set the theme from config 127 | err := theme.SetTheme(cfg.TUI.Theme) 128 | if err != nil { 129 | slog.Warn("Failed to set theme from config, using default theme", "theme", cfg.TUI.Theme, "error", err) 130 | } else { 131 | slog.Debug("Set theme from config", "theme", cfg.TUI.Theme) 132 | } 133 | } 134 | 135 | // IsFilepickerOpen returns whether the filepicker is currently open 136 | func (app *App) IsFilepickerOpen() bool { 137 | return app.filepickerOpen 138 | } 139 | 140 | // SetFilepickerOpen sets the state of the filepicker 141 | func (app *App) SetFilepickerOpen(open bool) { 142 | app.filepickerOpen = open 143 | } 144 | 145 | // IsCompletionDialogOpen returns whether the completion dialog is currently open 146 | func (app *App) IsCompletionDialogOpen() bool { 147 | return app.completionDialogOpen 148 | } 149 | 150 | // SetCompletionDialogOpen sets the state of the completion dialog 151 | func (app *App) SetCompletionDialogOpen(open bool) { 152 | app.completionDialogOpen = open 153 | } 154 | 155 | // Shutdown performs a clean shutdown of the application 156 | func (app *App) Shutdown() { 157 | // Cancel all watcher goroutines 158 | app.cancelFuncsMutex.Lock() 159 | for _, cancel := range app.watcherCancelFuncs { 160 | cancel() 161 | } 162 | app.cancelFuncsMutex.Unlock() 163 | app.watcherWG.Wait() 164 | 165 | // Perform additional cleanup for LSP clients 166 | app.clientsMutex.RLock() 167 | clients := make(map[string]*lsp.Client, len(app.LSPClients)) 168 | maps.Copy(clients, app.LSPClients) 169 | app.clientsMutex.RUnlock() 170 | 171 | for name, client := range clients { 172 | shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 173 | if err := client.Shutdown(shutdownCtx); err != nil { 174 | slog.Error("Failed to shutdown LSP client", "name", name, "error", err) 175 | } 176 | cancel() 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /internal/app/lsp.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "log/slog" 8 | 9 | "github.com/sst/opencode/internal/config" 10 | "github.com/sst/opencode/internal/logging" 11 | "github.com/sst/opencode/internal/lsp" 12 | "github.com/sst/opencode/internal/lsp/watcher" 13 | ) 14 | 15 | func (app *App) initLSPClients(ctx context.Context) { 16 | cfg := config.Get() 17 | 18 | // Initialize LSP clients 19 | for name, clientConfig := range cfg.LSP { 20 | // Start each client initialization in its own goroutine 21 | go app.createAndStartLSPClient(ctx, name, clientConfig.Command, clientConfig.Args...) 22 | } 23 | slog.Info("LSP clients initialization started in background") 24 | } 25 | 26 | // createAndStartLSPClient creates a new LSP client, initializes it, and starts its workspace watcher 27 | func (app *App) createAndStartLSPClient(ctx context.Context, name string, command string, args ...string) { 28 | // Create a specific context for initialization with a timeout 29 | slog.Info("Creating LSP client", "name", name, "command", command, "args", args) 30 | 31 | // Create the LSP client 32 | lspClient, err := lsp.NewClient(ctx, command, args...) 33 | if err != nil { 34 | slog.Error("Failed to create LSP client for", name, err) 35 | return 36 | } 37 | 38 | // Create a longer timeout for initialization (some servers take time to start) 39 | initCtx, cancel := context.WithTimeout(ctx, 30*time.Second) 40 | defer cancel() 41 | 42 | // Initialize with the initialization context 43 | _, err = lspClient.InitializeLSPClient(initCtx, config.WorkingDirectory()) 44 | if err != nil { 45 | slog.Error("Initialize failed", "name", name, "error", err) 46 | // Clean up the client to prevent resource leaks 47 | lspClient.Close() 48 | return 49 | } 50 | 51 | // Wait for the server to be ready 52 | if err := lspClient.WaitForServerReady(initCtx); err != nil { 53 | slog.Error("Server failed to become ready", "name", name, "error", err) 54 | // We'll continue anyway, as some functionality might still work 55 | lspClient.SetServerState(lsp.StateError) 56 | } else { 57 | slog.Info("LSP server is ready", "name", name) 58 | lspClient.SetServerState(lsp.StateReady) 59 | } 60 | 61 | slog.Info("LSP client initialized", "name", name) 62 | 63 | // Create a child context that can be canceled when the app is shutting down 64 | watchCtx, cancelFunc := context.WithCancel(ctx) 65 | 66 | // Create a context with the server name for better identification 67 | watchCtx = context.WithValue(watchCtx, "serverName", name) 68 | 69 | // Create the workspace watcher 70 | workspaceWatcher := watcher.NewWorkspaceWatcher(lspClient) 71 | 72 | // Store the cancel function to be called during cleanup 73 | app.cancelFuncsMutex.Lock() 74 | app.watcherCancelFuncs = append(app.watcherCancelFuncs, cancelFunc) 75 | app.cancelFuncsMutex.Unlock() 76 | 77 | // Add the watcher to a WaitGroup to track active goroutines 78 | app.watcherWG.Add(1) 79 | 80 | // Add to map with mutex protection before starting goroutine 81 | app.clientsMutex.Lock() 82 | app.LSPClients[name] = lspClient 83 | app.clientsMutex.Unlock() 84 | 85 | go app.runWorkspaceWatcher(watchCtx, name, workspaceWatcher) 86 | } 87 | 88 | // runWorkspaceWatcher executes the workspace watcher for an LSP client 89 | func (app *App) runWorkspaceWatcher(ctx context.Context, name string, workspaceWatcher *watcher.WorkspaceWatcher) { 90 | defer app.watcherWG.Done() 91 | defer logging.RecoverPanic("LSP-"+name, func() { 92 | // Try to restart the client 93 | app.restartLSPClient(ctx, name) 94 | }) 95 | 96 | workspaceWatcher.WatchWorkspace(ctx, config.WorkingDirectory()) 97 | slog.Info("Workspace watcher stopped", "client", name) 98 | } 99 | 100 | // restartLSPClient attempts to restart a crashed or failed LSP client 101 | func (app *App) restartLSPClient(ctx context.Context, name string) { 102 | // Get the original configuration 103 | cfg := config.Get() 104 | clientConfig, exists := cfg.LSP[name] 105 | if !exists { 106 | slog.Error("Cannot restart client, configuration not found", "client", name) 107 | return 108 | } 109 | 110 | // Clean up the old client if it exists 111 | app.clientsMutex.Lock() 112 | oldClient, exists := app.LSPClients[name] 113 | if exists { 114 | delete(app.LSPClients, name) // Remove from map before potentially slow shutdown 115 | } 116 | app.clientsMutex.Unlock() 117 | 118 | if exists && oldClient != nil { 119 | // Try to shut it down gracefully, but don't block on errors 120 | shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 121 | _ = oldClient.Shutdown(shutdownCtx) 122 | cancel() 123 | 124 | // Ensure we close the client to free resources 125 | _ = oldClient.Close() 126 | } 127 | 128 | // Wait a moment before restarting to avoid rapid restart cycles 129 | time.Sleep(1 * time.Second) 130 | 131 | // Create a new client using the shared function 132 | app.createAndStartLSPClient(ctx, name, clientConfig.Command, clientConfig.Args...) 133 | slog.Info("Successfully restarted LSP client", "client", name) 134 | } 135 | -------------------------------------------------------------------------------- /internal/config/init.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | const ( 10 | // InitFlagFilename is the name of the file that indicates whether the project has been initialized 11 | InitFlagFilename = "init" 12 | ) 13 | 14 | // ProjectInitFlag represents the initialization status for a project directory 15 | type ProjectInitFlag struct { 16 | Initialized bool `json:"initialized"` 17 | } 18 | 19 | // ShouldShowInitDialog checks if the initialization dialog should be shown for the current directory 20 | func ShouldShowInitDialog() (bool, error) { 21 | if cfg == nil { 22 | return false, fmt.Errorf("config not loaded") 23 | } 24 | 25 | // Create the flag file path 26 | flagFilePath := filepath.Join(cfg.Data.Directory, InitFlagFilename) 27 | 28 | // Check if the flag file exists 29 | _, err := os.Stat(flagFilePath) 30 | if err == nil { 31 | // File exists, don't show the dialog 32 | return false, nil 33 | } 34 | 35 | // If the error is not "file not found", return the error 36 | if !os.IsNotExist(err) { 37 | return false, fmt.Errorf("failed to check init flag file: %w", err) 38 | } 39 | 40 | // File doesn't exist, show the dialog 41 | return true, nil 42 | } 43 | 44 | // MarkProjectInitialized marks the current project as initialized 45 | func MarkProjectInitialized() error { 46 | if cfg == nil { 47 | return fmt.Errorf("config not loaded") 48 | } 49 | // Create the flag file path 50 | flagFilePath := filepath.Join(cfg.Data.Directory, InitFlagFilename) 51 | 52 | // Create an empty file to mark the project as initialized 53 | file, err := os.Create(flagFilePath) 54 | if err != nil { 55 | return fmt.Errorf("failed to create init flag file: %w", err) 56 | } 57 | defer file.Close() 58 | 59 | return nil 60 | } 61 | -------------------------------------------------------------------------------- /internal/db/connect.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | 9 | _ "github.com/ncruces/go-sqlite3/driver" 10 | _ "github.com/ncruces/go-sqlite3/embed" 11 | 12 | "github.com/sst/opencode/internal/config" 13 | "log/slog" 14 | 15 | "github.com/pressly/goose/v3" 16 | ) 17 | 18 | func Connect() (*sql.DB, error) { 19 | dataDir := config.Get().Data.Directory 20 | if dataDir == "" { 21 | return nil, fmt.Errorf("data.dir is not set") 22 | } 23 | if err := os.MkdirAll(dataDir, 0o700); err != nil { 24 | return nil, fmt.Errorf("failed to create data directory: %w", err) 25 | } 26 | dbPath := filepath.Join(dataDir, "opencode.db") 27 | // Open the SQLite database 28 | db, err := sql.Open("sqlite3", dbPath) 29 | if err != nil { 30 | return nil, fmt.Errorf("failed to open database: %w", err) 31 | } 32 | 33 | // Verify connection 34 | if err = db.Ping(); err != nil { 35 | db.Close() 36 | return nil, fmt.Errorf("failed to connect to database: %w", err) 37 | } 38 | 39 | // Set pragmas for better performance 40 | pragmas := []string{ 41 | "PRAGMA foreign_keys = ON;", 42 | "PRAGMA journal_mode = WAL;", 43 | "PRAGMA page_size = 4096;", 44 | "PRAGMA cache_size = -8000;", 45 | "PRAGMA synchronous = NORMAL;", 46 | } 47 | 48 | for _, pragma := range pragmas { 49 | if _, err = db.Exec(pragma); err != nil { 50 | slog.Error("Failed to set pragma", pragma, err) 51 | } else { 52 | slog.Debug("Set pragma", "pragma", pragma) 53 | } 54 | } 55 | 56 | goose.SetBaseFS(FS) 57 | 58 | if err := goose.SetDialect("sqlite3"); err != nil { 59 | slog.Error("Failed to set dialect", "error", err) 60 | return nil, fmt.Errorf("failed to set dialect: %w", err) 61 | } 62 | 63 | if err := goose.Up(db, "migrations"); err != nil { 64 | slog.Error("Failed to apply migrations", "error", err) 65 | return nil, fmt.Errorf("failed to apply migrations: %w", err) 66 | } 67 | return db, nil 68 | } 69 | -------------------------------------------------------------------------------- /internal/db/embed.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import "embed" 4 | 5 | //go:embed migrations/*.sql 6 | var FS embed.FS 7 | -------------------------------------------------------------------------------- /internal/db/logs.sql.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.29.0 4 | // source: logs.sql 5 | 6 | package db 7 | 8 | import ( 9 | "context" 10 | "database/sql" 11 | ) 12 | 13 | const createLog = `-- name: CreateLog :one 14 | INSERT INTO logs ( 15 | id, 16 | session_id, 17 | timestamp, 18 | level, 19 | message, 20 | attributes 21 | ) VALUES ( 22 | ?, 23 | ?, 24 | ?, 25 | ?, 26 | ?, 27 | ? 28 | ) RETURNING id, session_id, timestamp, level, message, attributes, created_at, updated_at 29 | ` 30 | 31 | type CreateLogParams struct { 32 | ID string `json:"id"` 33 | SessionID sql.NullString `json:"session_id"` 34 | Timestamp string `json:"timestamp"` 35 | Level string `json:"level"` 36 | Message string `json:"message"` 37 | Attributes sql.NullString `json:"attributes"` 38 | } 39 | 40 | func (q *Queries) CreateLog(ctx context.Context, arg CreateLogParams) (Log, error) { 41 | row := q.queryRow(ctx, q.createLogStmt, createLog, 42 | arg.ID, 43 | arg.SessionID, 44 | arg.Timestamp, 45 | arg.Level, 46 | arg.Message, 47 | arg.Attributes, 48 | ) 49 | var i Log 50 | err := row.Scan( 51 | &i.ID, 52 | &i.SessionID, 53 | &i.Timestamp, 54 | &i.Level, 55 | &i.Message, 56 | &i.Attributes, 57 | &i.CreatedAt, 58 | &i.UpdatedAt, 59 | ) 60 | return i, err 61 | } 62 | 63 | const listAllLogs = `-- name: ListAllLogs :many 64 | SELECT id, session_id, timestamp, level, message, attributes, created_at, updated_at FROM logs 65 | ORDER BY timestamp DESC 66 | LIMIT ? 67 | ` 68 | 69 | func (q *Queries) ListAllLogs(ctx context.Context, limit int64) ([]Log, error) { 70 | rows, err := q.query(ctx, q.listAllLogsStmt, listAllLogs, limit) 71 | if err != nil { 72 | return nil, err 73 | } 74 | defer rows.Close() 75 | items := []Log{} 76 | for rows.Next() { 77 | var i Log 78 | if err := rows.Scan( 79 | &i.ID, 80 | &i.SessionID, 81 | &i.Timestamp, 82 | &i.Level, 83 | &i.Message, 84 | &i.Attributes, 85 | &i.CreatedAt, 86 | &i.UpdatedAt, 87 | ); err != nil { 88 | return nil, err 89 | } 90 | items = append(items, i) 91 | } 92 | if err := rows.Close(); err != nil { 93 | return nil, err 94 | } 95 | if err := rows.Err(); err != nil { 96 | return nil, err 97 | } 98 | return items, nil 99 | } 100 | 101 | const listLogsBySession = `-- name: ListLogsBySession :many 102 | SELECT id, session_id, timestamp, level, message, attributes, created_at, updated_at FROM logs 103 | WHERE session_id = ? 104 | ORDER BY timestamp DESC 105 | ` 106 | 107 | func (q *Queries) ListLogsBySession(ctx context.Context, sessionID sql.NullString) ([]Log, error) { 108 | rows, err := q.query(ctx, q.listLogsBySessionStmt, listLogsBySession, sessionID) 109 | if err != nil { 110 | return nil, err 111 | } 112 | defer rows.Close() 113 | items := []Log{} 114 | for rows.Next() { 115 | var i Log 116 | if err := rows.Scan( 117 | &i.ID, 118 | &i.SessionID, 119 | &i.Timestamp, 120 | &i.Level, 121 | &i.Message, 122 | &i.Attributes, 123 | &i.CreatedAt, 124 | &i.UpdatedAt, 125 | ); err != nil { 126 | return nil, err 127 | } 128 | items = append(items, i) 129 | } 130 | if err := rows.Close(); err != nil { 131 | return nil, err 132 | } 133 | if err := rows.Err(); err != nil { 134 | return nil, err 135 | } 136 | return items, nil 137 | } 138 | -------------------------------------------------------------------------------- /internal/db/messages.sql.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.29.0 4 | // source: messages.sql 5 | 6 | package db 7 | 8 | import ( 9 | "context" 10 | "database/sql" 11 | ) 12 | 13 | const createMessage = `-- name: CreateMessage :one 14 | INSERT INTO messages ( 15 | id, 16 | session_id, 17 | role, 18 | parts, 19 | model 20 | ) VALUES ( 21 | ?, ?, ?, ?, ? 22 | ) 23 | RETURNING id, session_id, role, parts, model, created_at, updated_at, finished_at 24 | ` 25 | 26 | type CreateMessageParams struct { 27 | ID string `json:"id"` 28 | SessionID string `json:"session_id"` 29 | Role string `json:"role"` 30 | Parts string `json:"parts"` 31 | Model sql.NullString `json:"model"` 32 | } 33 | 34 | func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error) { 35 | row := q.queryRow(ctx, q.createMessageStmt, createMessage, 36 | arg.ID, 37 | arg.SessionID, 38 | arg.Role, 39 | arg.Parts, 40 | arg.Model, 41 | ) 42 | var i Message 43 | err := row.Scan( 44 | &i.ID, 45 | &i.SessionID, 46 | &i.Role, 47 | &i.Parts, 48 | &i.Model, 49 | &i.CreatedAt, 50 | &i.UpdatedAt, 51 | &i.FinishedAt, 52 | ) 53 | return i, err 54 | } 55 | 56 | const deleteMessage = `-- name: DeleteMessage :exec 57 | DELETE FROM messages 58 | WHERE id = ? 59 | ` 60 | 61 | func (q *Queries) DeleteMessage(ctx context.Context, id string) error { 62 | _, err := q.exec(ctx, q.deleteMessageStmt, deleteMessage, id) 63 | return err 64 | } 65 | 66 | const deleteSessionMessages = `-- name: DeleteSessionMessages :exec 67 | DELETE FROM messages 68 | WHERE session_id = ? 69 | ` 70 | 71 | func (q *Queries) DeleteSessionMessages(ctx context.Context, sessionID string) error { 72 | _, err := q.exec(ctx, q.deleteSessionMessagesStmt, deleteSessionMessages, sessionID) 73 | return err 74 | } 75 | 76 | const getMessage = `-- name: GetMessage :one 77 | SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at 78 | FROM messages 79 | WHERE id = ? LIMIT 1 80 | ` 81 | 82 | func (q *Queries) GetMessage(ctx context.Context, id string) (Message, error) { 83 | row := q.queryRow(ctx, q.getMessageStmt, getMessage, id) 84 | var i Message 85 | err := row.Scan( 86 | &i.ID, 87 | &i.SessionID, 88 | &i.Role, 89 | &i.Parts, 90 | &i.Model, 91 | &i.CreatedAt, 92 | &i.UpdatedAt, 93 | &i.FinishedAt, 94 | ) 95 | return i, err 96 | } 97 | 98 | const listMessagesBySession = `-- name: ListMessagesBySession :many 99 | SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at 100 | FROM messages 101 | WHERE session_id = ? 102 | ORDER BY created_at ASC 103 | ` 104 | 105 | func (q *Queries) ListMessagesBySession(ctx context.Context, sessionID string) ([]Message, error) { 106 | rows, err := q.query(ctx, q.listMessagesBySessionStmt, listMessagesBySession, sessionID) 107 | if err != nil { 108 | return nil, err 109 | } 110 | defer rows.Close() 111 | items := []Message{} 112 | for rows.Next() { 113 | var i Message 114 | if err := rows.Scan( 115 | &i.ID, 116 | &i.SessionID, 117 | &i.Role, 118 | &i.Parts, 119 | &i.Model, 120 | &i.CreatedAt, 121 | &i.UpdatedAt, 122 | &i.FinishedAt, 123 | ); err != nil { 124 | return nil, err 125 | } 126 | items = append(items, i) 127 | } 128 | if err := rows.Close(); err != nil { 129 | return nil, err 130 | } 131 | if err := rows.Err(); err != nil { 132 | return nil, err 133 | } 134 | return items, nil 135 | } 136 | 137 | const listMessagesBySessionAfter = `-- name: ListMessagesBySessionAfter :many 138 | SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at 139 | FROM messages 140 | WHERE session_id = ? AND created_at > ? 141 | ORDER BY created_at ASC 142 | ` 143 | 144 | type ListMessagesBySessionAfterParams struct { 145 | SessionID string `json:"session_id"` 146 | CreatedAt string `json:"created_at"` 147 | } 148 | 149 | func (q *Queries) ListMessagesBySessionAfter(ctx context.Context, arg ListMessagesBySessionAfterParams) ([]Message, error) { 150 | rows, err := q.query(ctx, q.listMessagesBySessionAfterStmt, listMessagesBySessionAfter, arg.SessionID, arg.CreatedAt) 151 | if err != nil { 152 | return nil, err 153 | } 154 | defer rows.Close() 155 | items := []Message{} 156 | for rows.Next() { 157 | var i Message 158 | if err := rows.Scan( 159 | &i.ID, 160 | &i.SessionID, 161 | &i.Role, 162 | &i.Parts, 163 | &i.Model, 164 | &i.CreatedAt, 165 | &i.UpdatedAt, 166 | &i.FinishedAt, 167 | ); err != nil { 168 | return nil, err 169 | } 170 | items = append(items, i) 171 | } 172 | if err := rows.Close(); err != nil { 173 | return nil, err 174 | } 175 | if err := rows.Err(); err != nil { 176 | return nil, err 177 | } 178 | return items, nil 179 | } 180 | 181 | const updateMessage = `-- name: UpdateMessage :exec 182 | UPDATE messages 183 | SET 184 | parts = ?, 185 | finished_at = ?, 186 | updated_at = strftime('%Y-%m-%dT%H:%M:%f000Z', 'now') 187 | WHERE id = ? 188 | ` 189 | 190 | type UpdateMessageParams struct { 191 | Parts string `json:"parts"` 192 | FinishedAt sql.NullString `json:"finished_at"` 193 | ID string `json:"id"` 194 | } 195 | 196 | func (q *Queries) UpdateMessage(ctx context.Context, arg UpdateMessageParams) error { 197 | _, err := q.exec(ctx, q.updateMessageStmt, updateMessage, arg.Parts, arg.FinishedAt, arg.ID) 198 | return err 199 | } 200 | -------------------------------------------------------------------------------- /internal/db/migrations/20250513000000_initial.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | -- Sessions 4 | CREATE TABLE IF NOT EXISTS sessions ( 5 | id TEXT PRIMARY KEY, 6 | parent_session_id TEXT, 7 | title TEXT NOT NULL, 8 | message_count INTEGER NOT NULL DEFAULT 0 CHECK (message_count >= 0), 9 | prompt_tokens INTEGER NOT NULL DEFAULT 0 CHECK (prompt_tokens >= 0), 10 | completion_tokens INTEGER NOT NULL DEFAULT 0 CHECK (completion_tokens >= 0), 11 | cost REAL NOT NULL DEFAULT 0.0 CHECK (cost >= 0.0), 12 | summary TEXT, 13 | summarized_at TEXT, 14 | updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')), 15 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')) 16 | ); 17 | 18 | CREATE TRIGGER IF NOT EXISTS update_sessions_updated_at 19 | AFTER UPDATE ON sessions 20 | BEGIN 21 | UPDATE sessions SET updated_at = strftime('%Y-%m-%dT%H:%M:%f000Z', 'now') 22 | WHERE id = new.id; 23 | END; 24 | 25 | -- Files 26 | CREATE TABLE IF NOT EXISTS files ( 27 | id TEXT PRIMARY KEY, 28 | session_id TEXT NOT NULL, 29 | path TEXT NOT NULL, 30 | content TEXT NOT NULL, 31 | version TEXT NOT NULL, 32 | is_new INTEGER DEFAULT 0, 33 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')), 34 | updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')), 35 | FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE, 36 | UNIQUE(path, session_id, version) 37 | ); 38 | 39 | CREATE INDEX IF NOT EXISTS idx_files_session_id ON files (session_id); 40 | CREATE INDEX IF NOT EXISTS idx_files_path ON files (path); 41 | 42 | CREATE TRIGGER IF NOT EXISTS update_files_updated_at 43 | AFTER UPDATE ON files 44 | BEGIN 45 | UPDATE files SET updated_at = strftime('%Y-%m-%dT%H:%M:%f000Z', 'now') 46 | WHERE id = new.id; 47 | END; 48 | 49 | -- Messages 50 | CREATE TABLE IF NOT EXISTS messages ( 51 | id TEXT PRIMARY KEY, 52 | session_id TEXT NOT NULL, 53 | role TEXT NOT NULL, 54 | parts TEXT NOT NULL default '[]', 55 | model TEXT, 56 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')), 57 | updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')), 58 | finished_at TEXT, 59 | FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE 60 | ); 61 | 62 | CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages (session_id); 63 | 64 | CREATE TRIGGER IF NOT EXISTS update_messages_updated_at 65 | AFTER UPDATE ON messages 66 | BEGIN 67 | UPDATE messages SET updated_at = strftime('%Y-%m-%dT%H:%M:%f000Z', 'now') 68 | WHERE id = new.id; 69 | END; 70 | 71 | CREATE TRIGGER IF NOT EXISTS update_session_message_count_on_insert 72 | AFTER INSERT ON messages 73 | BEGIN 74 | UPDATE sessions SET 75 | message_count = message_count + 1 76 | WHERE id = new.session_id; 77 | END; 78 | 79 | CREATE TRIGGER IF NOT EXISTS update_session_message_count_on_delete 80 | AFTER DELETE ON messages 81 | BEGIN 82 | UPDATE sessions SET 83 | message_count = message_count - 1 84 | WHERE id = old.session_id; 85 | END; 86 | 87 | -- Logs 88 | CREATE TABLE IF NOT EXISTS logs ( 89 | id TEXT PRIMARY KEY, 90 | session_id TEXT REFERENCES sessions(id) ON DELETE CASCADE, 91 | timestamp TEXT NOT NULL, 92 | level TEXT NOT NULL, 93 | message TEXT NOT NULL, 94 | attributes TEXT, 95 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')), 96 | updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')) 97 | ); 98 | 99 | CREATE INDEX logs_session_id_idx ON logs(session_id); 100 | CREATE INDEX logs_timestamp_idx ON logs(timestamp); 101 | 102 | CREATE TRIGGER IF NOT EXISTS update_logs_updated_at 103 | AFTER UPDATE ON logs 104 | BEGIN 105 | UPDATE logs SET updated_at = strftime('%Y-%m-%dT%H:%M:%f000Z', 'now') 106 | WHERE id = new.id; 107 | END; 108 | 109 | -- +goose StatementEnd 110 | 111 | -- +goose Down 112 | -- +goose StatementBegin 113 | DROP TRIGGER IF EXISTS update_sessions_updated_at; 114 | DROP TRIGGER IF EXISTS update_messages_updated_at; 115 | DROP TRIGGER IF EXISTS update_files_updated_at; 116 | DROP TRIGGER IF EXISTS update_logs_updated_at; 117 | 118 | DROP TRIGGER IF EXISTS update_session_message_count_on_delete; 119 | DROP TRIGGER IF EXISTS update_session_message_count_on_insert; 120 | 121 | DROP TABLE IF EXISTS logs; 122 | DROP TABLE IF EXISTS messages; 123 | DROP TABLE IF EXISTS files; 124 | DROP TABLE IF EXISTS sessions; 125 | -- +goose StatementEnd 126 | -------------------------------------------------------------------------------- /internal/db/models.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.29.0 4 | 5 | package db 6 | 7 | import ( 8 | "database/sql" 9 | ) 10 | 11 | type File struct { 12 | ID string `json:"id"` 13 | SessionID string `json:"session_id"` 14 | Path string `json:"path"` 15 | Content string `json:"content"` 16 | Version string `json:"version"` 17 | IsNew sql.NullInt64 `json:"is_new"` 18 | CreatedAt string `json:"created_at"` 19 | UpdatedAt string `json:"updated_at"` 20 | } 21 | 22 | type Log struct { 23 | ID string `json:"id"` 24 | SessionID sql.NullString `json:"session_id"` 25 | Timestamp string `json:"timestamp"` 26 | Level string `json:"level"` 27 | Message string `json:"message"` 28 | Attributes sql.NullString `json:"attributes"` 29 | CreatedAt string `json:"created_at"` 30 | UpdatedAt string `json:"updated_at"` 31 | } 32 | 33 | type Message struct { 34 | ID string `json:"id"` 35 | SessionID string `json:"session_id"` 36 | Role string `json:"role"` 37 | Parts string `json:"parts"` 38 | Model sql.NullString `json:"model"` 39 | CreatedAt string `json:"created_at"` 40 | UpdatedAt string `json:"updated_at"` 41 | FinishedAt sql.NullString `json:"finished_at"` 42 | } 43 | 44 | type Session struct { 45 | ID string `json:"id"` 46 | ParentSessionID sql.NullString `json:"parent_session_id"` 47 | Title string `json:"title"` 48 | MessageCount int64 `json:"message_count"` 49 | PromptTokens int64 `json:"prompt_tokens"` 50 | CompletionTokens int64 `json:"completion_tokens"` 51 | Cost float64 `json:"cost"` 52 | Summary sql.NullString `json:"summary"` 53 | SummarizedAt sql.NullString `json:"summarized_at"` 54 | UpdatedAt string `json:"updated_at"` 55 | CreatedAt string `json:"created_at"` 56 | } 57 | -------------------------------------------------------------------------------- /internal/db/querier.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.29.0 4 | 5 | package db 6 | 7 | import ( 8 | "context" 9 | "database/sql" 10 | ) 11 | 12 | type Querier interface { 13 | CreateFile(ctx context.Context, arg CreateFileParams) (File, error) 14 | CreateLog(ctx context.Context, arg CreateLogParams) (Log, error) 15 | CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error) 16 | CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) 17 | DeleteFile(ctx context.Context, id string) error 18 | DeleteMessage(ctx context.Context, id string) error 19 | DeleteSession(ctx context.Context, id string) error 20 | DeleteSessionFiles(ctx context.Context, sessionID string) error 21 | DeleteSessionMessages(ctx context.Context, sessionID string) error 22 | GetFile(ctx context.Context, id string) (File, error) 23 | GetFileByPathAndSession(ctx context.Context, arg GetFileByPathAndSessionParams) (File, error) 24 | GetMessage(ctx context.Context, id string) (Message, error) 25 | GetSessionByID(ctx context.Context, id string) (Session, error) 26 | ListAllLogs(ctx context.Context, limit int64) ([]Log, error) 27 | ListFilesByPath(ctx context.Context, path string) ([]File, error) 28 | ListFilesBySession(ctx context.Context, sessionID string) ([]File, error) 29 | ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error) 30 | ListLogsBySession(ctx context.Context, sessionID sql.NullString) ([]Log, error) 31 | ListMessagesBySession(ctx context.Context, sessionID string) ([]Message, error) 32 | ListMessagesBySessionAfter(ctx context.Context, arg ListMessagesBySessionAfterParams) ([]Message, error) 33 | ListNewFiles(ctx context.Context) ([]File, error) 34 | ListSessions(ctx context.Context) ([]Session, error) 35 | UpdateFile(ctx context.Context, arg UpdateFileParams) (File, error) 36 | UpdateMessage(ctx context.Context, arg UpdateMessageParams) error 37 | UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error) 38 | } 39 | 40 | var _ Querier = (*Queries)(nil) 41 | -------------------------------------------------------------------------------- /internal/db/sql/files.sql: -------------------------------------------------------------------------------- 1 | -- name: GetFile :one 2 | SELECT * 3 | FROM files 4 | WHERE id = ? LIMIT 1; 5 | 6 | -- name: GetFileByPathAndSession :one 7 | SELECT * 8 | FROM files 9 | WHERE path = ? AND session_id = ? 10 | ORDER BY created_at DESC 11 | LIMIT 1; 12 | 13 | -- name: ListFilesBySession :many 14 | SELECT * 15 | FROM files 16 | WHERE session_id = ? 17 | ORDER BY created_at ASC; 18 | 19 | -- name: ListFilesByPath :many 20 | SELECT * 21 | FROM files 22 | WHERE path = ? 23 | ORDER BY created_at DESC; 24 | 25 | -- name: CreateFile :one 26 | INSERT INTO files ( 27 | id, 28 | session_id, 29 | path, 30 | content, 31 | version 32 | ) VALUES ( 33 | ?, ?, ?, ?, ? 34 | ) 35 | RETURNING *; 36 | 37 | -- name: UpdateFile :one 38 | UPDATE files 39 | SET 40 | content = ?, 41 | version = ?, 42 | updated_at = strftime('%Y-%m-%dT%H:%M:%f000Z', 'now') 43 | WHERE id = ? 44 | RETURNING *; 45 | 46 | -- name: DeleteFile :exec 47 | DELETE FROM files 48 | WHERE id = ?; 49 | 50 | -- name: DeleteSessionFiles :exec 51 | DELETE FROM files 52 | WHERE session_id = ?; 53 | 54 | -- name: ListLatestSessionFiles :many 55 | SELECT f.* 56 | FROM files f 57 | INNER JOIN ( 58 | SELECT path, MAX(created_at) as max_created_at 59 | FROM files 60 | GROUP BY path 61 | ) latest ON f.path = latest.path AND f.created_at = latest.max_created_at 62 | WHERE f.session_id = ? 63 | ORDER BY f.path; 64 | 65 | -- name: ListNewFiles :many 66 | SELECT * 67 | FROM files 68 | WHERE is_new = 1 69 | ORDER BY created_at DESC; 70 | -------------------------------------------------------------------------------- /internal/db/sql/logs.sql: -------------------------------------------------------------------------------- 1 | -- name: CreateLog :one 2 | INSERT INTO logs ( 3 | id, 4 | session_id, 5 | timestamp, 6 | level, 7 | message, 8 | attributes 9 | ) VALUES ( 10 | ?, 11 | ?, 12 | ?, 13 | ?, 14 | ?, 15 | ? 16 | ) RETURNING *; 17 | 18 | -- name: ListLogsBySession :many 19 | SELECT * FROM logs 20 | WHERE session_id = ? 21 | ORDER BY timestamp DESC; 22 | 23 | -- name: ListAllLogs :many 24 | SELECT * FROM logs 25 | ORDER BY timestamp DESC 26 | LIMIT ?; 27 | -------------------------------------------------------------------------------- /internal/db/sql/messages.sql: -------------------------------------------------------------------------------- 1 | -- name: GetMessage :one 2 | SELECT * 3 | FROM messages 4 | WHERE id = ? LIMIT 1; 5 | 6 | -- name: ListMessagesBySession :many 7 | SELECT * 8 | FROM messages 9 | WHERE session_id = ? 10 | ORDER BY created_at ASC; 11 | 12 | -- name: ListMessagesBySessionAfter :many 13 | SELECT * 14 | FROM messages 15 | WHERE session_id = ? AND created_at > ? 16 | ORDER BY created_at ASC; 17 | 18 | -- name: CreateMessage :one 19 | INSERT INTO messages ( 20 | id, 21 | session_id, 22 | role, 23 | parts, 24 | model 25 | ) VALUES ( 26 | ?, ?, ?, ?, ? 27 | ) 28 | RETURNING *; 29 | 30 | -- name: UpdateMessage :exec 31 | UPDATE messages 32 | SET 33 | parts = ?, 34 | finished_at = ?, 35 | updated_at = strftime('%Y-%m-%dT%H:%M:%f000Z', 'now') 36 | WHERE id = ?; 37 | 38 | 39 | -- name: DeleteMessage :exec 40 | DELETE FROM messages 41 | WHERE id = ?; 42 | 43 | -- name: DeleteSessionMessages :exec 44 | DELETE FROM messages 45 | WHERE session_id = ?; 46 | -------------------------------------------------------------------------------- /internal/db/sql/sessions.sql: -------------------------------------------------------------------------------- 1 | -- name: CreateSession :one 2 | INSERT INTO sessions ( 3 | id, 4 | parent_session_id, 5 | title, 6 | message_count, 7 | prompt_tokens, 8 | completion_tokens, 9 | cost, 10 | summary, 11 | summarized_at 12 | ) VALUES ( 13 | ?, 14 | ?, 15 | ?, 16 | ?, 17 | ?, 18 | ?, 19 | ?, 20 | ?, 21 | ? 22 | ) RETURNING *; 23 | 24 | -- name: GetSessionByID :one 25 | SELECT * 26 | FROM sessions 27 | WHERE id = ? LIMIT 1; 28 | 29 | -- name: ListSessions :many 30 | SELECT * 31 | FROM sessions 32 | WHERE parent_session_id is NULL 33 | ORDER BY created_at DESC; 34 | 35 | -- name: UpdateSession :one 36 | UPDATE sessions 37 | SET 38 | title = ?, 39 | prompt_tokens = ?, 40 | completion_tokens = ?, 41 | cost = ?, 42 | summary = ?, 43 | summarized_at = ? 44 | WHERE id = ? 45 | RETURNING *; 46 | 47 | 48 | -- name: DeleteSession :exec 49 | DELETE FROM sessions 50 | WHERE id = ?; 51 | -------------------------------------------------------------------------------- /internal/diff/diff_test.go: -------------------------------------------------------------------------------- 1 | package diff 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/charmbracelet/lipgloss" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | // TestApplyHighlighting tests the applyHighlighting function with various ANSI sequences 12 | func TestApplyHighlighting(t *testing.T) { 13 | t.Parallel() 14 | 15 | // Mock theme colors for testing 16 | mockHighlightBg := lipgloss.AdaptiveColor{ 17 | Dark: "#FF0000", // Red background for highlighting 18 | Light: "#FF0000", 19 | } 20 | 21 | // Test cases 22 | tests := []struct { 23 | name string 24 | content string 25 | segments []Segment 26 | segmentType LineType 27 | expectContains string 28 | }{ 29 | { 30 | name: "Simple text with no ANSI", 31 | content: "This is a test", 32 | segments: []Segment{{Start: 0, End: 4, Type: LineAdded}}, 33 | segmentType: LineAdded, 34 | // Should contain full reset sequence after highlighting 35 | expectContains: "\x1b[0m", 36 | }, 37 | { 38 | name: "Text with existing ANSI foreground", 39 | content: "This \x1b[32mis\x1b[0m a test", // "is" in green 40 | segments: []Segment{{Start: 5, End: 7, Type: LineAdded}}, 41 | segmentType: LineAdded, 42 | // Should contain full reset sequence after highlighting 43 | expectContains: "\x1b[0m", 44 | }, 45 | { 46 | name: "Text with existing ANSI background", 47 | content: "This \x1b[42mis\x1b[0m a test", // "is" with green background 48 | segments: []Segment{{Start: 5, End: 7, Type: LineAdded}}, 49 | segmentType: LineAdded, 50 | // Should contain full reset sequence after highlighting 51 | expectContains: "\x1b[0m", 52 | }, 53 | { 54 | name: "Text with complex ANSI styling", 55 | content: "This \x1b[1;32;45mis\x1b[0m a test", // "is" bold green on magenta 56 | segments: []Segment{{Start: 5, End: 7, Type: LineAdded}}, 57 | segmentType: LineAdded, 58 | // Should contain full reset sequence after highlighting 59 | expectContains: "\x1b[0m", 60 | }, 61 | } 62 | 63 | for _, tc := range tests { 64 | tc := tc // Capture range variable for parallel testing 65 | t.Run(tc.name, func(t *testing.T) { 66 | t.Parallel() 67 | result := applyHighlighting(tc.content, tc.segments, tc.segmentType, mockHighlightBg) 68 | 69 | // Verify the result contains the expected sequence 70 | assert.Contains(t, result, tc.expectContains, 71 | "Result should contain full reset sequence") 72 | 73 | // Print the result for manual inspection if needed 74 | if t.Failed() { 75 | fmt.Printf("Original: %q\nResult: %q\n", tc.content, result) 76 | } 77 | }) 78 | } 79 | } 80 | 81 | // TestApplyHighlightingWithMultipleSegments tests highlighting multiple segments 82 | func TestApplyHighlightingWithMultipleSegments(t *testing.T) { 83 | t.Parallel() 84 | 85 | // Mock theme colors for testing 86 | mockHighlightBg := lipgloss.AdaptiveColor{ 87 | Dark: "#FF0000", // Red background for highlighting 88 | Light: "#FF0000", 89 | } 90 | 91 | content := "This is a test with multiple segments to highlight" 92 | segments := []Segment{ 93 | {Start: 0, End: 4, Type: LineAdded}, // "This" 94 | {Start: 8, End: 9, Type: LineAdded}, // "a" 95 | {Start: 15, End: 23, Type: LineAdded}, // "multiple" 96 | } 97 | 98 | result := applyHighlighting(content, segments, LineAdded, mockHighlightBg) 99 | 100 | // Verify the result contains the full reset sequence 101 | assert.Contains(t, result, "\x1b[0m", 102 | "Result should contain full reset sequence") 103 | } -------------------------------------------------------------------------------- /internal/fileutil/fileutil.go: -------------------------------------------------------------------------------- 1 | package fileutil 2 | 3 | import ( 4 | "fmt" 5 | "io/fs" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | "sort" 10 | "strings" 11 | "time" 12 | 13 | "github.com/bmatcuk/doublestar/v4" 14 | "github.com/sst/opencode/internal/status" 15 | ) 16 | 17 | var ( 18 | rgPath string 19 | fzfPath string 20 | ) 21 | 22 | func Init() { 23 | var err error 24 | rgPath, err = exec.LookPath("rg") 25 | if err != nil { 26 | status.Warn("Ripgrep (rg) not found in $PATH. Some features might be limited or slower.") 27 | rgPath = "" 28 | } 29 | fzfPath, err = exec.LookPath("fzf") 30 | if err != nil { 31 | status.Warn("FZF not found in $PATH. Some features might be limited or slower.") 32 | fzfPath = "" 33 | } 34 | } 35 | 36 | func GetRgCmd(globPattern string) *exec.Cmd { 37 | if rgPath == "" { 38 | return nil 39 | } 40 | rgArgs := []string{ 41 | "--files", 42 | "-L", 43 | "--null", 44 | } 45 | if globPattern != "" { 46 | if !filepath.IsAbs(globPattern) && !strings.HasPrefix(globPattern, "/") { 47 | globPattern = "/" + globPattern 48 | } 49 | rgArgs = append(rgArgs, "--glob", globPattern) 50 | } 51 | cmd := exec.Command(rgPath, rgArgs...) 52 | cmd.Dir = "." 53 | return cmd 54 | } 55 | 56 | func GetFzfCmd(query string) *exec.Cmd { 57 | if fzfPath == "" { 58 | return nil 59 | } 60 | fzfArgs := []string{ 61 | "--filter", 62 | query, 63 | "--read0", 64 | "--print0", 65 | } 66 | cmd := exec.Command(fzfPath, fzfArgs...) 67 | cmd.Dir = "." 68 | return cmd 69 | } 70 | 71 | type FileInfo struct { 72 | Path string 73 | ModTime time.Time 74 | } 75 | 76 | func SkipHidden(path string) bool { 77 | // Check for hidden files (starting with a dot) 78 | base := filepath.Base(path) 79 | if base != "." && strings.HasPrefix(base, ".") { 80 | return true 81 | } 82 | 83 | commonIgnoredDirs := map[string]bool{ 84 | ".opencode": true, 85 | "node_modules": true, 86 | "vendor": true, 87 | "dist": true, 88 | "build": true, 89 | "target": true, 90 | ".git": true, 91 | ".idea": true, 92 | ".vscode": true, 93 | "__pycache__": true, 94 | "bin": true, 95 | "obj": true, 96 | "out": true, 97 | "coverage": true, 98 | "tmp": true, 99 | "temp": true, 100 | "logs": true, 101 | "generated": true, 102 | "bower_components": true, 103 | "jspm_packages": true, 104 | } 105 | 106 | parts := strings.Split(path, string(os.PathSeparator)) 107 | for _, part := range parts { 108 | if commonIgnoredDirs[part] { 109 | return true 110 | } 111 | } 112 | return false 113 | } 114 | 115 | func GlobWithDoublestar(pattern, searchPath string, limit int) ([]string, bool, error) { 116 | fsys := os.DirFS(searchPath) 117 | relPattern := strings.TrimPrefix(pattern, "/") 118 | var matches []FileInfo 119 | 120 | err := doublestar.GlobWalk(fsys, relPattern, func(path string, d fs.DirEntry) error { 121 | if d.IsDir() { 122 | return nil 123 | } 124 | if SkipHidden(path) { 125 | return nil 126 | } 127 | info, err := d.Info() 128 | if err != nil { 129 | return nil 130 | } 131 | absPath := path 132 | if !strings.HasPrefix(absPath, searchPath) && searchPath != "." { 133 | absPath = filepath.Join(searchPath, absPath) 134 | } else if !strings.HasPrefix(absPath, "/") && searchPath == "." { 135 | absPath = filepath.Join(searchPath, absPath) // Ensure relative paths are joined correctly 136 | } 137 | 138 | matches = append(matches, FileInfo{Path: absPath, ModTime: info.ModTime()}) 139 | if limit > 0 && len(matches) >= limit*2 { 140 | return fs.SkipAll 141 | } 142 | return nil 143 | }) 144 | if err != nil { 145 | return nil, false, fmt.Errorf("glob walk error: %w", err) 146 | } 147 | 148 | sort.Slice(matches, func(i, j int) bool { 149 | return matches[i].ModTime.After(matches[j].ModTime) 150 | }) 151 | 152 | truncated := false 153 | if limit > 0 && len(matches) > limit { 154 | matches = matches[:limit] 155 | truncated = true 156 | } 157 | 158 | results := make([]string, len(matches)) 159 | for i, m := range matches { 160 | results[i] = m.Path 161 | } 162 | return results, truncated, nil 163 | } 164 | -------------------------------------------------------------------------------- /internal/format/format.go: -------------------------------------------------------------------------------- 1 | package format 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | // OutputFormat represents the format for non-interactive mode output 9 | type OutputFormat string 10 | 11 | const ( 12 | // TextFormat is plain text output (default) 13 | TextFormat OutputFormat = "text" 14 | 15 | // JSONFormat is output wrapped in a JSON object 16 | JSONFormat OutputFormat = "json" 17 | ) 18 | 19 | // IsValid checks if the output format is valid 20 | func (f OutputFormat) IsValid() bool { 21 | return f == TextFormat || f == JSONFormat 22 | } 23 | 24 | // String returns the string representation of the output format 25 | func (f OutputFormat) String() string { 26 | return string(f) 27 | } 28 | 29 | // FormatOutput formats the given content according to the specified format 30 | func FormatOutput(content string, format OutputFormat) (string, error) { 31 | switch format { 32 | case TextFormat: 33 | return content, nil 34 | case JSONFormat: 35 | jsonData := map[string]string{ 36 | "response": content, 37 | } 38 | jsonBytes, err := json.MarshalIndent(jsonData, "", " ") 39 | if err != nil { 40 | return "", fmt.Errorf("failed to marshal JSON: %w", err) 41 | } 42 | return string(jsonBytes), nil 43 | default: 44 | return "", fmt.Errorf("unsupported output format: %s", format) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /internal/format/format_test.go: -------------------------------------------------------------------------------- 1 | package format 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestOutputFormat_IsValid(t *testing.T) { 8 | t.Parallel() 9 | 10 | tests := []struct { 11 | name string 12 | format OutputFormat 13 | want bool 14 | }{ 15 | { 16 | name: "text format", 17 | format: TextFormat, 18 | want: true, 19 | }, 20 | { 21 | name: "json format", 22 | format: JSONFormat, 23 | want: true, 24 | }, 25 | { 26 | name: "invalid format", 27 | format: "invalid", 28 | want: false, 29 | }, 30 | } 31 | 32 | for _, tt := range tests { 33 | tt := tt 34 | t.Run(tt.name, func(t *testing.T) { 35 | t.Parallel() 36 | if got := tt.format.IsValid(); got != tt.want { 37 | t.Errorf("OutputFormat.IsValid() = %v, want %v", got, tt.want) 38 | } 39 | }) 40 | } 41 | } 42 | 43 | func TestFormatOutput(t *testing.T) { 44 | t.Parallel() 45 | 46 | tests := []struct { 47 | name string 48 | content string 49 | format OutputFormat 50 | want string 51 | wantErr bool 52 | }{ 53 | { 54 | name: "text format", 55 | content: "test content", 56 | format: TextFormat, 57 | want: "test content", 58 | wantErr: false, 59 | }, 60 | { 61 | name: "json format", 62 | content: "test content", 63 | format: JSONFormat, 64 | want: "{\n \"response\": \"test content\"\n}", 65 | wantErr: false, 66 | }, 67 | { 68 | name: "invalid format", 69 | content: "test content", 70 | format: "invalid", 71 | want: "", 72 | wantErr: true, 73 | }, 74 | } 75 | 76 | for _, tt := range tests { 77 | tt := tt 78 | t.Run(tt.name, func(t *testing.T) { 79 | t.Parallel() 80 | got, err := FormatOutput(tt.content, tt.format) 81 | if (err != nil) != tt.wantErr { 82 | t.Errorf("FormatOutput() error = %v, wantErr %v", err, tt.wantErr) 83 | return 84 | } 85 | if got != tt.want { 86 | t.Errorf("FormatOutput() = %v, want %v", got, tt.want) 87 | } 88 | }) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /internal/llm/agent/agent-tool.go: -------------------------------------------------------------------------------- 1 | package agent 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | 8 | "github.com/sst/opencode/internal/config" 9 | "github.com/sst/opencode/internal/llm/tools" 10 | "github.com/sst/opencode/internal/lsp" 11 | "github.com/sst/opencode/internal/message" 12 | "github.com/sst/opencode/internal/session" 13 | ) 14 | 15 | type agentTool struct { 16 | sessions session.Service 17 | messages message.Service 18 | lspClients map[string]*lsp.Client 19 | } 20 | 21 | const ( 22 | AgentToolName = "agent" 23 | ) 24 | 25 | type AgentParams struct { 26 | Prompt string `json:"prompt"` 27 | } 28 | 29 | func (b *agentTool) Info() tools.ToolInfo { 30 | return tools.ToolInfo{ 31 | Name: AgentToolName, 32 | Description: "Launch a new agent that has access to the following tools: GlobTool, GrepTool, LS, View. When you are searching for a keyword or file and are not confident that you will find the right match on the first try, use the Agent tool to perform the search for you. For example:\n\n- If you are searching for a keyword like \"config\" or \"logger\", or for questions like \"which file does X?\", the Agent tool is strongly recommended\n- If you want to read a specific file path, use the View or GlobTool tool instead of the Agent tool, to find the match more quickly\n- If you are searching for a specific class definition like \"class Foo\", use the GlobTool tool instead, to find the match more quickly\n\nUsage notes:\n1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses\n2. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.\n3. Each agent invocation is stateless. You will not be able to send additional messages to the agent, nor will the agent be able to communicate with you outside of its final report. Therefore, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you.\n4. The agent's outputs should generally be trusted\n5. IMPORTANT: The agent can not use Bash, Replace, Edit, so can not modify files. If you want to use these tools, use them directly instead of going through the agent.", 33 | Parameters: map[string]any{ 34 | "prompt": map[string]any{ 35 | "type": "string", 36 | "description": "The task for the agent to perform", 37 | }, 38 | }, 39 | Required: []string{"prompt"}, 40 | } 41 | } 42 | 43 | func (b *agentTool) Run(ctx context.Context, call tools.ToolCall) (tools.ToolResponse, error) { 44 | var params AgentParams 45 | if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil { 46 | return tools.NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil 47 | } 48 | if params.Prompt == "" { 49 | return tools.NewTextErrorResponse("prompt is required"), nil 50 | } 51 | 52 | sessionID, messageID := tools.GetContextValues(ctx) 53 | if sessionID == "" || messageID == "" { 54 | return tools.ToolResponse{}, fmt.Errorf("session_id and message_id are required") 55 | } 56 | 57 | agent, err := NewAgent(config.AgentTask, b.sessions, b.messages, TaskAgentTools(b.lspClients)) 58 | if err != nil { 59 | return tools.ToolResponse{}, fmt.Errorf("error creating agent: %s", err) 60 | } 61 | 62 | session, err := b.sessions.CreateTaskSession(ctx, call.ID, sessionID, "New Agent Session") 63 | if err != nil { 64 | return tools.ToolResponse{}, fmt.Errorf("error creating session: %s", err) 65 | } 66 | 67 | done, err := agent.Run(ctx, session.ID, params.Prompt) 68 | if err != nil { 69 | return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", err) 70 | } 71 | result := <-done 72 | if result.Err() != nil { 73 | return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", result.Err()) 74 | } 75 | 76 | response := result.Response() 77 | if response.Role != message.Assistant { 78 | return tools.NewTextErrorResponse("no response"), nil 79 | } 80 | 81 | updatedSession, err := b.sessions.Get(ctx, session.ID) 82 | if err != nil { 83 | return tools.ToolResponse{}, fmt.Errorf("error getting session: %s", err) 84 | } 85 | parentSession, err := b.sessions.Get(ctx, sessionID) 86 | if err != nil { 87 | return tools.ToolResponse{}, fmt.Errorf("error getting parent session: %s", err) 88 | } 89 | 90 | parentSession.Cost += updatedSession.Cost 91 | 92 | _, err = b.sessions.Update(ctx, parentSession) 93 | if err != nil { 94 | return tools.ToolResponse{}, fmt.Errorf("error saving parent session: %s", err) 95 | } 96 | return tools.NewTextResponse(response.Content().String()), nil 97 | } 98 | 99 | func NewAgentTool( 100 | Sessions session.Service, 101 | Messages message.Service, 102 | LspClients map[string]*lsp.Client, 103 | ) tools.BaseTool { 104 | return &agentTool{ 105 | sessions: Sessions, 106 | messages: Messages, 107 | lspClients: LspClients, 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /internal/llm/agent/tools.go: -------------------------------------------------------------------------------- 1 | package agent 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/sst/opencode/internal/history" 7 | "github.com/sst/opencode/internal/llm/tools" 8 | "github.com/sst/opencode/internal/lsp" 9 | "github.com/sst/opencode/internal/message" 10 | "github.com/sst/opencode/internal/permission" 11 | "github.com/sst/opencode/internal/session" 12 | ) 13 | 14 | func PrimaryAgentTools( 15 | permissions permission.Service, 16 | sessions session.Service, 17 | messages message.Service, 18 | history history.Service, 19 | lspClients map[string]*lsp.Client, 20 | ) []tools.BaseTool { 21 | ctx := context.Background() 22 | mcpTools := GetMcpTools(ctx, permissions) 23 | 24 | // Create the list of tools 25 | toolsList := []tools.BaseTool{ 26 | tools.NewBashTool(permissions), 27 | tools.NewEditTool(lspClients, permissions, history), 28 | tools.NewFetchTool(permissions), 29 | tools.NewGlobTool(), 30 | tools.NewGrepTool(), 31 | tools.NewLsTool(), 32 | tools.NewViewTool(lspClients), 33 | tools.NewPatchTool(lspClients, permissions, history), 34 | tools.NewWriteTool(lspClients, permissions, history), 35 | tools.NewDiagnosticsTool(lspClients), 36 | tools.NewDefinitionTool(lspClients), 37 | tools.NewReferencesTool(lspClients), 38 | tools.NewDocSymbolsTool(lspClients), 39 | tools.NewWorkspaceSymbolsTool(lspClients), 40 | tools.NewCodeActionTool(lspClients), 41 | NewAgentTool(sessions, messages, lspClients), 42 | } 43 | 44 | // Create a map of tools for the batch tool 45 | toolsMap := make(map[string]tools.BaseTool) 46 | for _, tool := range toolsList { 47 | toolsMap[tool.Info().Name] = tool 48 | } 49 | 50 | // Add the batch tool with access to all other tools 51 | toolsList = append(toolsList, tools.NewBatchTool(toolsMap)) 52 | 53 | return append(toolsList, mcpTools...) 54 | } 55 | 56 | func TaskAgentTools(lspClients map[string]*lsp.Client) []tools.BaseTool { 57 | // Create the list of tools 58 | toolsList := []tools.BaseTool{ 59 | tools.NewGlobTool(), 60 | tools.NewGrepTool(), 61 | tools.NewLsTool(), 62 | tools.NewViewTool(lspClients), 63 | tools.NewDefinitionTool(lspClients), 64 | tools.NewReferencesTool(lspClients), 65 | tools.NewDocSymbolsTool(lspClients), 66 | tools.NewWorkspaceSymbolsTool(lspClients), 67 | } 68 | 69 | // Create a map of tools for the batch tool 70 | toolsMap := make(map[string]tools.BaseTool) 71 | for _, tool := range toolsList { 72 | toolsMap[tool.Info().Name] = tool 73 | } 74 | 75 | // Add the batch tool with access to all other tools 76 | toolsList = append(toolsList, tools.NewBatchTool(toolsMap)) 77 | 78 | return toolsList 79 | } 80 | -------------------------------------------------------------------------------- /internal/llm/models/anthropic.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | const ( 4 | ProviderAnthropic ModelProvider = "anthropic" 5 | 6 | // Models 7 | Claude35Sonnet ModelID = "claude-3.5-sonnet" 8 | Claude3Haiku ModelID = "claude-3-haiku" 9 | Claude37Sonnet ModelID = "claude-3.7-sonnet" 10 | Claude35Haiku ModelID = "claude-3.5-haiku" 11 | Claude3Opus ModelID = "claude-3-opus" 12 | Claude4Sonnet ModelID = "claude-4-sonnet" 13 | Claude4Opus ModelID = "claude-4-opus" 14 | ) 15 | 16 | // https://docs.anthropic.com/en/docs/about-claude/models/all-models 17 | var AnthropicModels = map[ModelID]Model{ 18 | Claude35Sonnet: { 19 | ID: Claude35Sonnet, 20 | Name: "Claude 3.5 Sonnet", 21 | Provider: ProviderAnthropic, 22 | APIModel: "claude-3-5-sonnet-latest", 23 | CostPer1MIn: 3.0, 24 | CostPer1MInCached: 3.75, 25 | CostPer1MOutCached: 0.30, 26 | CostPer1MOut: 15.0, 27 | ContextWindow: 200000, 28 | DefaultMaxTokens: 5000, 29 | SupportsAttachments: true, 30 | }, 31 | Claude3Haiku: { 32 | ID: Claude3Haiku, 33 | Name: "Claude 3 Haiku", 34 | Provider: ProviderAnthropic, 35 | APIModel: "claude-3-haiku-20240307", // doesn't support "-latest" 36 | CostPer1MIn: 0.25, 37 | CostPer1MInCached: 0.30, 38 | CostPer1MOutCached: 0.03, 39 | CostPer1MOut: 1.25, 40 | ContextWindow: 200000, 41 | DefaultMaxTokens: 4096, 42 | SupportsAttachments: true, 43 | }, 44 | Claude37Sonnet: { 45 | ID: Claude37Sonnet, 46 | Name: "Claude 3.7 Sonnet", 47 | Provider: ProviderAnthropic, 48 | APIModel: "claude-3-7-sonnet-latest", 49 | CostPer1MIn: 3.0, 50 | CostPer1MInCached: 3.75, 51 | CostPer1MOutCached: 0.30, 52 | CostPer1MOut: 15.0, 53 | ContextWindow: 200000, 54 | DefaultMaxTokens: 50000, 55 | CanReason: true, 56 | SupportsAttachments: true, 57 | }, 58 | Claude4Sonnet: { 59 | ID: Claude4Sonnet, 60 | Name: "Claude 4 Sonnet", 61 | Provider: ProviderAnthropic, 62 | APIModel: "claude-sonnet-4-20250514", 63 | CostPer1MIn: 3.0, 64 | CostPer1MInCached: 3.75, 65 | CostPer1MOutCached: 0.30, 66 | CostPer1MOut: 15.0, 67 | ContextWindow: 200000, 68 | DefaultMaxTokens: 50000, 69 | CanReason: true, 70 | SupportsAttachments: true, 71 | }, 72 | Claude4Opus: { 73 | ID: Claude4Opus, 74 | Name: "Claude 4 Opus", 75 | Provider: ProviderAnthropic, 76 | APIModel: "claude-opus-4-20250514", 77 | CostPer1MIn: 15.0, 78 | CostPer1MInCached: 18.75, 79 | CostPer1MOutCached: 1.50, 80 | CostPer1MOut: 75.0, 81 | ContextWindow: 200000, 82 | DefaultMaxTokens: 32000, 83 | CanReason: true, 84 | SupportsAttachments: true, 85 | }, 86 | Claude35Haiku: { 87 | ID: Claude35Haiku, 88 | Name: "Claude 3.5 Haiku", 89 | Provider: ProviderAnthropic, 90 | APIModel: "claude-3-5-haiku-latest", 91 | CostPer1MIn: 0.80, 92 | CostPer1MInCached: 1.0, 93 | CostPer1MOutCached: 0.08, 94 | CostPer1MOut: 4.0, 95 | ContextWindow: 200000, 96 | DefaultMaxTokens: 4096, 97 | SupportsAttachments: true, 98 | }, 99 | Claude3Opus: { 100 | ID: Claude3Opus, 101 | Name: "Claude 3 Opus", 102 | Provider: ProviderAnthropic, 103 | APIModel: "claude-3-opus-latest", 104 | CostPer1MIn: 15.0, 105 | CostPer1MInCached: 18.75, 106 | CostPer1MOutCached: 1.50, 107 | CostPer1MOut: 75.0, 108 | ContextWindow: 200000, 109 | DefaultMaxTokens: 4096, 110 | SupportsAttachments: true, 111 | }, 112 | } 113 | -------------------------------------------------------------------------------- /internal/llm/models/bedrock.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | const ( 4 | ProviderBedrock ModelProvider = "bedrock" 5 | 6 | // Models 7 | BedrockClaude37Sonnet ModelID = "bedrock.claude-3.7-sonnet" 8 | BedrockClaude4Sonnet ModelID = "bedrock.claude-4.0-sonnet" 9 | BedrockClaude4Opus ModelID = "bedrock.claude-4.0-opus" 10 | ) 11 | 12 | var BedrockModels = map[ModelID]Model{ 13 | BedrockClaude37Sonnet: { 14 | ID: BedrockClaude37Sonnet, 15 | Name: "Bedrock: Claude 3.7 Sonnet", 16 | Provider: ProviderBedrock, 17 | APIModel: "anthropic.claude-3-7-sonnet-20250219-v1:0", 18 | CostPer1MIn: 3.0, 19 | CostPer1MInCached: 3.75, 20 | CostPer1MOutCached: 0.30, 21 | CostPer1MOut: 15.0, 22 | ContextWindow: 200_000, 23 | DefaultMaxTokens: 50_000, 24 | CanReason: true, 25 | SupportsAttachments: true, 26 | }, 27 | BedrockClaude4Sonnet: { 28 | ID: BedrockClaude4Sonnet, 29 | Name: "Bedrock: Claude 4 Sonnet", 30 | Provider: ProviderBedrock, 31 | APIModel: "anthropic.claude-sonnet-4-20250514-v1:0", 32 | CostPer1MIn: 3.0, 33 | CostPer1MInCached: 3.75, 34 | CostPer1MOutCached: 0.30, 35 | CostPer1MOut: 15.0, 36 | ContextWindow: 200_000, 37 | DefaultMaxTokens: 50_000, 38 | CanReason: true, 39 | SupportsAttachments: true, 40 | }, 41 | BedrockClaude4Opus: { 42 | ID: BedrockClaude4Opus, 43 | Name: "Bedrock: Claude 4 Opus", 44 | Provider: ProviderBedrock, 45 | APIModel: "anthropic.claude-opus-4-20250514-v1:0", 46 | CostPer1MIn: 15.0, 47 | CostPer1MInCached: 18.75, 48 | CostPer1MOutCached: 1.50, 49 | CostPer1MOut: 75.0, 50 | ContextWindow: 200_000, 51 | DefaultMaxTokens: 50_000, 52 | CanReason: true, 53 | SupportsAttachments: true, 54 | }, 55 | } 56 | -------------------------------------------------------------------------------- /internal/llm/models/gemini.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | const ( 4 | ProviderGemini ModelProvider = "gemini" 5 | 6 | // Models 7 | Gemini25Flash ModelID = "gemini-2.5-flash" 8 | Gemini25 ModelID = "gemini-2.5" 9 | Gemini20Flash ModelID = "gemini-2.0-flash" 10 | Gemini20FlashLite ModelID = "gemini-2.0-flash-lite" 11 | ) 12 | 13 | var GeminiModels = map[ModelID]Model{ 14 | Gemini25Flash: { 15 | ID: Gemini25Flash, 16 | Name: "Gemini 2.5 Flash", 17 | Provider: ProviderGemini, 18 | APIModel: "gemini-2.5-flash-preview-04-17", 19 | CostPer1MIn: 0.15, 20 | CostPer1MInCached: 0, 21 | CostPer1MOutCached: 0, 22 | CostPer1MOut: 0.60, 23 | ContextWindow: 1000000, 24 | DefaultMaxTokens: 50000, 25 | SupportsAttachments: true, 26 | }, 27 | Gemini25: { 28 | ID: Gemini25, 29 | Name: "Gemini 2.5 Pro", 30 | Provider: ProviderGemini, 31 | APIModel: "gemini-2.5-pro-preview-03-25", 32 | CostPer1MIn: 1.25, 33 | CostPer1MInCached: 0, 34 | CostPer1MOutCached: 0, 35 | CostPer1MOut: 10, 36 | ContextWindow: 1000000, 37 | DefaultMaxTokens: 50000, 38 | SupportsAttachments: true, 39 | }, 40 | 41 | Gemini20Flash: { 42 | ID: Gemini20Flash, 43 | Name: "Gemini 2.0 Flash", 44 | Provider: ProviderGemini, 45 | APIModel: "gemini-2.0-flash", 46 | CostPer1MIn: 0.10, 47 | CostPer1MInCached: 0, 48 | CostPer1MOutCached: 0, 49 | CostPer1MOut: 0.40, 50 | ContextWindow: 1000000, 51 | DefaultMaxTokens: 6000, 52 | SupportsAttachments: true, 53 | }, 54 | Gemini20FlashLite: { 55 | ID: Gemini20FlashLite, 56 | Name: "Gemini 2.0 Flash Lite", 57 | Provider: ProviderGemini, 58 | APIModel: "gemini-2.0-flash-lite", 59 | CostPer1MIn: 0.05, 60 | CostPer1MInCached: 0, 61 | CostPer1MOutCached: 0, 62 | CostPer1MOut: 0.30, 63 | ContextWindow: 1000000, 64 | DefaultMaxTokens: 6000, 65 | SupportsAttachments: true, 66 | }, 67 | } 68 | -------------------------------------------------------------------------------- /internal/llm/models/groq.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | const ( 4 | ProviderGROQ ModelProvider = "groq" 5 | 6 | // GROQ 7 | QWENQwq ModelID = "qwen-qwq" 8 | 9 | // GROQ preview models 10 | Llama4Scout ModelID = "meta-llama/llama-4-scout-17b-16e-instruct" 11 | Llama4Maverick ModelID = "meta-llama/llama-4-maverick-17b-128e-instruct" 12 | Llama3_3_70BVersatile ModelID = "llama-3.3-70b-versatile" 13 | DeepseekR1DistillLlama70b ModelID = "deepseek-r1-distill-llama-70b" 14 | ) 15 | 16 | var GroqModels = map[ModelID]Model{ 17 | // 18 | // GROQ 19 | QWENQwq: { 20 | ID: QWENQwq, 21 | Name: "Qwen Qwq", 22 | Provider: ProviderGROQ, 23 | APIModel: "qwen-qwq-32b", 24 | CostPer1MIn: 0.29, 25 | CostPer1MInCached: 0.275, 26 | CostPer1MOutCached: 0.0, 27 | CostPer1MOut: 0.39, 28 | ContextWindow: 128_000, 29 | DefaultMaxTokens: 50000, 30 | // for some reason, the groq api doesn't like the reasoningEffort parameter 31 | CanReason: false, 32 | SupportsAttachments: false, 33 | }, 34 | 35 | Llama4Scout: { 36 | ID: Llama4Scout, 37 | Name: "Llama4Scout", 38 | Provider: ProviderGROQ, 39 | APIModel: "meta-llama/llama-4-scout-17b-16e-instruct", 40 | CostPer1MIn: 0.11, 41 | CostPer1MInCached: 0, 42 | CostPer1MOutCached: 0, 43 | CostPer1MOut: 0.34, 44 | DefaultMaxTokens: 8192, 45 | ContextWindow: 128_000, // 10M when? 46 | SupportsAttachments: true, 47 | }, 48 | 49 | Llama4Maverick: { 50 | ID: Llama4Maverick, 51 | Name: "Llama4Maverick", 52 | Provider: ProviderGROQ, 53 | APIModel: "meta-llama/llama-4-maverick-17b-128e-instruct", 54 | CostPer1MIn: 0.20, 55 | CostPer1MInCached: 0, 56 | CostPer1MOutCached: 0, 57 | CostPer1MOut: 0.20, 58 | DefaultMaxTokens: 8192, 59 | ContextWindow: 128_000, 60 | SupportsAttachments: true, 61 | }, 62 | 63 | Llama3_3_70BVersatile: { 64 | ID: Llama3_3_70BVersatile, 65 | Name: "Llama3_3_70BVersatile", 66 | Provider: ProviderGROQ, 67 | APIModel: "llama-3.3-70b-versatile", 68 | CostPer1MIn: 0.59, 69 | CostPer1MInCached: 0, 70 | CostPer1MOutCached: 0, 71 | CostPer1MOut: 0.79, 72 | ContextWindow: 128_000, 73 | SupportsAttachments: false, 74 | }, 75 | 76 | DeepseekR1DistillLlama70b: { 77 | ID: DeepseekR1DistillLlama70b, 78 | Name: "DeepseekR1DistillLlama70b", 79 | Provider: ProviderGROQ, 80 | APIModel: "deepseek-r1-distill-llama-70b", 81 | CostPer1MIn: 0.75, 82 | CostPer1MInCached: 0, 83 | CostPer1MOutCached: 0, 84 | CostPer1MOut: 0.99, 85 | ContextWindow: 128_000, 86 | CanReason: true, 87 | SupportsAttachments: false, 88 | }, 89 | } 90 | -------------------------------------------------------------------------------- /internal/llm/models/models.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "maps" 4 | 5 | type ( 6 | ModelID string 7 | ModelProvider string 8 | ) 9 | 10 | type Model struct { 11 | ID ModelID `json:"id"` 12 | Name string `json:"name"` 13 | Provider ModelProvider `json:"provider"` 14 | APIModel string `json:"api_model"` 15 | CostPer1MIn float64 `json:"cost_per_1m_in"` 16 | CostPer1MOut float64 `json:"cost_per_1m_out"` 17 | CostPer1MInCached float64 `json:"cost_per_1m_in_cached"` 18 | CostPer1MOutCached float64 `json:"cost_per_1m_out_cached"` 19 | ContextWindow int64 `json:"context_window"` 20 | DefaultMaxTokens int64 `json:"default_max_tokens"` 21 | CanReason bool `json:"can_reason"` 22 | SupportsAttachments bool `json:"supports_attachments"` 23 | } 24 | 25 | const ( 26 | // ForTests 27 | ProviderMock ModelProvider = "__mock" 28 | ) 29 | 30 | // Providers in order of popularity 31 | var ProviderPopularity = map[ModelProvider]int{ 32 | ProviderAnthropic: 1, 33 | ProviderOpenAI: 2, 34 | ProviderGemini: 3, 35 | ProviderGROQ: 4, 36 | ProviderOpenRouter: 5, 37 | ProviderBedrock: 6, 38 | ProviderAzure: 7, 39 | ProviderVertexAI: 8, 40 | } 41 | 42 | var SupportedModels = map[ModelID]Model{} 43 | 44 | func init() { 45 | maps.Copy(SupportedModels, AnthropicModels) 46 | maps.Copy(SupportedModels, BedrockModels) 47 | maps.Copy(SupportedModels, OpenAIModels) 48 | maps.Copy(SupportedModels, GeminiModels) 49 | maps.Copy(SupportedModels, GroqModels) 50 | maps.Copy(SupportedModels, AzureModels) 51 | maps.Copy(SupportedModels, OpenRouterModels) 52 | maps.Copy(SupportedModels, XAIModels) 53 | maps.Copy(SupportedModels, VertexAIGeminiModels) 54 | } 55 | -------------------------------------------------------------------------------- /internal/llm/models/vertexai.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | const ( 4 | ProviderVertexAI ModelProvider = "vertexai" 5 | 6 | // Models 7 | VertexAIGemini25Flash ModelID = "vertexai.gemini-2.5-flash" 8 | VertexAIGemini25 ModelID = "vertexai.gemini-2.5" 9 | ) 10 | 11 | var VertexAIGeminiModels = map[ModelID]Model{ 12 | VertexAIGemini25Flash: { 13 | ID: VertexAIGemini25Flash, 14 | Name: "VertexAI: Gemini 2.5 Flash", 15 | Provider: ProviderVertexAI, 16 | APIModel: "gemini-2.5-flash-preview-04-17", 17 | CostPer1MIn: GeminiModels[Gemini25Flash].CostPer1MIn, 18 | CostPer1MInCached: GeminiModels[Gemini25Flash].CostPer1MInCached, 19 | CostPer1MOut: GeminiModels[Gemini25Flash].CostPer1MOut, 20 | CostPer1MOutCached: GeminiModels[Gemini25Flash].CostPer1MOutCached, 21 | ContextWindow: GeminiModels[Gemini25Flash].ContextWindow, 22 | DefaultMaxTokens: GeminiModels[Gemini25Flash].DefaultMaxTokens, 23 | SupportsAttachments: true, 24 | }, 25 | VertexAIGemini25: { 26 | ID: VertexAIGemini25, 27 | Name: "VertexAI: Gemini 2.5 Pro", 28 | Provider: ProviderVertexAI, 29 | APIModel: "gemini-2.5-pro-preview-03-25", 30 | CostPer1MIn: GeminiModels[Gemini25].CostPer1MIn, 31 | CostPer1MInCached: GeminiModels[Gemini25].CostPer1MInCached, 32 | CostPer1MOut: GeminiModels[Gemini25].CostPer1MOut, 33 | CostPer1MOutCached: GeminiModels[Gemini25].CostPer1MOutCached, 34 | ContextWindow: GeminiModels[Gemini25].ContextWindow, 35 | DefaultMaxTokens: GeminiModels[Gemini25].DefaultMaxTokens, 36 | SupportsAttachments: true, 37 | }, 38 | } 39 | -------------------------------------------------------------------------------- /internal/llm/models/xai.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | const ( 4 | ProviderXAI ModelProvider = "xai" 5 | 6 | XAIGrok3Beta ModelID = "grok-3-beta" 7 | XAIGrok3MiniBeta ModelID = "grok-3-mini-beta" 8 | XAIGrok3FastBeta ModelID = "grok-3-fast-beta" 9 | XAiGrok3MiniFastBeta ModelID = "grok-3-mini-fast-beta" 10 | ) 11 | 12 | var XAIModels = map[ModelID]Model{ 13 | XAIGrok3Beta: { 14 | ID: XAIGrok3Beta, 15 | Name: "Grok3 Beta", 16 | Provider: ProviderXAI, 17 | APIModel: "grok-3-beta", 18 | CostPer1MIn: 3.0, 19 | CostPer1MInCached: 0, 20 | CostPer1MOut: 15, 21 | CostPer1MOutCached: 0, 22 | ContextWindow: 131_072, 23 | DefaultMaxTokens: 20_000, 24 | }, 25 | XAIGrok3MiniBeta: { 26 | ID: XAIGrok3MiniBeta, 27 | Name: "Grok3 Mini Beta", 28 | Provider: ProviderXAI, 29 | APIModel: "grok-3-mini-beta", 30 | CostPer1MIn: 0.3, 31 | CostPer1MInCached: 0, 32 | CostPer1MOut: 0.5, 33 | CostPer1MOutCached: 0, 34 | ContextWindow: 131_072, 35 | DefaultMaxTokens: 20_000, 36 | }, 37 | XAIGrok3FastBeta: { 38 | ID: XAIGrok3FastBeta, 39 | Name: "Grok3 Fast Beta", 40 | Provider: ProviderXAI, 41 | APIModel: "grok-3-fast-beta", 42 | CostPer1MIn: 5, 43 | CostPer1MInCached: 0, 44 | CostPer1MOut: 25, 45 | CostPer1MOutCached: 0, 46 | ContextWindow: 131_072, 47 | DefaultMaxTokens: 20_000, 48 | }, 49 | XAiGrok3MiniFastBeta: { 50 | ID: XAiGrok3MiniFastBeta, 51 | Name: "Grok3 Mini Fast Beta", 52 | Provider: ProviderXAI, 53 | APIModel: "grok-3-mini-fast-beta", 54 | CostPer1MIn: 0.6, 55 | CostPer1MInCached: 0, 56 | CostPer1MOut: 4.0, 57 | CostPer1MOutCached: 0, 58 | ContextWindow: 131_072, 59 | DefaultMaxTokens: 20_000, 60 | }, 61 | } 62 | -------------------------------------------------------------------------------- /internal/llm/prompt/prompt.go: -------------------------------------------------------------------------------- 1 | package prompt 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | "sync" 9 | 10 | "github.com/sst/opencode/internal/config" 11 | "github.com/sst/opencode/internal/llm/models" 12 | "log/slog" 13 | ) 14 | 15 | func GetAgentPrompt(agentName config.AgentName, provider models.ModelProvider) string { 16 | basePrompt := "" 17 | switch agentName { 18 | case config.AgentPrimary: 19 | basePrompt = PrimaryPrompt(provider) 20 | case config.AgentTitle: 21 | basePrompt = TitlePrompt(provider) 22 | case config.AgentTask: 23 | basePrompt = TaskPrompt(provider) 24 | default: 25 | basePrompt = "You are a helpful assistant" 26 | } 27 | 28 | if agentName == config.AgentPrimary || agentName == config.AgentTask { 29 | // Add context from project-specific instruction files if they exist 30 | contextContent := getContextFromPaths() 31 | slog.Debug("Context content", "Context", contextContent) 32 | if contextContent != "" { 33 | return fmt.Sprintf("%s\n\n# Project-Specific Context\n Make sure to follow the instructions in the context below\n%s", basePrompt, contextContent) 34 | } 35 | } 36 | return basePrompt 37 | } 38 | 39 | var ( 40 | onceContext sync.Once 41 | contextContent string 42 | ) 43 | 44 | func getContextFromPaths() string { 45 | onceContext.Do(func() { 46 | var ( 47 | cfg = config.Get() 48 | workDir = cfg.WorkingDir 49 | contextPaths = cfg.ContextPaths 50 | ) 51 | 52 | contextContent = processContextPaths(workDir, contextPaths) 53 | }) 54 | 55 | return contextContent 56 | } 57 | 58 | func processContextPaths(workDir string, paths []string) string { 59 | var ( 60 | wg sync.WaitGroup 61 | resultCh = make(chan string) 62 | ) 63 | 64 | // Track processed files to avoid duplicates 65 | processedFiles := make(map[string]bool) 66 | var processedMutex sync.Mutex 67 | 68 | for _, path := range paths { 69 | wg.Add(1) 70 | go func(p string) { 71 | defer wg.Done() 72 | 73 | if strings.HasSuffix(p, "/") { 74 | filepath.WalkDir(filepath.Join(workDir, p), func(path string, d os.DirEntry, err error) error { 75 | if err != nil { 76 | return err 77 | } 78 | if !d.IsDir() { 79 | // Check if we've already processed this file (case-insensitive) 80 | processedMutex.Lock() 81 | lowerPath := strings.ToLower(path) 82 | if !processedFiles[lowerPath] { 83 | processedFiles[lowerPath] = true 84 | processedMutex.Unlock() 85 | 86 | if result := processFile(path); result != "" { 87 | resultCh <- result 88 | } 89 | } else { 90 | processedMutex.Unlock() 91 | } 92 | } 93 | return nil 94 | }) 95 | } else { 96 | fullPath := filepath.Join(workDir, p) 97 | 98 | // Check if we've already processed this file (case-insensitive) 99 | processedMutex.Lock() 100 | lowerPath := strings.ToLower(fullPath) 101 | if !processedFiles[lowerPath] { 102 | processedFiles[lowerPath] = true 103 | processedMutex.Unlock() 104 | 105 | result := processFile(fullPath) 106 | if result != "" { 107 | resultCh <- result 108 | } 109 | } else { 110 | processedMutex.Unlock() 111 | } 112 | } 113 | }(path) 114 | } 115 | 116 | go func() { 117 | wg.Wait() 118 | close(resultCh) 119 | }() 120 | 121 | results := make([]string, 0) 122 | for result := range resultCh { 123 | results = append(results, result) 124 | } 125 | 126 | return strings.Join(results, "\n") 127 | } 128 | 129 | func processFile(filePath string) string { 130 | content, err := os.ReadFile(filePath) 131 | if err != nil { 132 | return "" 133 | } 134 | return "# From:" + filePath + "\n" + string(content) 135 | } 136 | -------------------------------------------------------------------------------- /internal/llm/prompt/prompt_test.go: -------------------------------------------------------------------------------- 1 | package prompt 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | 10 | "github.com/sst/opencode/internal/config" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestGetContextFromPaths(t *testing.T) { 16 | t.Parallel() 17 | 18 | lvl := new(slog.LevelVar) 19 | lvl.Set(slog.LevelDebug) 20 | 21 | tmpDir := t.TempDir() 22 | _, err := config.Load(tmpDir, false, lvl) 23 | if err != nil { 24 | t.Fatalf("Failed to load config: %v", err) 25 | } 26 | cfg := config.Get() 27 | cfg.WorkingDir = tmpDir 28 | cfg.ContextPaths = []string{ 29 | "file.txt", 30 | "directory/", 31 | } 32 | testFiles := []string{ 33 | "file.txt", 34 | "directory/file_a.txt", 35 | "directory/file_b.txt", 36 | "directory/file_c.txt", 37 | } 38 | 39 | createTestFiles(t, tmpDir, testFiles) 40 | 41 | context := getContextFromPaths() 42 | 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) 43 | assert.Equal(t, expectedContext, context) 44 | } 45 | 46 | func createTestFiles(t *testing.T, tmpDir string, testFiles []string) { 47 | t.Helper() 48 | for _, path := range testFiles { 49 | fullPath := filepath.Join(tmpDir, path) 50 | if path[len(path)-1] == '/' { 51 | err := os.MkdirAll(fullPath, 0755) 52 | require.NoError(t, err) 53 | } else { 54 | dir := filepath.Dir(fullPath) 55 | err := os.MkdirAll(dir, 0755) 56 | require.NoError(t, err) 57 | err = os.WriteFile(fullPath, []byte(path+": test content"), 0644) 58 | require.NoError(t, err) 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /internal/llm/prompt/task.go: -------------------------------------------------------------------------------- 1 | package prompt 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/sst/opencode/internal/llm/models" 7 | ) 8 | 9 | func TaskPrompt(_ models.ModelProvider) string { 10 | agentPrompt := `You are an agent for OpenCode. Given the user's prompt, you should use the tools available to you to answer the user's question. 11 | Notes: 12 | 1. IMPORTANT: You should be concise, direct, and to the point, since your responses will be displayed on a command line interface. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is .", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...". 13 | 2. When relevant, share file names and code snippets relevant to the query 14 | 3. Any file paths you return in your final response MUST be absolute. DO NOT use relative paths.` 15 | 16 | return fmt.Sprintf("%s\n%s\n", agentPrompt, getEnvironmentInfo()) 17 | } 18 | -------------------------------------------------------------------------------- /internal/llm/prompt/title.go: -------------------------------------------------------------------------------- 1 | package prompt 2 | 3 | import "github.com/sst/opencode/internal/llm/models" 4 | 5 | func TitlePrompt(_ models.ModelProvider) string { 6 | return `you will generate a short title based on the first message a user begins a conversation with 7 | - ensure it is not more than 50 characters long 8 | - the title should be a summary of the user's message 9 | - it should be one line long 10 | - do not use quotes or colons 11 | - the entire text you return will be used as the title 12 | - never return anything that is more than one sentence (one line) long` 13 | } 14 | -------------------------------------------------------------------------------- /internal/llm/provider/azure.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/Azure/azure-sdk-for-go/sdk/azidentity" 7 | "github.com/openai/openai-go" 8 | "github.com/openai/openai-go/azure" 9 | "github.com/openai/openai-go/option" 10 | ) 11 | 12 | type azureClient struct { 13 | *openaiClient 14 | } 15 | 16 | type AzureClient ProviderClient 17 | 18 | func newAzureClient(opts providerClientOptions) AzureClient { 19 | 20 | endpoint := os.Getenv("AZURE_OPENAI_ENDPOINT") // ex: https://foo.openai.azure.com 21 | apiVersion := os.Getenv("AZURE_OPENAI_API_VERSION") // ex: 2025-04-01-preview 22 | 23 | if endpoint == "" || apiVersion == "" { 24 | return &azureClient{openaiClient: newOpenAIClient(opts).(*openaiClient)} 25 | } 26 | 27 | reqOpts := []option.RequestOption{ 28 | azure.WithEndpoint(endpoint, apiVersion), 29 | } 30 | 31 | if opts.apiKey != "" || os.Getenv("AZURE_OPENAI_API_KEY") != "" { 32 | key := opts.apiKey 33 | if key == "" { 34 | key = os.Getenv("AZURE_OPENAI_API_KEY") 35 | } 36 | reqOpts = append(reqOpts, azure.WithAPIKey(key)) 37 | } else if cred, err := azidentity.NewDefaultAzureCredential(nil); err == nil { 38 | reqOpts = append(reqOpts, azure.WithTokenCredential(cred)) 39 | } 40 | 41 | base := &openaiClient{ 42 | providerOptions: opts, 43 | client: openai.NewClient(reqOpts...), 44 | } 45 | 46 | return &azureClient{openaiClient: base} 47 | } 48 | -------------------------------------------------------------------------------- /internal/llm/provider/bedrock.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "strings" 9 | 10 | "github.com/sst/opencode/internal/llm/tools" 11 | "github.com/sst/opencode/internal/message" 12 | ) 13 | 14 | type bedrockOptions struct { 15 | // Bedrock specific options can be added here 16 | } 17 | 18 | type BedrockOption func(*bedrockOptions) 19 | 20 | type bedrockClient struct { 21 | providerOptions providerClientOptions 22 | options bedrockOptions 23 | childProvider ProviderClient 24 | } 25 | 26 | type BedrockClient ProviderClient 27 | 28 | func newBedrockClient(opts providerClientOptions) BedrockClient { 29 | bedrockOpts := bedrockOptions{} 30 | // Apply bedrock specific options if they are added in the future 31 | 32 | // Get AWS region from environment 33 | region := os.Getenv("AWS_REGION") 34 | if region == "" { 35 | region = os.Getenv("AWS_DEFAULT_REGION") 36 | } 37 | 38 | if region == "" { 39 | region = "us-east-1" // default region 40 | } 41 | if len(region) < 2 { 42 | return &bedrockClient{ 43 | providerOptions: opts, 44 | options: bedrockOpts, 45 | childProvider: nil, // Will cause an error when used 46 | } 47 | } 48 | 49 | // Prefix the model name with region 50 | regionPrefix := region[:2] 51 | modelName := opts.model.APIModel 52 | opts.model.APIModel = fmt.Sprintf("%s.%s", regionPrefix, modelName) 53 | 54 | // Determine which provider to use based on the model 55 | if strings.Contains(string(opts.model.APIModel), "anthropic") { 56 | // Create Anthropic client with Bedrock configuration 57 | anthropicOpts := opts 58 | anthropicOpts.anthropicOptions = append(anthropicOpts.anthropicOptions, 59 | WithAnthropicBedrock(true), 60 | WithAnthropicDisableCache(), 61 | ) 62 | return &bedrockClient{ 63 | providerOptions: opts, 64 | options: bedrockOpts, 65 | childProvider: newAnthropicClient(anthropicOpts), 66 | } 67 | } 68 | 69 | // Return client with nil childProvider if model is not supported 70 | // This will cause an error when used 71 | return &bedrockClient{ 72 | providerOptions: opts, 73 | options: bedrockOpts, 74 | childProvider: nil, 75 | } 76 | } 77 | 78 | func (b *bedrockClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) { 79 | if b.childProvider == nil { 80 | return nil, errors.New("unsupported model for bedrock provider") 81 | } 82 | return b.childProvider.send(ctx, messages, tools) 83 | } 84 | 85 | func (b *bedrockClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent { 86 | eventChan := make(chan ProviderEvent) 87 | 88 | if b.childProvider == nil { 89 | go func() { 90 | eventChan <- ProviderEvent{ 91 | Type: EventError, 92 | Error: errors.New("unsupported model for bedrock provider"), 93 | } 94 | close(eventChan) 95 | }() 96 | return eventChan 97 | } 98 | 99 | return b.childProvider.stream(ctx, messages, tools) 100 | } 101 | -------------------------------------------------------------------------------- /internal/llm/provider/openai.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | "github.com/openai/openai-go" 9 | "github.com/openai/openai-go/option" 10 | "github.com/sst/opencode/internal/llm/models" 11 | "github.com/sst/opencode/internal/llm/tools" 12 | "github.com/sst/opencode/internal/message" 13 | ) 14 | 15 | type openaiOptions struct { 16 | baseURL string 17 | disableCache bool 18 | reasoningEffort string 19 | extraHeaders map[string]string 20 | } 21 | 22 | type OpenAIOption func(*openaiOptions) 23 | 24 | type openaiClient struct { 25 | providerOptions providerClientOptions 26 | options openaiOptions 27 | client openai.Client 28 | } 29 | 30 | type OpenAIClient ProviderClient 31 | 32 | func newOpenAIClient(opts providerClientOptions) OpenAIClient { 33 | openaiOpts := openaiOptions{ 34 | reasoningEffort: "medium", 35 | } 36 | for _, o := range opts.openaiOptions { 37 | o(&openaiOpts) 38 | } 39 | 40 | openaiClientOptions := []option.RequestOption{} 41 | if opts.apiKey != "" { 42 | openaiClientOptions = append(openaiClientOptions, option.WithAPIKey(opts.apiKey)) 43 | } 44 | if openaiOpts.baseURL != "" { 45 | openaiClientOptions = append(openaiClientOptions, option.WithBaseURL(openaiOpts.baseURL)) 46 | } 47 | 48 | if openaiOpts.extraHeaders != nil { 49 | for key, value := range openaiOpts.extraHeaders { 50 | openaiClientOptions = append(openaiClientOptions, option.WithHeader(key, value)) 51 | } 52 | } 53 | 54 | client := openai.NewClient(openaiClientOptions...) 55 | return &openaiClient{ 56 | providerOptions: opts, 57 | options: openaiOpts, 58 | client: client, 59 | } 60 | } 61 | 62 | func (o *openaiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) { 63 | if o.providerOptions.model.ID == models.OpenAIModels[models.CodexMini].ID || o.providerOptions.model.ID == models.OpenAIModels[models.O1Pro].ID { 64 | return o.sendResponseMessages(ctx, messages, tools) 65 | } 66 | return o.sendChatcompletionMessage(ctx, messages, tools) 67 | } 68 | 69 | func (o *openaiClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent { 70 | if o.providerOptions.model.ID == models.OpenAIModels[models.CodexMini].ID || o.providerOptions.model.ID == models.OpenAIModels[models.O1Pro].ID { 71 | return o.streamResponseMessages(ctx, messages, tools) 72 | } 73 | return o.streamChatCompletionMessages(ctx, messages, tools) 74 | } 75 | 76 | 77 | func (o *openaiClient) finishReason(reason string) message.FinishReason { 78 | switch reason { 79 | case "stop": 80 | return message.FinishReasonEndTurn 81 | case "length": 82 | return message.FinishReasonMaxTokens 83 | case "tool_calls": 84 | return message.FinishReasonToolUse 85 | default: 86 | return message.FinishReasonUnknown 87 | } 88 | } 89 | 90 | 91 | func (o *openaiClient) shouldRetry(attempts int, err error) (bool, int64, error) { 92 | var apierr *openai.Error 93 | if !errors.As(err, &apierr) { 94 | return false, 0, err 95 | } 96 | 97 | if apierr.StatusCode != 429 && apierr.StatusCode != 500 { 98 | return false, 0, err 99 | } 100 | 101 | if attempts > maxRetries { 102 | return false, 0, fmt.Errorf("maximum retry attempts reached for rate limit: %d retries", maxRetries) 103 | } 104 | 105 | retryMs := 0 106 | retryAfterValues := apierr.Response.Header.Values("Retry-After") 107 | 108 | backoffMs := 2000 * (1 << (attempts - 1)) 109 | jitterMs := int(float64(backoffMs) * 0.2) 110 | retryMs = backoffMs + jitterMs 111 | if len(retryAfterValues) > 0 { 112 | if _, err := fmt.Sscanf(retryAfterValues[0], "%d", &retryMs); err == nil { 113 | retryMs = retryMs * 1000 114 | } 115 | } 116 | return true, int64(retryMs), nil 117 | } 118 | 119 | 120 | func WithOpenAIBaseURL(baseURL string) OpenAIOption { 121 | return func(options *openaiOptions) { 122 | options.baseURL = baseURL 123 | } 124 | } 125 | 126 | func WithOpenAIExtraHeaders(headers map[string]string) OpenAIOption { 127 | return func(options *openaiOptions) { 128 | options.extraHeaders = headers 129 | } 130 | } 131 | 132 | func WithOpenAIDisableCache() OpenAIOption { 133 | return func(options *openaiOptions) { 134 | options.disableCache = true 135 | } 136 | } 137 | 138 | func WithReasoningEffort(effort string) OpenAIOption { 139 | return func(options *openaiOptions) { 140 | defaultReasoningEffort := "medium" 141 | switch effort { 142 | case "low", "medium", "high": 143 | defaultReasoningEffort = effort 144 | default: 145 | slog.Warn("Invalid reasoning effort, using default: medium") 146 | } 147 | options.reasoningEffort = defaultReasoningEffort 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /internal/llm/provider/vertexai.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "os" 7 | 8 | "google.golang.org/genai" 9 | ) 10 | 11 | type VertexAIClient ProviderClient 12 | 13 | func newVertexAIClient(opts providerClientOptions) VertexAIClient { 14 | geminiOpts := geminiOptions{} 15 | for _, o := range opts.geminiOptions { 16 | o(&geminiOpts) 17 | } 18 | 19 | client, err := genai.NewClient(context.Background(), &genai.ClientConfig{ 20 | Project: os.Getenv("VERTEXAI_PROJECT"), 21 | Location: os.Getenv("VERTEXAI_LOCATION"), 22 | Backend: genai.BackendVertexAI, 23 | }) 24 | if err != nil { 25 | slog.Error("Failed to create VertexAI client", "error", err) 26 | return nil 27 | } 28 | 29 | return &geminiClient{ 30 | providerOptions: opts, 31 | options: geminiOpts, 32 | client: client, 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /internal/llm/tools/file.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | ) 7 | 8 | // File record to track when files were read/written 9 | type fileRecord struct { 10 | path string 11 | readTime time.Time 12 | writeTime time.Time 13 | } 14 | 15 | var ( 16 | fileRecords = make(map[string]fileRecord) 17 | fileRecordMutex sync.RWMutex 18 | ) 19 | 20 | func recordFileRead(path string) { 21 | fileRecordMutex.Lock() 22 | defer fileRecordMutex.Unlock() 23 | 24 | record, exists := fileRecords[path] 25 | if !exists { 26 | record = fileRecord{path: path} 27 | } 28 | record.readTime = time.Now() 29 | fileRecords[path] = record 30 | } 31 | 32 | func getLastReadTime(path string) time.Time { 33 | fileRecordMutex.RLock() 34 | defer fileRecordMutex.RUnlock() 35 | 36 | record, exists := fileRecords[path] 37 | if !exists { 38 | return time.Time{} 39 | } 40 | return record.readTime 41 | } 42 | 43 | func recordFileWrite(path string) { 44 | fileRecordMutex.Lock() 45 | defer fileRecordMutex.Unlock() 46 | 47 | record, exists := fileRecords[path] 48 | if !exists { 49 | record = fileRecord{path: path} 50 | } 51 | record.writeTime = time.Now() 52 | fileRecords[path] = record 53 | } 54 | -------------------------------------------------------------------------------- /internal/llm/tools/lsp_workspace_symbols.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/sst/opencode/internal/lsp" 10 | "github.com/sst/opencode/internal/lsp/protocol" 11 | ) 12 | 13 | type WorkspaceSymbolsParams struct { 14 | Query string `json:"query"` 15 | } 16 | 17 | type workspaceSymbolsTool struct { 18 | lspClients map[string]*lsp.Client 19 | } 20 | 21 | const ( 22 | WorkspaceSymbolsToolName = "workspaceSymbols" 23 | workspaceSymbolsDescription = `Find symbols across the workspace matching a query. 24 | WHEN TO USE THIS TOOL: 25 | - Use when you need to find symbols across multiple files 26 | - Helpful for locating classes, functions, or variables in a project 27 | - Great for exploring large codebases 28 | 29 | HOW TO USE: 30 | - Provide a query string to search for symbols 31 | - Results show matching symbols from across the workspace 32 | 33 | FEATURES: 34 | - Searches across all files in the workspace 35 | - Shows symbol types (function, class, variable, etc.) 36 | - Provides location information for each symbol 37 | - Works with partial matches and fuzzy search (depending on LSP server) 38 | 39 | LIMITATIONS: 40 | - Requires a functioning LSP server for the file types 41 | - Results depend on the accuracy of the LSP server 42 | - Query capabilities vary by language server 43 | - May not work for all file types 44 | 45 | TIPS: 46 | - Use specific queries to narrow down results 47 | - Combine with DocSymbols tool for detailed file exploration 48 | - Use with Definition tool to jump to symbol definitions 49 | ` 50 | ) 51 | 52 | func NewWorkspaceSymbolsTool(lspClients map[string]*lsp.Client) BaseTool { 53 | return &workspaceSymbolsTool{ 54 | lspClients, 55 | } 56 | } 57 | 58 | func (b *workspaceSymbolsTool) Info() ToolInfo { 59 | return ToolInfo{ 60 | Name: WorkspaceSymbolsToolName, 61 | Description: workspaceSymbolsDescription, 62 | Parameters: map[string]any{ 63 | "query": map[string]any{ 64 | "type": "string", 65 | "description": "The query string to search for symbols", 66 | }, 67 | }, 68 | Required: []string{"query"}, 69 | } 70 | } 71 | 72 | func (b *workspaceSymbolsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) { 73 | var params WorkspaceSymbolsParams 74 | if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil { 75 | return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil 76 | } 77 | 78 | lsps := b.lspClients 79 | 80 | if len(lsps) == 0 { 81 | return NewTextResponse("\nLSP clients are still initializing. Workspace symbols lookup will be available once they're ready.\n"), nil 82 | } 83 | 84 | output := getWorkspaceSymbols(ctx, params.Query, lsps) 85 | 86 | return NewTextResponse(output), nil 87 | } 88 | 89 | func getWorkspaceSymbols(ctx context.Context, query string, lsps map[string]*lsp.Client) string { 90 | var results []string 91 | 92 | for lspName, client := range lsps { 93 | // Create workspace symbol params 94 | symbolParams := protocol.WorkspaceSymbolParams{ 95 | Query: query, 96 | } 97 | 98 | // Get workspace symbols 99 | symbolResult, err := client.Symbol(ctx, symbolParams) 100 | if err != nil { 101 | results = append(results, fmt.Sprintf("Error from %s: %s", lspName, err)) 102 | continue 103 | } 104 | 105 | // Process the symbol result 106 | symbols := processWorkspaceSymbolResult(symbolResult) 107 | if len(symbols) == 0 { 108 | results = append(results, fmt.Sprintf("No symbols found by %s for query '%s'", lspName, query)) 109 | continue 110 | } 111 | 112 | // Format the symbols 113 | results = append(results, fmt.Sprintf("Symbols found by %s for query '%s':", lspName, query)) 114 | for _, symbol := range symbols { 115 | results = append(results, fmt.Sprintf(" %s (%s) - %s", symbol.Name, symbol.Kind, symbol.Location)) 116 | } 117 | } 118 | 119 | if len(results) == 0 { 120 | return fmt.Sprintf("No symbols found matching query '%s'.", query) 121 | } 122 | 123 | return strings.Join(results, "\n") 124 | } 125 | 126 | func processWorkspaceSymbolResult(result protocol.Or_Result_workspace_symbol) []SymbolInfo { 127 | var symbols []SymbolInfo 128 | 129 | switch v := result.Value.(type) { 130 | case []protocol.SymbolInformation: 131 | for _, si := range v { 132 | symbols = append(symbols, SymbolInfo{ 133 | Name: si.Name, 134 | Kind: symbolKindToString(si.Kind), 135 | Location: formatWorkspaceLocation(si.Location), 136 | Children: nil, 137 | }) 138 | } 139 | case []protocol.WorkspaceSymbol: 140 | for _, ws := range v { 141 | location := "Unknown location" 142 | if ws.Location.Value != nil { 143 | if loc, ok := ws.Location.Value.(protocol.Location); ok { 144 | location = formatWorkspaceLocation(loc) 145 | } 146 | } 147 | symbols = append(symbols, SymbolInfo{ 148 | Name: ws.Name, 149 | Kind: symbolKindToString(ws.Kind), 150 | Location: location, 151 | Children: nil, 152 | }) 153 | } 154 | } 155 | 156 | return symbols 157 | } 158 | 159 | func formatWorkspaceLocation(location protocol.Location) string { 160 | path := strings.TrimPrefix(string(location.URI), "file://") 161 | return fmt.Sprintf("%s:%d:%d", path, location.Range.Start.Line+1, location.Range.Start.Character+1) 162 | } -------------------------------------------------------------------------------- /internal/llm/tools/tools.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | ) 7 | 8 | type ToolInfo struct { 9 | Name string 10 | Description string 11 | Parameters map[string]any 12 | Required []string 13 | } 14 | 15 | type toolResponseType string 16 | 17 | type ( 18 | sessionIDContextKey string 19 | messageIDContextKey string 20 | ) 21 | 22 | const ( 23 | ToolResponseTypeText toolResponseType = "text" 24 | ToolResponseTypeImage toolResponseType = "image" 25 | 26 | SessionIDContextKey sessionIDContextKey = "session_id" 27 | MessageIDContextKey messageIDContextKey = "message_id" 28 | ) 29 | 30 | type ToolResponse struct { 31 | Type toolResponseType `json:"type"` 32 | Content string `json:"content"` 33 | Metadata string `json:"metadata,omitempty"` 34 | IsError bool `json:"is_error"` 35 | } 36 | 37 | func NewTextResponse(content string) ToolResponse { 38 | return ToolResponse{ 39 | Type: ToolResponseTypeText, 40 | Content: content, 41 | } 42 | } 43 | 44 | func WithResponseMetadata(response ToolResponse, metadata any) ToolResponse { 45 | if metadata != nil { 46 | metadataBytes, err := json.Marshal(metadata) 47 | if err != nil { 48 | return response 49 | } 50 | response.Metadata = string(metadataBytes) 51 | } 52 | return response 53 | } 54 | 55 | func NewTextErrorResponse(content string) ToolResponse { 56 | return ToolResponse{ 57 | Type: ToolResponseTypeText, 58 | Content: content, 59 | IsError: true, 60 | } 61 | } 62 | 63 | type ToolCall struct { 64 | ID string `json:"id"` 65 | Name string `json:"name"` 66 | Input string `json:"input"` 67 | } 68 | 69 | type BaseTool interface { 70 | Info() ToolInfo 71 | Run(ctx context.Context, params ToolCall) (ToolResponse, error) 72 | } 73 | 74 | func GetContextValues(ctx context.Context) (string, string) { 75 | sessionID := ctx.Value(SessionIDContextKey) 76 | messageID := ctx.Value(MessageIDContextKey) 77 | if sessionID == nil { 78 | return "", "" 79 | } 80 | if messageID == nil { 81 | return sessionID.(string), "" 82 | } 83 | return sessionID.(string), messageID.(string) 84 | } 85 | -------------------------------------------------------------------------------- /internal/lsp/discovery/integration.go: -------------------------------------------------------------------------------- 1 | package discovery 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/sst/opencode/internal/config" 7 | "log/slog" 8 | ) 9 | 10 | // IntegrateLSPServers discovers languages and LSP servers and integrates them into the application configuration 11 | func IntegrateLSPServers(workingDir string) error { 12 | // Get the current configuration 13 | cfg := config.Get() 14 | if cfg == nil { 15 | return fmt.Errorf("config not loaded") 16 | } 17 | 18 | // Check if this is the first run 19 | shouldInit, err := config.ShouldShowInitDialog() 20 | if err != nil { 21 | return fmt.Errorf("failed to check initialization status: %w", err) 22 | } 23 | 24 | // Always run language detection, but log differently for first run vs. subsequent runs 25 | if shouldInit || len(cfg.LSP) == 0 { 26 | slog.Info("Running initial LSP auto-discovery...") 27 | } else { 28 | slog.Debug("Running LSP auto-discovery to detect new languages...") 29 | } 30 | 31 | // Configure LSP servers 32 | servers, err := ConfigureLSPServers(workingDir) 33 | if err != nil { 34 | return fmt.Errorf("failed to configure LSP servers: %w", err) 35 | } 36 | 37 | // Update the configuration with discovered servers 38 | for langID, serverInfo := range servers { 39 | // Skip languages that already have a configured server 40 | if _, exists := cfg.LSP[langID]; exists { 41 | slog.Debug("LSP server already configured for language", "language", langID) 42 | continue 43 | } 44 | 45 | if serverInfo.Available { 46 | // Only add servers that were found 47 | cfg.LSP[langID] = config.LSPConfig{ 48 | Disabled: false, 49 | Command: serverInfo.Path, 50 | Args: serverInfo.Args, 51 | } 52 | slog.Info("Added LSP server to configuration", 53 | "language", langID, 54 | "command", serverInfo.Command, 55 | "path", serverInfo.Path) 56 | } else { 57 | slog.Warn("LSP server not available", 58 | "language", langID, 59 | "command", serverInfo.Command, 60 | "installCmd", serverInfo.InstallCmd) 61 | } 62 | } 63 | 64 | return nil 65 | } 66 | -------------------------------------------------------------------------------- /internal/lsp/handlers.go: -------------------------------------------------------------------------------- 1 | package lsp 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/sst/opencode/internal/config" 7 | "github.com/sst/opencode/internal/lsp/protocol" 8 | "github.com/sst/opencode/internal/lsp/util" 9 | "log/slog" 10 | ) 11 | 12 | // Requests 13 | 14 | func HandleWorkspaceConfiguration(params json.RawMessage) (any, error) { 15 | return []map[string]any{{}}, nil 16 | } 17 | 18 | func HandleRegisterCapability(params json.RawMessage) (any, error) { 19 | var registerParams protocol.RegistrationParams 20 | if err := json.Unmarshal(params, ®isterParams); err != nil { 21 | slog.Error("Error unmarshaling registration params", "error", err) 22 | return nil, err 23 | } 24 | 25 | for _, reg := range registerParams.Registrations { 26 | switch reg.Method { 27 | case "workspace/didChangeWatchedFiles": 28 | // Parse the registration options 29 | optionsJSON, err := json.Marshal(reg.RegisterOptions) 30 | if err != nil { 31 | slog.Error("Error marshaling registration options", "error", err) 32 | continue 33 | } 34 | 35 | var options protocol.DidChangeWatchedFilesRegistrationOptions 36 | if err := json.Unmarshal(optionsJSON, &options); err != nil { 37 | slog.Error("Error unmarshaling registration options", "error", err) 38 | continue 39 | } 40 | 41 | // Store the file watchers registrations 42 | notifyFileWatchRegistration(reg.ID, options.Watchers) 43 | } 44 | } 45 | 46 | return nil, nil 47 | } 48 | 49 | func HandleApplyEdit(params json.RawMessage) (any, error) { 50 | var edit protocol.ApplyWorkspaceEditParams 51 | if err := json.Unmarshal(params, &edit); err != nil { 52 | return nil, err 53 | } 54 | 55 | err := util.ApplyWorkspaceEdit(edit.Edit) 56 | if err != nil { 57 | slog.Error("Error applying workspace edit", "error", err) 58 | return protocol.ApplyWorkspaceEditResult{Applied: false, FailureReason: err.Error()}, nil 59 | } 60 | 61 | return protocol.ApplyWorkspaceEditResult{Applied: true}, nil 62 | } 63 | 64 | // FileWatchRegistrationHandler is a function that will be called when file watch registrations are received 65 | type FileWatchRegistrationHandler func(id string, watchers []protocol.FileSystemWatcher) 66 | 67 | // fileWatchHandler holds the current handler for file watch registrations 68 | var fileWatchHandler FileWatchRegistrationHandler 69 | 70 | // RegisterFileWatchHandler sets the handler for file watch registrations 71 | func RegisterFileWatchHandler(handler FileWatchRegistrationHandler) { 72 | fileWatchHandler = handler 73 | } 74 | 75 | // notifyFileWatchRegistration notifies the handler about new file watch registrations 76 | func notifyFileWatchRegistration(id string, watchers []protocol.FileSystemWatcher) { 77 | if fileWatchHandler != nil { 78 | fileWatchHandler(id, watchers) 79 | } 80 | } 81 | 82 | // Notifications 83 | 84 | func HandleServerMessage(params json.RawMessage) { 85 | cnf := config.Get() 86 | var msg struct { 87 | Type int `json:"type"` 88 | Message string `json:"message"` 89 | } 90 | if err := json.Unmarshal(params, &msg); err == nil { 91 | if cnf.DebugLSP { 92 | slog.Debug("Server message", "type", msg.Type, "message", msg.Message) 93 | } 94 | } 95 | } 96 | 97 | func HandleDiagnostics(client *Client, params json.RawMessage) { 98 | var diagParams protocol.PublishDiagnosticsParams 99 | if err := json.Unmarshal(params, &diagParams); err != nil { 100 | slog.Error("Error unmarshaling diagnostics params", "error", err) 101 | return 102 | } 103 | 104 | client.diagnosticsMu.Lock() 105 | defer client.diagnosticsMu.Unlock() 106 | 107 | client.diagnostics[diagParams.URI] = diagParams.Diagnostics 108 | } 109 | -------------------------------------------------------------------------------- /internal/lsp/language.go: -------------------------------------------------------------------------------- 1 | package lsp 2 | 3 | import ( 4 | "path/filepath" 5 | "strings" 6 | 7 | "github.com/sst/opencode/internal/lsp/protocol" 8 | ) 9 | 10 | func DetectLanguageID(uri string) protocol.LanguageKind { 11 | ext := strings.ToLower(filepath.Ext(uri)) 12 | switch ext { 13 | case ".abap": 14 | return protocol.LangABAP 15 | case ".bat": 16 | return protocol.LangWindowsBat 17 | case ".bib", ".bibtex": 18 | return protocol.LangBibTeX 19 | case ".clj": 20 | return protocol.LangClojure 21 | case ".coffee": 22 | return protocol.LangCoffeescript 23 | case ".c": 24 | return protocol.LangC 25 | case ".cpp", ".cxx", ".cc", ".c++": 26 | return protocol.LangCPP 27 | case ".cs": 28 | return protocol.LangCSharp 29 | case ".css": 30 | return protocol.LangCSS 31 | case ".d": 32 | return protocol.LangD 33 | case ".pas", ".pascal": 34 | return protocol.LangDelphi 35 | case ".diff", ".patch": 36 | return protocol.LangDiff 37 | case ".dart": 38 | return protocol.LangDart 39 | case ".dockerfile": 40 | return protocol.LangDockerfile 41 | case ".ex", ".exs": 42 | return protocol.LangElixir 43 | case ".erl", ".hrl": 44 | return protocol.LangErlang 45 | case ".fs", ".fsi", ".fsx", ".fsscript": 46 | return protocol.LangFSharp 47 | case ".gitcommit": 48 | return protocol.LangGitCommit 49 | case ".gitrebase": 50 | return protocol.LangGitRebase 51 | case ".go": 52 | return protocol.LangGo 53 | case ".groovy": 54 | return protocol.LangGroovy 55 | case ".hbs", ".handlebars": 56 | return protocol.LangHandlebars 57 | case ".hs": 58 | return protocol.LangHaskell 59 | case ".html", ".htm": 60 | return protocol.LangHTML 61 | case ".ini": 62 | return protocol.LangIni 63 | case ".java": 64 | return protocol.LangJava 65 | case ".js": 66 | return protocol.LangJavaScript 67 | case ".jsx": 68 | return protocol.LangJavaScriptReact 69 | case ".json": 70 | return protocol.LangJSON 71 | case ".tex", ".latex": 72 | return protocol.LangLaTeX 73 | case ".less": 74 | return protocol.LangLess 75 | case ".lua": 76 | return protocol.LangLua 77 | case ".makefile", "makefile": 78 | return protocol.LangMakefile 79 | case ".md", ".markdown": 80 | return protocol.LangMarkdown 81 | case ".m": 82 | return protocol.LangObjectiveC 83 | case ".mm": 84 | return protocol.LangObjectiveCPP 85 | case ".pl": 86 | return protocol.LangPerl 87 | case ".pm": 88 | return protocol.LangPerl6 89 | case ".php": 90 | return protocol.LangPHP 91 | case ".ps1", ".psm1": 92 | return protocol.LangPowershell 93 | case ".pug", ".jade": 94 | return protocol.LangPug 95 | case ".py": 96 | return protocol.LangPython 97 | case ".r": 98 | return protocol.LangR 99 | case ".cshtml", ".razor": 100 | return protocol.LangRazor 101 | case ".rb": 102 | return protocol.LangRuby 103 | case ".rs": 104 | return protocol.LangRust 105 | case ".scss": 106 | return protocol.LangSCSS 107 | case ".sass": 108 | return protocol.LangSASS 109 | case ".scala": 110 | return protocol.LangScala 111 | case ".shader": 112 | return protocol.LangShaderLab 113 | case ".sh", ".bash", ".zsh", ".ksh": 114 | return protocol.LangShellScript 115 | case ".sql": 116 | return protocol.LangSQL 117 | case ".swift": 118 | return protocol.LangSwift 119 | case ".ts": 120 | return protocol.LangTypeScript 121 | case ".tsx": 122 | return protocol.LangTypeScriptReact 123 | case ".xml": 124 | return protocol.LangXML 125 | case ".xsl": 126 | return protocol.LangXSL 127 | case ".yaml", ".yml": 128 | return protocol.LangYAML 129 | default: 130 | return protocol.LanguageKind("") // Unknown language 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /internal/lsp/protocol.go: -------------------------------------------------------------------------------- 1 | package lsp 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | // Message represents a JSON-RPC 2.0 message 8 | type Message struct { 9 | JSONRPC string `json:"jsonrpc"` 10 | ID int32 `json:"id,omitempty"` 11 | Method string `json:"method,omitempty"` 12 | Params json.RawMessage `json:"params,omitempty"` 13 | Result json.RawMessage `json:"result,omitempty"` 14 | Error *ResponseError `json:"error,omitempty"` 15 | } 16 | 17 | // ResponseError represents a JSON-RPC 2.0 error 18 | type ResponseError struct { 19 | Code int `json:"code"` 20 | Message string `json:"message"` 21 | } 22 | 23 | func NewRequest(id int32, method string, params any) (*Message, error) { 24 | paramsJSON, err := json.Marshal(params) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | return &Message{ 30 | JSONRPC: "2.0", 31 | ID: id, 32 | Method: method, 33 | Params: paramsJSON, 34 | }, nil 35 | } 36 | 37 | func NewNotification(method string, params any) (*Message, error) { 38 | paramsJSON, err := json.Marshal(params) 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | return &Message{ 44 | JSONRPC: "2.0", 45 | Method: method, 46 | Params: paramsJSON, 47 | }, nil 48 | } 49 | -------------------------------------------------------------------------------- /internal/lsp/protocol/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2009 The Go Authors. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are 5 | met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above 10 | copyright notice, this list of conditions and the following disclaimer 11 | in the documentation and/or other materials provided with the 12 | distribution. 13 | * Neither the name of Google LLC nor the names of its 14 | contributors may be used to endorse or promote products derived from 15 | this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 21 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 22 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 23 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 24 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /internal/lsp/protocol/interface.go: -------------------------------------------------------------------------------- 1 | package protocol 2 | 3 | import "fmt" 4 | 5 | // TextEditResult is an interface for types that represent workspace symbols 6 | type WorkspaceSymbolResult interface { 7 | GetName() string 8 | GetLocation() Location 9 | isWorkspaceSymbol() // marker method 10 | } 11 | 12 | func (ws *WorkspaceSymbol) GetName() string { return ws.Name } 13 | func (ws *WorkspaceSymbol) GetLocation() Location { 14 | switch v := ws.Location.Value.(type) { 15 | case Location: 16 | return v 17 | case LocationUriOnly: 18 | return Location{URI: v.URI} 19 | } 20 | return Location{} 21 | } 22 | func (ws *WorkspaceSymbol) isWorkspaceSymbol() {} 23 | 24 | func (si *SymbolInformation) GetName() string { return si.Name } 25 | func (si *SymbolInformation) GetLocation() Location { return si.Location } 26 | func (si *SymbolInformation) isWorkspaceSymbol() {} 27 | 28 | // Results converts the Value to a slice of WorkspaceSymbolResult 29 | func (r Or_Result_workspace_symbol) Results() ([]WorkspaceSymbolResult, error) { 30 | if r.Value == nil { 31 | return make([]WorkspaceSymbolResult, 0), nil 32 | } 33 | switch v := r.Value.(type) { 34 | case []WorkspaceSymbol: 35 | results := make([]WorkspaceSymbolResult, len(v)) 36 | for i := range v { 37 | results[i] = &v[i] 38 | } 39 | return results, nil 40 | case []SymbolInformation: 41 | results := make([]WorkspaceSymbolResult, len(v)) 42 | for i := range v { 43 | results[i] = &v[i] 44 | } 45 | return results, nil 46 | default: 47 | return nil, fmt.Errorf("unknown symbol type: %T", r.Value) 48 | } 49 | } 50 | 51 | // TextEditResult is an interface for types that represent document symbols 52 | type DocumentSymbolResult interface { 53 | GetRange() Range 54 | GetName() string 55 | isDocumentSymbol() // marker method 56 | } 57 | 58 | func (ds *DocumentSymbol) GetRange() Range { return ds.Range } 59 | func (ds *DocumentSymbol) GetName() string { return ds.Name } 60 | func (ds *DocumentSymbol) isDocumentSymbol() {} 61 | 62 | func (si *SymbolInformation) GetRange() Range { return si.Location.Range } 63 | 64 | // Note: SymbolInformation already has GetName() implemented above 65 | func (si *SymbolInformation) isDocumentSymbol() {} 66 | 67 | // Results converts the Value to a slice of DocumentSymbolResult 68 | func (r Or_Result_textDocument_documentSymbol) Results() ([]DocumentSymbolResult, error) { 69 | if r.Value == nil { 70 | return make([]DocumentSymbolResult, 0), nil 71 | } 72 | switch v := r.Value.(type) { 73 | case []DocumentSymbol: 74 | results := make([]DocumentSymbolResult, len(v)) 75 | for i := range v { 76 | results[i] = &v[i] 77 | } 78 | return results, nil 79 | case []SymbolInformation: 80 | results := make([]DocumentSymbolResult, len(v)) 81 | for i := range v { 82 | results[i] = &v[i] 83 | } 84 | return results, nil 85 | default: 86 | return nil, fmt.Errorf("unknown document symbol type: %T", v) 87 | } 88 | } 89 | 90 | // TextEditResult is an interface for types that can be used as text edits 91 | type TextEditResult interface { 92 | GetRange() Range 93 | GetNewText() string 94 | isTextEdit() // marker method 95 | } 96 | 97 | func (te *TextEdit) GetRange() Range { return te.Range } 98 | func (te *TextEdit) GetNewText() string { return te.NewText } 99 | func (te *TextEdit) isTextEdit() {} 100 | 101 | // Convert Or_TextDocumentEdit_edits_Elem to TextEdit 102 | func (e Or_TextDocumentEdit_edits_Elem) AsTextEdit() (TextEdit, error) { 103 | if e.Value == nil { 104 | return TextEdit{}, fmt.Errorf("nil text edit") 105 | } 106 | switch v := e.Value.(type) { 107 | case TextEdit: 108 | return v, nil 109 | case AnnotatedTextEdit: 110 | return TextEdit{ 111 | Range: v.Range, 112 | NewText: v.NewText, 113 | }, nil 114 | default: 115 | return TextEdit{}, fmt.Errorf("unknown text edit type: %T", e.Value) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /internal/lsp/protocol/pattern_interfaces.go: -------------------------------------------------------------------------------- 1 | package protocol 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // PatternInfo is an interface for types that represent glob patterns 9 | type PatternInfo interface { 10 | GetPattern() string 11 | GetBasePath() string 12 | isPattern() // marker method 13 | } 14 | 15 | // StringPattern implements PatternInfo for string patterns 16 | type StringPattern struct { 17 | Pattern string 18 | } 19 | 20 | func (p StringPattern) GetPattern() string { return p.Pattern } 21 | func (p StringPattern) GetBasePath() string { return "" } 22 | func (p StringPattern) isPattern() {} 23 | 24 | // RelativePatternInfo implements PatternInfo for RelativePattern 25 | type RelativePatternInfo struct { 26 | RP RelativePattern 27 | BasePath string 28 | } 29 | 30 | func (p RelativePatternInfo) GetPattern() string { return string(p.RP.Pattern) } 31 | func (p RelativePatternInfo) GetBasePath() string { return p.BasePath } 32 | func (p RelativePatternInfo) isPattern() {} 33 | 34 | // AsPattern converts GlobPattern to a PatternInfo object 35 | func (g *GlobPattern) AsPattern() (PatternInfo, error) { 36 | if g.Value == nil { 37 | return nil, fmt.Errorf("nil pattern") 38 | } 39 | 40 | switch v := g.Value.(type) { 41 | case string: 42 | return StringPattern{Pattern: v}, nil 43 | case RelativePattern: 44 | // Handle BaseURI which could be string or DocumentUri 45 | basePath := "" 46 | switch baseURI := v.BaseURI.Value.(type) { 47 | case string: 48 | basePath = strings.TrimPrefix(baseURI, "file://") 49 | case DocumentUri: 50 | basePath = strings.TrimPrefix(string(baseURI), "file://") 51 | default: 52 | return nil, fmt.Errorf("unknown BaseURI type: %T", v.BaseURI.Value) 53 | } 54 | return RelativePatternInfo{RP: v, BasePath: basePath}, nil 55 | default: 56 | return nil, fmt.Errorf("unknown pattern type: %T", g.Value) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /internal/lsp/protocol/tables.go: -------------------------------------------------------------------------------- 1 | package protocol 2 | 3 | var TableKindMap = map[SymbolKind]string{ 4 | File: "File", 5 | Module: "Module", 6 | Namespace: "Namespace", 7 | Package: "Package", 8 | Class: "Class", 9 | Method: "Method", 10 | Property: "Property", 11 | Field: "Field", 12 | Constructor: "Constructor", 13 | Enum: "Enum", 14 | Interface: "Interface", 15 | Function: "Function", 16 | Variable: "Variable", 17 | Constant: "Constant", 18 | String: "String", 19 | Number: "Number", 20 | Boolean: "Boolean", 21 | Array: "Array", 22 | Object: "Object", 23 | Key: "Key", 24 | Null: "Null", 25 | EnumMember: "EnumMember", 26 | Struct: "Struct", 27 | Event: "Event", 28 | Operator: "Operator", 29 | TypeParameter: "TypeParameter", 30 | } 31 | -------------------------------------------------------------------------------- /internal/lsp/protocol/tsdocument-changes.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package protocol 6 | 7 | import ( 8 | "encoding/json" 9 | "fmt" 10 | ) 11 | 12 | // DocumentChange is a union of various file edit operations. 13 | // 14 | // Exactly one field of this struct is non-nil; see [DocumentChange.Valid]. 15 | // 16 | // See https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#resourceChanges 17 | type DocumentChange struct { 18 | TextDocumentEdit *TextDocumentEdit 19 | CreateFile *CreateFile 20 | RenameFile *RenameFile 21 | DeleteFile *DeleteFile 22 | } 23 | 24 | // Valid reports whether the DocumentChange sum-type value is valid, 25 | // that is, exactly one of create, delete, edit, or rename. 26 | func (ch DocumentChange) Valid() bool { 27 | n := 0 28 | if ch.TextDocumentEdit != nil { 29 | n++ 30 | } 31 | if ch.CreateFile != nil { 32 | n++ 33 | } 34 | if ch.RenameFile != nil { 35 | n++ 36 | } 37 | if ch.DeleteFile != nil { 38 | n++ 39 | } 40 | return n == 1 41 | } 42 | 43 | func (d *DocumentChange) UnmarshalJSON(data []byte) error { 44 | var m map[string]any 45 | if err := json.Unmarshal(data, &m); err != nil { 46 | return err 47 | } 48 | 49 | if _, ok := m["textDocument"]; ok { 50 | d.TextDocumentEdit = new(TextDocumentEdit) 51 | return json.Unmarshal(data, d.TextDocumentEdit) 52 | } 53 | 54 | // The {Create,Rename,Delete}File types all share a 'kind' field. 55 | kind := m["kind"] 56 | switch kind { 57 | case "create": 58 | d.CreateFile = new(CreateFile) 59 | return json.Unmarshal(data, d.CreateFile) 60 | case "rename": 61 | d.RenameFile = new(RenameFile) 62 | return json.Unmarshal(data, d.RenameFile) 63 | case "delete": 64 | d.DeleteFile = new(DeleteFile) 65 | return json.Unmarshal(data, d.DeleteFile) 66 | } 67 | return fmt.Errorf("DocumentChanges: unexpected kind: %q", kind) 68 | } 69 | 70 | func (d *DocumentChange) MarshalJSON() ([]byte, error) { 71 | if d.TextDocumentEdit != nil { 72 | return json.Marshal(d.TextDocumentEdit) 73 | } else if d.CreateFile != nil { 74 | return json.Marshal(d.CreateFile) 75 | } else if d.RenameFile != nil { 76 | return json.Marshal(d.RenameFile) 77 | } else if d.DeleteFile != nil { 78 | return json.Marshal(d.DeleteFile) 79 | } 80 | return nil, fmt.Errorf("empty DocumentChanges union value") 81 | } 82 | -------------------------------------------------------------------------------- /internal/message/attachment.go: -------------------------------------------------------------------------------- 1 | package message 2 | 3 | type Attachment struct { 4 | FilePath string 5 | FileName string 6 | MimeType string 7 | Content []byte 8 | } 9 | -------------------------------------------------------------------------------- /internal/pubsub/broker.go: -------------------------------------------------------------------------------- 1 | package pubsub 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "sync" 8 | "time" 9 | ) 10 | 11 | const defaultChannelBufferSize = 100 12 | 13 | type Broker[T any] struct { 14 | subs map[chan Event[T]]context.CancelFunc 15 | mu sync.RWMutex 16 | isClosed bool 17 | } 18 | 19 | func NewBroker[T any]() *Broker[T] { 20 | return &Broker[T]{ 21 | subs: make(map[chan Event[T]]context.CancelFunc), 22 | } 23 | } 24 | 25 | func (b *Broker[T]) Shutdown() { 26 | b.mu.Lock() 27 | if b.isClosed { 28 | b.mu.Unlock() 29 | return 30 | } 31 | b.isClosed = true 32 | 33 | for ch, cancel := range b.subs { 34 | cancel() 35 | close(ch) 36 | delete(b.subs, ch) 37 | } 38 | b.mu.Unlock() 39 | slog.Debug("PubSub broker shut down", "type", fmt.Sprintf("%T", *new(T))) 40 | } 41 | 42 | func (b *Broker[T]) Subscribe(ctx context.Context) <-chan Event[T] { 43 | b.mu.Lock() 44 | defer b.mu.Unlock() 45 | 46 | if b.isClosed { 47 | closedCh := make(chan Event[T]) 48 | close(closedCh) 49 | return closedCh 50 | } 51 | 52 | subCtx, subCancel := context.WithCancel(ctx) 53 | subscriberChannel := make(chan Event[T], defaultChannelBufferSize) 54 | b.subs[subscriberChannel] = subCancel 55 | 56 | go func() { 57 | <-subCtx.Done() 58 | b.mu.Lock() 59 | defer b.mu.Unlock() 60 | if _, ok := b.subs[subscriberChannel]; ok { 61 | close(subscriberChannel) 62 | delete(b.subs, subscriberChannel) 63 | } 64 | }() 65 | 66 | return subscriberChannel 67 | } 68 | 69 | func (b *Broker[T]) Publish(eventType EventType, payload T) { 70 | b.mu.RLock() 71 | defer b.mu.RUnlock() 72 | 73 | if b.isClosed { 74 | slog.Warn("Attempted to publish on a closed pubsub broker", "type", eventType, "payload_type", fmt.Sprintf("%T", payload)) 75 | return 76 | } 77 | 78 | event := Event[T]{Type: eventType, Payload: payload} 79 | 80 | for ch := range b.subs { 81 | // Non-blocking send with a fallback to a goroutine to prevent slow subscribers 82 | // from blocking the publisher. 83 | select { 84 | case ch <- event: 85 | // Successfully sent 86 | default: 87 | // Subscriber channel is full or receiver is slow. 88 | // Send in a new goroutine to avoid blocking the publisher. 89 | // This might lead to out-of-order delivery for this specific slow subscriber. 90 | go func(sChan chan Event[T], ev Event[T]) { 91 | // Re-check if broker is closed before attempting send in goroutine 92 | b.mu.RLock() 93 | isBrokerClosed := b.isClosed 94 | b.mu.RUnlock() 95 | if isBrokerClosed { 96 | return 97 | } 98 | 99 | select { 100 | case sChan <- ev: 101 | case <-time.After(2 * time.Second): // Timeout for slow subscriber 102 | slog.Warn("PubSub: Dropped event for slow subscriber after timeout", "type", ev.Type) 103 | } 104 | }(ch, event) 105 | } 106 | } 107 | } 108 | 109 | func (b *Broker[T]) GetSubscriberCount() int { 110 | b.mu.RLock() 111 | defer b.mu.RUnlock() 112 | return len(b.subs) 113 | } 114 | -------------------------------------------------------------------------------- /internal/pubsub/broker_test.go: -------------------------------------------------------------------------------- 1 | package pubsub 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestBrokerSubscribe(t *testing.T) { 13 | t.Parallel() 14 | 15 | t.Run("with cancellable context", func(t *testing.T) { 16 | t.Parallel() 17 | broker := NewBroker[string]() 18 | ctx, cancel := context.WithCancel(context.Background()) 19 | defer cancel() 20 | 21 | ch := broker.Subscribe(ctx) 22 | assert.NotNil(t, ch) 23 | assert.Equal(t, 1, broker.GetSubscriberCount()) 24 | 25 | // Cancel the context should remove the subscription 26 | cancel() 27 | time.Sleep(10 * time.Millisecond) // Give time for goroutine to process 28 | assert.Equal(t, 0, broker.GetSubscriberCount()) 29 | }) 30 | 31 | t.Run("with background context", func(t *testing.T) { 32 | t.Parallel() 33 | broker := NewBroker[string]() 34 | 35 | // Using context.Background() should not leak goroutines 36 | ch := broker.Subscribe(context.Background()) 37 | assert.NotNil(t, ch) 38 | assert.Equal(t, 1, broker.GetSubscriberCount()) 39 | 40 | // Shutdown should clean up all subscriptions 41 | broker.Shutdown() 42 | assert.Equal(t, 0, broker.GetSubscriberCount()) 43 | }) 44 | } 45 | 46 | func TestBrokerPublish(t *testing.T) { 47 | t.Parallel() 48 | broker := NewBroker[string]() 49 | ctx := t.Context() 50 | 51 | ch := broker.Subscribe(ctx) 52 | 53 | // Publish a message 54 | broker.Publish(EventTypeCreated, "test message") 55 | 56 | // Verify message is received 57 | select { 58 | case event := <-ch: 59 | assert.Equal(t, EventTypeCreated, event.Type) 60 | assert.Equal(t, "test message", event.Payload) 61 | case <-time.After(100 * time.Millisecond): 62 | t.Fatal("timeout waiting for message") 63 | } 64 | } 65 | 66 | func TestBrokerShutdown(t *testing.T) { 67 | t.Parallel() 68 | broker := NewBroker[string]() 69 | 70 | // Create multiple subscribers 71 | ch1 := broker.Subscribe(context.Background()) 72 | ch2 := broker.Subscribe(context.Background()) 73 | 74 | assert.Equal(t, 2, broker.GetSubscriberCount()) 75 | 76 | // Shutdown should close all channels and clean up 77 | broker.Shutdown() 78 | 79 | // Verify channels are closed 80 | _, ok1 := <-ch1 81 | _, ok2 := <-ch2 82 | assert.False(t, ok1, "channel 1 should be closed") 83 | assert.False(t, ok2, "channel 2 should be closed") 84 | 85 | // Verify subscriber count is reset 86 | assert.Equal(t, 0, broker.GetSubscriberCount()) 87 | } 88 | 89 | func TestBrokerConcurrency(t *testing.T) { 90 | t.Parallel() 91 | broker := NewBroker[int]() 92 | 93 | // Create a large number of subscribers 94 | const numSubscribers = 100 95 | var wg sync.WaitGroup 96 | wg.Add(numSubscribers) 97 | 98 | // Create a channel to collect received events 99 | receivedEvents := make(chan int, numSubscribers) 100 | 101 | for i := range numSubscribers { 102 | go func(id int) { 103 | defer wg.Done() 104 | ctx, cancel := context.WithCancel(context.Background()) 105 | defer cancel() 106 | 107 | ch := broker.Subscribe(ctx) 108 | 109 | // Receive one message then cancel 110 | select { 111 | case event := <-ch: 112 | receivedEvents <- event.Payload 113 | case <-time.After(1 * time.Second): 114 | t.Errorf("timeout waiting for message %d", id) 115 | } 116 | cancel() 117 | }(i) 118 | } 119 | 120 | // Give subscribers time to set up 121 | time.Sleep(10 * time.Millisecond) 122 | 123 | // Publish messages to all subscribers 124 | for i := range numSubscribers { 125 | broker.Publish(EventTypeCreated, i) 126 | } 127 | 128 | // Wait for all subscribers to finish 129 | wg.Wait() 130 | close(receivedEvents) 131 | 132 | // Give time for cleanup goroutines to run 133 | time.Sleep(10 * time.Millisecond) 134 | 135 | // Verify all subscribers are cleaned up 136 | assert.Equal(t, 0, broker.GetSubscriberCount()) 137 | 138 | // Verify we received the expected number of events 139 | count := 0 140 | for range receivedEvents { 141 | count++ 142 | } 143 | assert.Equal(t, numSubscribers, count) 144 | } 145 | -------------------------------------------------------------------------------- /internal/pubsub/events.go: -------------------------------------------------------------------------------- 1 | package pubsub 2 | 3 | import "context" 4 | 5 | type EventType string 6 | 7 | const ( 8 | EventTypeCreated EventType = "created" 9 | EventTypeUpdated EventType = "updated" 10 | EventTypeDeleted EventType = "deleted" 11 | ) 12 | 13 | type Event[T any] struct { 14 | Type EventType 15 | Payload T 16 | } 17 | 18 | type Subscriber[T any] interface { 19 | Subscribe(ctx context.Context) <-chan Event[T] 20 | } 21 | 22 | type Publisher[T any] interface { 23 | Publish(eventType EventType, payload T) 24 | } 25 | -------------------------------------------------------------------------------- /internal/status/status.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "sync" 8 | "time" 9 | 10 | "github.com/sst/opencode/internal/pubsub" 11 | ) 12 | 13 | type Level string 14 | 15 | const ( 16 | LevelInfo Level = "info" 17 | LevelWarn Level = "warn" 18 | LevelError Level = "error" 19 | LevelDebug Level = "debug" 20 | ) 21 | 22 | type StatusMessage struct { 23 | Level Level `json:"level"` 24 | Message string `json:"message"` 25 | Timestamp time.Time `json:"timestamp"` 26 | Critical bool `json:"critical"` 27 | Duration time.Duration `json:"duration"` 28 | } 29 | 30 | // StatusOption is a function that configures a status message 31 | type StatusOption func(*StatusMessage) 32 | 33 | // WithCritical marks a status message as critical, causing it to be displayed immediately 34 | func WithCritical(critical bool) StatusOption { 35 | return func(msg *StatusMessage) { 36 | msg.Critical = critical 37 | } 38 | } 39 | 40 | // WithDuration sets a custom display duration for a status message 41 | func WithDuration(duration time.Duration) StatusOption { 42 | return func(msg *StatusMessage) { 43 | msg.Duration = duration 44 | } 45 | } 46 | 47 | const ( 48 | EventStatusPublished pubsub.EventType = "status_published" 49 | ) 50 | 51 | type Service interface { 52 | pubsub.Subscriber[StatusMessage] 53 | 54 | Info(message string, opts ...StatusOption) 55 | Warn(message string, opts ...StatusOption) 56 | Error(message string, opts ...StatusOption) 57 | Debug(message string, opts ...StatusOption) 58 | } 59 | 60 | type service struct { 61 | broker *pubsub.Broker[StatusMessage] 62 | mu sync.RWMutex 63 | } 64 | 65 | var globalStatusService *service 66 | 67 | func InitService() error { 68 | if globalStatusService != nil { 69 | return fmt.Errorf("status service already initialized") 70 | } 71 | broker := pubsub.NewBroker[StatusMessage]() 72 | globalStatusService = &service{ 73 | broker: broker, 74 | } 75 | return nil 76 | } 77 | 78 | func GetService() Service { 79 | if globalStatusService == nil { 80 | panic("status service not initialized. Call status.InitService() at application startup.") 81 | } 82 | return globalStatusService 83 | } 84 | 85 | func (s *service) Info(message string, opts ...StatusOption) { 86 | s.publish(LevelInfo, message, opts...) 87 | slog.Info(message) 88 | } 89 | 90 | func (s *service) Warn(message string, opts ...StatusOption) { 91 | s.publish(LevelWarn, message, opts...) 92 | slog.Warn(message) 93 | } 94 | 95 | func (s *service) Error(message string, opts ...StatusOption) { 96 | s.publish(LevelError, message, opts...) 97 | slog.Error(message) 98 | } 99 | 100 | func (s *service) Debug(message string, opts ...StatusOption) { 101 | s.publish(LevelDebug, message, opts...) 102 | slog.Debug(message) 103 | } 104 | 105 | func (s *service) publish(level Level, messageText string, opts ...StatusOption) { 106 | statusMsg := StatusMessage{ 107 | Level: level, 108 | Message: messageText, 109 | Timestamp: time.Now(), 110 | } 111 | 112 | // Apply all options 113 | for _, opt := range opts { 114 | opt(&statusMsg) 115 | } 116 | 117 | s.broker.Publish(EventStatusPublished, statusMsg) 118 | } 119 | 120 | func (s *service) Subscribe(ctx context.Context) <-chan pubsub.Event[StatusMessage] { 121 | return s.broker.Subscribe(ctx) 122 | } 123 | 124 | func Info(message string, opts ...StatusOption) { 125 | GetService().Info(message, opts...) 126 | } 127 | 128 | func Warn(message string, opts ...StatusOption) { 129 | GetService().Warn(message, opts...) 130 | } 131 | 132 | func Error(message string, opts ...StatusOption) { 133 | GetService().Error(message, opts...) 134 | } 135 | 136 | func Debug(message string, opts ...StatusOption) { 137 | GetService().Debug(message, opts...) 138 | } 139 | 140 | func Subscribe(ctx context.Context) <-chan pubsub.Event[StatusMessage] { 141 | return GetService().Subscribe(ctx) 142 | } 143 | -------------------------------------------------------------------------------- /internal/tui/components/chat/chat.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | 7 | "github.com/charmbracelet/lipgloss" 8 | "github.com/charmbracelet/x/ansi" 9 | "github.com/sst/opencode/internal/config" 10 | "github.com/sst/opencode/internal/message" 11 | "github.com/sst/opencode/internal/tui/styles" 12 | "github.com/sst/opencode/internal/tui/theme" 13 | "github.com/sst/opencode/internal/version" 14 | ) 15 | 16 | type SendMsg struct { 17 | Text string 18 | Attachments []message.Attachment 19 | } 20 | 21 | func header(width int) string { 22 | return lipgloss.JoinVertical( 23 | lipgloss.Top, 24 | logo(width), 25 | repo(width), 26 | "", 27 | cwd(width), 28 | ) 29 | } 30 | 31 | func lspsConfigured(width int) string { 32 | cfg := config.Get() 33 | title := "LSP Servers" 34 | title = ansi.Truncate(title, width, "…") 35 | 36 | t := theme.CurrentTheme() 37 | baseStyle := styles.BaseStyle() 38 | 39 | lsps := baseStyle. 40 | Width(width). 41 | Foreground(t.Primary()). 42 | Bold(true). 43 | Render(title) 44 | 45 | // Get LSP names and sort them for consistent ordering 46 | var lspNames []string 47 | for name := range cfg.LSP { 48 | lspNames = append(lspNames, name) 49 | } 50 | sort.Strings(lspNames) 51 | 52 | var lspViews []string 53 | for _, name := range lspNames { 54 | lsp := cfg.LSP[name] 55 | lspName := baseStyle. 56 | Foreground(t.Text()). 57 | Render(fmt.Sprintf("• %s", name)) 58 | 59 | cmd := lsp.Command 60 | cmd = ansi.Truncate(cmd, width-lipgloss.Width(lspName)-3, "…") 61 | 62 | lspPath := baseStyle. 63 | Foreground(t.TextMuted()). 64 | Render(fmt.Sprintf(" (%s)", cmd)) 65 | 66 | lspViews = append(lspViews, 67 | baseStyle. 68 | Width(width). 69 | Render( 70 | lipgloss.JoinHorizontal( 71 | lipgloss.Left, 72 | lspName, 73 | lspPath, 74 | ), 75 | ), 76 | ) 77 | } 78 | 79 | return baseStyle. 80 | Width(width). 81 | Render( 82 | lipgloss.JoinVertical( 83 | lipgloss.Left, 84 | lsps, 85 | lipgloss.JoinVertical( 86 | lipgloss.Left, 87 | lspViews..., 88 | ), 89 | ), 90 | ) 91 | } 92 | 93 | func logo(width int) string { 94 | logo := fmt.Sprintf("%s %s", styles.OpenCodeIcon, "OpenCode") 95 | t := theme.CurrentTheme() 96 | baseStyle := styles.BaseStyle() 97 | 98 | versionText := baseStyle. 99 | Foreground(t.TextMuted()). 100 | Render(version.Version) 101 | 102 | return baseStyle. 103 | Bold(true). 104 | Width(width). 105 | Render( 106 | lipgloss.JoinHorizontal( 107 | lipgloss.Left, 108 | logo, 109 | " ", 110 | versionText, 111 | ), 112 | ) 113 | } 114 | 115 | func repo(width int) string { 116 | repo := "github.com/sst/opencode" 117 | t := theme.CurrentTheme() 118 | 119 | return styles.BaseStyle(). 120 | Foreground(t.TextMuted()). 121 | Width(width). 122 | Render(repo) 123 | } 124 | 125 | func cwd(width int) string { 126 | cwd := fmt.Sprintf("cwd: %s", config.WorkingDirectory()) 127 | t := theme.CurrentTheme() 128 | 129 | return styles.BaseStyle(). 130 | Foreground(t.TextMuted()). 131 | Width(width). 132 | Render(cwd) 133 | } 134 | -------------------------------------------------------------------------------- /internal/tui/components/dialog/commands.go: -------------------------------------------------------------------------------- 1 | package dialog 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | tea "github.com/charmbracelet/bubbletea" 6 | "github.com/charmbracelet/lipgloss" 7 | utilComponents "github.com/sst/opencode/internal/tui/components/util" 8 | "github.com/sst/opencode/internal/tui/layout" 9 | "github.com/sst/opencode/internal/tui/styles" 10 | "github.com/sst/opencode/internal/tui/theme" 11 | "github.com/sst/opencode/internal/tui/util" 12 | ) 13 | 14 | // Command represents a command that can be executed 15 | type Command struct { 16 | ID string 17 | Title string 18 | Description string 19 | Handler func(cmd Command) tea.Cmd 20 | } 21 | 22 | func (ci Command) Render(selected bool, width int) string { 23 | t := theme.CurrentTheme() 24 | baseStyle := styles.BaseStyle() 25 | 26 | descStyle := baseStyle.Width(width).Foreground(t.TextMuted()) 27 | itemStyle := baseStyle.Width(width). 28 | Foreground(t.Text()). 29 | Background(t.Background()) 30 | 31 | if selected { 32 | itemStyle = itemStyle. 33 | Background(t.Primary()). 34 | Foreground(t.Background()). 35 | Bold(true) 36 | descStyle = descStyle. 37 | Background(t.Primary()). 38 | Foreground(t.Background()) 39 | } 40 | 41 | title := itemStyle.Padding(0, 1).Render(ci.Title) 42 | if ci.Description != "" { 43 | description := descStyle.Padding(0, 1).Render(ci.Description) 44 | return lipgloss.JoinVertical(lipgloss.Left, title, description) 45 | } 46 | return title 47 | } 48 | 49 | // CommandSelectedMsg is sent when a command is selected 50 | type CommandSelectedMsg struct { 51 | Command Command 52 | } 53 | 54 | // CloseCommandDialogMsg is sent when the command dialog is closed 55 | type CloseCommandDialogMsg struct{} 56 | 57 | // CommandDialog interface for the command selection dialog 58 | type CommandDialog interface { 59 | tea.Model 60 | layout.Bindings 61 | SetCommands(commands []Command) 62 | } 63 | 64 | type commandDialogCmp struct { 65 | listView utilComponents.SimpleList[Command] 66 | width int 67 | height int 68 | } 69 | 70 | type commandKeyMap struct { 71 | Enter key.Binding 72 | Escape key.Binding 73 | } 74 | 75 | var commandKeys = commandKeyMap{ 76 | Enter: key.NewBinding( 77 | key.WithKeys("enter"), 78 | key.WithHelp("enter", "select command"), 79 | ), 80 | Escape: key.NewBinding( 81 | key.WithKeys("esc"), 82 | key.WithHelp("esc", "close"), 83 | ), 84 | } 85 | 86 | func (c *commandDialogCmp) Init() tea.Cmd { 87 | return c.listView.Init() 88 | } 89 | 90 | func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 91 | var cmds []tea.Cmd 92 | switch msg := msg.(type) { 93 | case tea.KeyMsg: 94 | switch { 95 | case key.Matches(msg, commandKeys.Enter): 96 | selectedItem, idx := c.listView.GetSelectedItem() 97 | if idx != -1 { 98 | return c, util.CmdHandler(CommandSelectedMsg{ 99 | Command: selectedItem, 100 | }) 101 | } 102 | case key.Matches(msg, commandKeys.Escape): 103 | return c, util.CmdHandler(CloseCommandDialogMsg{}) 104 | } 105 | case tea.WindowSizeMsg: 106 | c.width = msg.Width 107 | c.height = msg.Height 108 | } 109 | 110 | u, cmd := c.listView.Update(msg) 111 | c.listView = u.(utilComponents.SimpleList[Command]) 112 | cmds = append(cmds, cmd) 113 | 114 | return c, tea.Batch(cmds...) 115 | } 116 | 117 | func (c *commandDialogCmp) View() string { 118 | t := theme.CurrentTheme() 119 | baseStyle := styles.BaseStyle() 120 | 121 | maxWidth := 40 122 | 123 | commands := c.listView.GetItems() 124 | 125 | for _, cmd := range commands { 126 | if len(cmd.Title) > maxWidth-4 { 127 | maxWidth = len(cmd.Title) + 4 128 | } 129 | if cmd.Description != "" { 130 | if len(cmd.Description) > maxWidth-4 { 131 | maxWidth = len(cmd.Description) + 4 132 | } 133 | } 134 | } 135 | 136 | c.listView.SetMaxWidth(maxWidth) 137 | 138 | title := baseStyle. 139 | Foreground(t.Primary()). 140 | Bold(true). 141 | Width(maxWidth). 142 | Padding(0, 1). 143 | Render("Commands") 144 | 145 | content := lipgloss.JoinVertical( 146 | lipgloss.Left, 147 | title, 148 | baseStyle.Width(maxWidth).Render(""), 149 | baseStyle.Width(maxWidth).Render(c.listView.View()), 150 | baseStyle.Width(maxWidth).Render(""), 151 | ) 152 | 153 | return baseStyle.Padding(1, 2). 154 | Border(lipgloss.RoundedBorder()). 155 | BorderBackground(t.Background()). 156 | BorderForeground(t.TextMuted()). 157 | Width(lipgloss.Width(content) + 4). 158 | Render(content) 159 | } 160 | 161 | func (c *commandDialogCmp) BindingKeys() []key.Binding { 162 | return layout.KeyMapToSlice(commandKeys) 163 | } 164 | 165 | func (c *commandDialogCmp) SetCommands(commands []Command) { 166 | c.listView.SetItems(commands) 167 | } 168 | 169 | // NewCommandDialogCmp creates a new command selection dialog 170 | func NewCommandDialogCmp() CommandDialog { 171 | listView := utilComponents.NewSimpleList[Command]( 172 | []Command{}, 173 | 10, 174 | "No commands available", 175 | true, 176 | ) 177 | return &commandDialogCmp{ 178 | listView: listView, 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /internal/tui/components/dialog/custom_commands_test.go: -------------------------------------------------------------------------------- 1 | package dialog 2 | 3 | import ( 4 | "testing" 5 | "regexp" 6 | ) 7 | 8 | func TestNamedArgPattern(t *testing.T) { 9 | testCases := []struct { 10 | input string 11 | expected []string 12 | }{ 13 | { 14 | input: "This is a test with $ARGUMENTS placeholder", 15 | expected: []string{"ARGUMENTS"}, 16 | }, 17 | { 18 | input: "This is a test with $FOO and $BAR placeholders", 19 | expected: []string{"FOO", "BAR"}, 20 | }, 21 | { 22 | input: "This is a test with $FOO_BAR and $BAZ123 placeholders", 23 | expected: []string{"FOO_BAR", "BAZ123"}, 24 | }, 25 | { 26 | input: "This is a test with no placeholders", 27 | expected: []string{}, 28 | }, 29 | { 30 | input: "This is a test with $FOO appearing twice: $FOO", 31 | expected: []string{"FOO"}, 32 | }, 33 | { 34 | input: "This is a test with $1INVALID placeholder", 35 | expected: []string{}, 36 | }, 37 | } 38 | 39 | for _, tc := range testCases { 40 | matches := namedArgPattern.FindAllStringSubmatch(tc.input, -1) 41 | 42 | // Extract unique argument names 43 | argNames := make([]string, 0) 44 | argMap := make(map[string]bool) 45 | 46 | for _, match := range matches { 47 | argName := match[1] // Group 1 is the name without $ 48 | if !argMap[argName] { 49 | argMap[argName] = true 50 | argNames = append(argNames, argName) 51 | } 52 | } 53 | 54 | // Check if we got the expected number of arguments 55 | if len(argNames) != len(tc.expected) { 56 | t.Errorf("Expected %d arguments, got %d for input: %s", len(tc.expected), len(argNames), tc.input) 57 | continue 58 | } 59 | 60 | // Check if we got the expected argument names 61 | for _, expectedArg := range tc.expected { 62 | found := false 63 | for _, actualArg := range argNames { 64 | if actualArg == expectedArg { 65 | found = true 66 | break 67 | } 68 | } 69 | if !found { 70 | t.Errorf("Expected argument %s not found in %v for input: %s", expectedArg, argNames, tc.input) 71 | } 72 | } 73 | } 74 | } 75 | 76 | func TestRegexPattern(t *testing.T) { 77 | pattern := regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`) 78 | 79 | validMatches := []string{ 80 | "$FOO", 81 | "$BAR", 82 | "$FOO_BAR", 83 | "$BAZ123", 84 | "$ARGUMENTS", 85 | } 86 | 87 | invalidMatches := []string{ 88 | "$foo", 89 | "$1BAR", 90 | "$_FOO", 91 | "FOO", 92 | "$", 93 | } 94 | 95 | for _, valid := range validMatches { 96 | if !pattern.MatchString(valid) { 97 | t.Errorf("Expected %s to match, but it didn't", valid) 98 | } 99 | } 100 | 101 | for _, invalid := range invalidMatches { 102 | if pattern.MatchString(invalid) { 103 | t.Errorf("Expected %s not to match, but it did", invalid) 104 | } 105 | } 106 | } -------------------------------------------------------------------------------- /internal/tui/components/dialog/quit.go: -------------------------------------------------------------------------------- 1 | package dialog 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/charmbracelet/bubbles/key" 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/charmbracelet/lipgloss" 9 | "github.com/sst/opencode/internal/tui/layout" 10 | "github.com/sst/opencode/internal/tui/styles" 11 | "github.com/sst/opencode/internal/tui/theme" 12 | "github.com/sst/opencode/internal/tui/util" 13 | ) 14 | 15 | const question = "Are you sure you want to quit?" 16 | 17 | type CloseQuitMsg struct{} 18 | 19 | type QuitDialog interface { 20 | tea.Model 21 | layout.Bindings 22 | } 23 | 24 | type quitDialogCmp struct { 25 | selectedNo bool 26 | } 27 | 28 | type helpMapping struct { 29 | LeftRight key.Binding 30 | EnterSpace key.Binding 31 | Yes key.Binding 32 | No key.Binding 33 | Tab key.Binding 34 | } 35 | 36 | var helpKeys = helpMapping{ 37 | LeftRight: key.NewBinding( 38 | key.WithKeys("left", "right"), 39 | key.WithHelp("←/→", "switch options"), 40 | ), 41 | EnterSpace: key.NewBinding( 42 | key.WithKeys("enter", " "), 43 | key.WithHelp("enter/space", "confirm"), 44 | ), 45 | Yes: key.NewBinding( 46 | key.WithKeys("y", "Y"), 47 | key.WithHelp("y/Y", "yes"), 48 | ), 49 | No: key.NewBinding( 50 | key.WithKeys("n", "N"), 51 | key.WithHelp("n/N", "no"), 52 | ), 53 | Tab: key.NewBinding( 54 | key.WithKeys("tab"), 55 | key.WithHelp("tab", "switch options"), 56 | ), 57 | } 58 | 59 | func (q *quitDialogCmp) Init() tea.Cmd { 60 | return nil 61 | } 62 | 63 | func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 64 | switch msg := msg.(type) { 65 | case tea.KeyMsg: 66 | switch { 67 | case key.Matches(msg, helpKeys.LeftRight) || key.Matches(msg, helpKeys.Tab): 68 | q.selectedNo = !q.selectedNo 69 | return q, nil 70 | case key.Matches(msg, helpKeys.EnterSpace): 71 | if !q.selectedNo { 72 | return q, tea.Quit 73 | } 74 | return q, util.CmdHandler(CloseQuitMsg{}) 75 | case key.Matches(msg, helpKeys.Yes): 76 | return q, tea.Quit 77 | case key.Matches(msg, helpKeys.No): 78 | return q, util.CmdHandler(CloseQuitMsg{}) 79 | } 80 | } 81 | return q, nil 82 | } 83 | 84 | func (q *quitDialogCmp) View() string { 85 | t := theme.CurrentTheme() 86 | baseStyle := styles.BaseStyle() 87 | 88 | yesStyle := baseStyle 89 | noStyle := baseStyle 90 | spacerStyle := baseStyle.Background(t.Background()) 91 | 92 | if q.selectedNo { 93 | noStyle = noStyle.Background(t.Primary()).Foreground(t.Background()) 94 | yesStyle = yesStyle.Background(t.Background()).Foreground(t.Primary()) 95 | } else { 96 | yesStyle = yesStyle.Background(t.Primary()).Foreground(t.Background()) 97 | noStyle = noStyle.Background(t.Background()).Foreground(t.Primary()) 98 | } 99 | 100 | yesButton := yesStyle.Padding(0, 1).Render("Yes") 101 | noButton := noStyle.Padding(0, 1).Render("No") 102 | 103 | buttons := lipgloss.JoinHorizontal(lipgloss.Left, yesButton, spacerStyle.Render(" "), noButton) 104 | 105 | width := lipgloss.Width(question) 106 | remainingWidth := width - lipgloss.Width(buttons) 107 | if remainingWidth > 0 { 108 | buttons = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + buttons 109 | } 110 | 111 | content := baseStyle.Render( 112 | lipgloss.JoinVertical( 113 | lipgloss.Center, 114 | question, 115 | "", 116 | buttons, 117 | ), 118 | ) 119 | 120 | return baseStyle.Padding(1, 2). 121 | Border(lipgloss.RoundedBorder()). 122 | BorderBackground(t.Background()). 123 | BorderForeground(t.TextMuted()). 124 | Width(lipgloss.Width(content) + 4). 125 | Render(content) 126 | } 127 | 128 | func (q *quitDialogCmp) BindingKeys() []key.Binding { 129 | return layout.KeyMapToSlice(helpKeys) 130 | } 131 | 132 | func NewQuitCmp() QuitDialog { 133 | return &quitDialogCmp{ 134 | selectedNo: true, 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /internal/tui/components/dialog/tools.go: -------------------------------------------------------------------------------- 1 | package dialog 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | tea "github.com/charmbracelet/bubbletea" 6 | "github.com/charmbracelet/lipgloss" 7 | utilComponents "github.com/sst/opencode/internal/tui/components/util" 8 | "github.com/sst/opencode/internal/tui/layout" 9 | "github.com/sst/opencode/internal/tui/styles" 10 | "github.com/sst/opencode/internal/tui/theme" 11 | ) 12 | 13 | const ( 14 | maxToolsDialogWidth = 60 15 | maxVisibleTools = 15 16 | ) 17 | 18 | // ToolsDialog interface for the tools list dialog 19 | type ToolsDialog interface { 20 | tea.Model 21 | layout.Bindings 22 | SetTools(tools []string) 23 | } 24 | 25 | // ShowToolsDialogMsg is sent to show the tools dialog 26 | type ShowToolsDialogMsg struct { 27 | Show bool 28 | } 29 | 30 | // CloseToolsDialogMsg is sent when the tools dialog is closed 31 | type CloseToolsDialogMsg struct{} 32 | 33 | type toolItem struct { 34 | name string 35 | } 36 | 37 | func (t toolItem) Render(selected bool, width int) string { 38 | th := theme.CurrentTheme() 39 | baseStyle := styles.BaseStyle(). 40 | Width(width). 41 | Background(th.Background()) 42 | 43 | if selected { 44 | baseStyle = baseStyle. 45 | Background(th.Primary()). 46 | Foreground(th.Background()). 47 | Bold(true) 48 | } else { 49 | baseStyle = baseStyle. 50 | Foreground(th.Text()) 51 | } 52 | 53 | return baseStyle.Render(t.name) 54 | } 55 | 56 | type toolsDialogCmp struct { 57 | tools []toolItem 58 | width int 59 | height int 60 | list utilComponents.SimpleList[toolItem] 61 | } 62 | 63 | type toolsKeyMap struct { 64 | Up key.Binding 65 | Down key.Binding 66 | Escape key.Binding 67 | J key.Binding 68 | K key.Binding 69 | } 70 | 71 | var toolsKeys = toolsKeyMap{ 72 | Up: key.NewBinding( 73 | key.WithKeys("up"), 74 | key.WithHelp("↑", "previous tool"), 75 | ), 76 | Down: key.NewBinding( 77 | key.WithKeys("down"), 78 | key.WithHelp("↓", "next tool"), 79 | ), 80 | Escape: key.NewBinding( 81 | key.WithKeys("esc"), 82 | key.WithHelp("esc", "close"), 83 | ), 84 | J: key.NewBinding( 85 | key.WithKeys("j"), 86 | key.WithHelp("j", "next tool"), 87 | ), 88 | K: key.NewBinding( 89 | key.WithKeys("k"), 90 | key.WithHelp("k", "previous tool"), 91 | ), 92 | } 93 | 94 | func (m *toolsDialogCmp) Init() tea.Cmd { 95 | return nil 96 | } 97 | 98 | func (m *toolsDialogCmp) SetTools(tools []string) { 99 | var toolItems []toolItem 100 | for _, name := range tools { 101 | toolItems = append(toolItems, toolItem{name: name}) 102 | } 103 | 104 | m.tools = toolItems 105 | m.list.SetItems(toolItems) 106 | } 107 | 108 | func (m *toolsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 109 | switch msg := msg.(type) { 110 | case tea.KeyMsg: 111 | switch { 112 | case key.Matches(msg, toolsKeys.Escape): 113 | return m, func() tea.Msg { return CloseToolsDialogMsg{} } 114 | // Pass other key messages to the list component 115 | default: 116 | var cmd tea.Cmd 117 | listModel, cmd := m.list.Update(msg) 118 | m.list = listModel.(utilComponents.SimpleList[toolItem]) 119 | return m, cmd 120 | } 121 | case tea.WindowSizeMsg: 122 | m.width = msg.Width 123 | m.height = msg.Height 124 | } 125 | 126 | // For non-key messages 127 | var cmd tea.Cmd 128 | listModel, cmd := m.list.Update(msg) 129 | m.list = listModel.(utilComponents.SimpleList[toolItem]) 130 | return m, cmd 131 | } 132 | 133 | func (m *toolsDialogCmp) View() string { 134 | t := theme.CurrentTheme() 135 | baseStyle := styles.BaseStyle().Background(t.Background()) 136 | 137 | title := baseStyle. 138 | Foreground(t.Primary()). 139 | Bold(true). 140 | Width(maxToolsDialogWidth). 141 | Padding(0, 0, 1). 142 | Render("Available Tools") 143 | 144 | // Calculate dialog width based on content 145 | dialogWidth := min(maxToolsDialogWidth, m.width/2) 146 | m.list.SetMaxWidth(dialogWidth) 147 | 148 | content := lipgloss.JoinVertical( 149 | lipgloss.Left, 150 | title, 151 | m.list.View(), 152 | ) 153 | 154 | return baseStyle.Padding(1, 2). 155 | Border(lipgloss.RoundedBorder()). 156 | BorderBackground(t.Background()). 157 | BorderForeground(t.TextMuted()). 158 | Background(t.Background()). 159 | Width(lipgloss.Width(content) + 4). 160 | Render(content) 161 | } 162 | 163 | func (m *toolsDialogCmp) BindingKeys() []key.Binding { 164 | return layout.KeyMapToSlice(toolsKeys) 165 | } 166 | 167 | func NewToolsDialogCmp() ToolsDialog { 168 | list := utilComponents.NewSimpleList[toolItem]( 169 | []toolItem{}, 170 | maxVisibleTools, 171 | "No tools available", 172 | true, 173 | ) 174 | 175 | return &toolsDialogCmp{ 176 | list: list, 177 | } 178 | } -------------------------------------------------------------------------------- /internal/tui/components/spinner/spinner.go: -------------------------------------------------------------------------------- 1 | package spinner 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/charmbracelet/bubbles/spinner" 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/charmbracelet/lipgloss" 11 | ) 12 | 13 | // Spinner wraps the bubbles spinner for both interactive and non-interactive mode 14 | type Spinner struct { 15 | model spinner.Model 16 | done chan struct{} 17 | prog *tea.Program 18 | ctx context.Context 19 | cancel context.CancelFunc 20 | } 21 | 22 | // spinnerModel is the tea.Model for the spinner 23 | type spinnerModel struct { 24 | spinner spinner.Model 25 | message string 26 | quitting bool 27 | } 28 | 29 | func (m spinnerModel) Init() tea.Cmd { 30 | return m.spinner.Tick 31 | } 32 | 33 | func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 34 | switch msg := msg.(type) { 35 | case tea.KeyMsg: 36 | m.quitting = true 37 | return m, tea.Quit 38 | case spinner.TickMsg: 39 | var cmd tea.Cmd 40 | m.spinner, cmd = m.spinner.Update(msg) 41 | return m, cmd 42 | case quitMsg: 43 | m.quitting = true 44 | return m, tea.Quit 45 | default: 46 | return m, nil 47 | } 48 | } 49 | 50 | func (m spinnerModel) View() string { 51 | if m.quitting { 52 | return "" 53 | } 54 | return fmt.Sprintf("%s %s", m.spinner.View(), m.message) 55 | } 56 | 57 | // quitMsg is sent when we want to quit the spinner 58 | type quitMsg struct{} 59 | 60 | // NewSpinner creates a new spinner with the given message 61 | func NewSpinner(message string) *Spinner { 62 | s := spinner.New() 63 | s.Spinner = spinner.Dot 64 | s.Style = s.Style.Foreground(s.Style.GetForeground()) 65 | 66 | ctx, cancel := context.WithCancel(context.Background()) 67 | 68 | model := spinnerModel{ 69 | spinner: s, 70 | message: message, 71 | } 72 | 73 | prog := tea.NewProgram(model, tea.WithOutput(os.Stderr), tea.WithoutCatchPanics()) 74 | 75 | return &Spinner{ 76 | model: s, 77 | done: make(chan struct{}), 78 | prog: prog, 79 | ctx: ctx, 80 | cancel: cancel, 81 | } 82 | } 83 | 84 | // NewThemedSpinner creates a new spinner with the given message and color 85 | func NewThemedSpinner(message string, color lipgloss.AdaptiveColor) *Spinner { 86 | s := spinner.New() 87 | s.Spinner = spinner.Dot 88 | s.Style = s.Style.Foreground(color) 89 | 90 | ctx, cancel := context.WithCancel(context.Background()) 91 | 92 | model := spinnerModel{ 93 | spinner: s, 94 | message: message, 95 | } 96 | 97 | prog := tea.NewProgram(model, tea.WithOutput(os.Stderr), tea.WithoutCatchPanics()) 98 | 99 | return &Spinner{ 100 | model: s, 101 | done: make(chan struct{}), 102 | prog: prog, 103 | ctx: ctx, 104 | cancel: cancel, 105 | } 106 | } 107 | 108 | // Start begins the spinner animation 109 | func (s *Spinner) Start() { 110 | go func() { 111 | defer close(s.done) 112 | go func() { 113 | <-s.ctx.Done() 114 | s.prog.Send(quitMsg{}) 115 | }() 116 | _, err := s.prog.Run() 117 | if err != nil { 118 | fmt.Fprintf(os.Stderr, "Error running spinner: %v\n", err) 119 | } 120 | }() 121 | } 122 | 123 | // Stop ends the spinner animation 124 | func (s *Spinner) Stop() { 125 | s.cancel() 126 | <-s.done 127 | } -------------------------------------------------------------------------------- /internal/tui/components/spinner/spinner_test.go: -------------------------------------------------------------------------------- 1 | package spinner 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestSpinner(t *testing.T) { 9 | t.Parallel() 10 | 11 | // Create a spinner 12 | s := NewSpinner("Test spinner") 13 | 14 | // Start the spinner 15 | s.Start() 16 | 17 | // Wait a bit to let it run 18 | time.Sleep(100 * time.Millisecond) 19 | 20 | // Stop the spinner 21 | s.Stop() 22 | 23 | // If we got here without panicking, the test passes 24 | } -------------------------------------------------------------------------------- /internal/tui/components/util/simple-list.go: -------------------------------------------------------------------------------- 1 | package utilComponents 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | tea "github.com/charmbracelet/bubbletea" 6 | "github.com/charmbracelet/lipgloss" 7 | "github.com/sst/opencode/internal/tui/layout" 8 | "github.com/sst/opencode/internal/tui/styles" 9 | "github.com/sst/opencode/internal/tui/theme" 10 | ) 11 | 12 | type SimpleListItem interface { 13 | Render(selected bool, width int) string 14 | } 15 | 16 | type SimpleList[T SimpleListItem] interface { 17 | tea.Model 18 | layout.Bindings 19 | SetMaxWidth(maxWidth int) 20 | GetSelectedItem() (item T, idx int) 21 | SetItems(items []T) 22 | GetItems() []T 23 | } 24 | 25 | type simpleListCmp[T SimpleListItem] struct { 26 | fallbackMsg string 27 | items []T 28 | selectedIdx int 29 | maxWidth int 30 | maxVisibleItems int 31 | useAlphaNumericKeys bool 32 | width int 33 | height int 34 | } 35 | 36 | type simpleListKeyMap struct { 37 | Up key.Binding 38 | Down key.Binding 39 | UpAlpha key.Binding 40 | DownAlpha key.Binding 41 | } 42 | 43 | var simpleListKeys = simpleListKeyMap{ 44 | Up: key.NewBinding( 45 | key.WithKeys("up"), 46 | key.WithHelp("↑", "previous list item"), 47 | ), 48 | Down: key.NewBinding( 49 | key.WithKeys("down"), 50 | key.WithHelp("↓", "next list item"), 51 | ), 52 | UpAlpha: key.NewBinding( 53 | key.WithKeys("k"), 54 | key.WithHelp("k", "previous list item"), 55 | ), 56 | DownAlpha: key.NewBinding( 57 | key.WithKeys("j"), 58 | key.WithHelp("j", "next list item"), 59 | ), 60 | } 61 | 62 | func (c *simpleListCmp[T]) Init() tea.Cmd { 63 | return nil 64 | } 65 | 66 | func (c *simpleListCmp[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 67 | switch msg := msg.(type) { 68 | case tea.KeyMsg: 69 | switch { 70 | case key.Matches(msg, simpleListKeys.Up) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.UpAlpha)): 71 | if c.selectedIdx > 0 { 72 | c.selectedIdx-- 73 | } 74 | return c, nil 75 | case key.Matches(msg, simpleListKeys.Down) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.DownAlpha)): 76 | if c.selectedIdx < len(c.items)-1 { 77 | c.selectedIdx++ 78 | } 79 | return c, nil 80 | } 81 | } 82 | 83 | return c, nil 84 | } 85 | 86 | func (c *simpleListCmp[T]) BindingKeys() []key.Binding { 87 | return layout.KeyMapToSlice(simpleListKeys) 88 | } 89 | 90 | func (c *simpleListCmp[T]) GetSelectedItem() (T, int) { 91 | if len(c.items) > 0 { 92 | return c.items[c.selectedIdx], c.selectedIdx 93 | } 94 | 95 | var zero T 96 | return zero, -1 97 | } 98 | 99 | func (c *simpleListCmp[T]) SetItems(items []T) { 100 | c.selectedIdx = 0 101 | c.items = items 102 | } 103 | 104 | func (c *simpleListCmp[T]) GetItems() []T { 105 | return c.items 106 | } 107 | 108 | func (c *simpleListCmp[T]) SetMaxWidth(width int) { 109 | c.maxWidth = width 110 | } 111 | 112 | func (c *simpleListCmp[T]) View() string { 113 | t := theme.CurrentTheme() 114 | baseStyle := styles.BaseStyle() 115 | 116 | items := c.items 117 | maxWidth := c.maxWidth 118 | maxVisibleItems := min(c.maxVisibleItems, len(items)) 119 | startIdx := 0 120 | 121 | if len(items) <= 0 { 122 | return baseStyle. 123 | Background(t.Background()). 124 | Padding(0, 1). 125 | Width(maxWidth). 126 | Render(c.fallbackMsg) 127 | } 128 | 129 | if len(items) > maxVisibleItems { 130 | halfVisible := maxVisibleItems / 2 131 | if c.selectedIdx >= halfVisible && c.selectedIdx < len(items)-halfVisible { 132 | startIdx = c.selectedIdx - halfVisible 133 | } else if c.selectedIdx >= len(items)-halfVisible { 134 | startIdx = len(items) - maxVisibleItems 135 | } 136 | } 137 | 138 | endIdx := min(startIdx+maxVisibleItems, len(items)) 139 | 140 | listItems := make([]string, 0, maxVisibleItems) 141 | 142 | for i := startIdx; i < endIdx; i++ { 143 | item := items[i] 144 | title := item.Render(i == c.selectedIdx, maxWidth) 145 | listItems = append(listItems, title) 146 | } 147 | 148 | return lipgloss.JoinVertical(lipgloss.Left, listItems...) 149 | } 150 | 151 | func NewSimpleList[T SimpleListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) SimpleList[T] { 152 | return &simpleListCmp[T]{ 153 | fallbackMsg: fallbackMsg, 154 | items: items, 155 | maxVisibleItems: maxVisibleItems, 156 | useAlphaNumericKeys: useAlphaNumericKeys, 157 | selectedIdx: 0, 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /internal/tui/image/clipboard_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package image 4 | 5 | import ( 6 | "bytes" 7 | "fmt" 8 | "image" 9 | "github.com/atotto/clipboard" 10 | ) 11 | 12 | func GetImageFromClipboard() ([]byte, string, error) { 13 | text, err := clipboard.ReadAll() 14 | if err != nil { 15 | return nil, "", fmt.Errorf("Error reading clipboard") 16 | } 17 | 18 | if text == "" { 19 | return nil, "", nil 20 | } 21 | 22 | binaryData := []byte(text) 23 | imageBytes, err := binaryToImage(binaryData) 24 | if err != nil { 25 | return nil, text, nil 26 | } 27 | return imageBytes, "", nil 28 | 29 | } 30 | 31 | 32 | 33 | func binaryToImage(data []byte) ([]byte, error) { 34 | reader := bytes.NewReader(data) 35 | img, _, err := image.Decode(reader) 36 | if err != nil { 37 | return nil, fmt.Errorf("Unable to covert bytes to image") 38 | } 39 | 40 | return ImageToBytes(img) 41 | } 42 | 43 | 44 | func min(a, b int) int { 45 | if a < b { 46 | return a 47 | } 48 | return b 49 | } 50 | -------------------------------------------------------------------------------- /internal/tui/image/images.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "image" 7 | "image/png" 8 | "os" 9 | "strings" 10 | 11 | "github.com/charmbracelet/lipgloss" 12 | "github.com/disintegration/imaging" 13 | "github.com/lucasb-eyer/go-colorful" 14 | _ "golang.org/x/image/webp" 15 | ) 16 | 17 | func ValidateFileSize(filePath string, sizeLimit int64) (bool, error) { 18 | fileInfo, err := os.Stat(filePath) 19 | if err != nil { 20 | return false, fmt.Errorf("error getting file info: %w", err) 21 | } 22 | 23 | if fileInfo.Size() > sizeLimit { 24 | return true, nil 25 | } 26 | 27 | return false, nil 28 | } 29 | 30 | func ToString(width int, img image.Image) string { 31 | img = imaging.Resize(img, width, 0, imaging.Lanczos) 32 | b := img.Bounds() 33 | imageWidth := b.Max.X 34 | h := b.Max.Y 35 | str := strings.Builder{} 36 | 37 | for heightCounter := 0; heightCounter < h; heightCounter += 2 { 38 | for x := range imageWidth { 39 | c1, _ := colorful.MakeColor(img.At(x, heightCounter)) 40 | color1 := lipgloss.Color(c1.Hex()) 41 | 42 | var color2 lipgloss.Color 43 | if heightCounter+1 < h { 44 | c2, _ := colorful.MakeColor(img.At(x, heightCounter+1)) 45 | color2 = lipgloss.Color(c2.Hex()) 46 | } else { 47 | color2 = color1 48 | } 49 | 50 | str.WriteString(lipgloss.NewStyle().Foreground(color1). 51 | Background(color2).Render("▀")) 52 | } 53 | 54 | str.WriteString("\n") 55 | } 56 | 57 | return str.String() 58 | } 59 | 60 | func ImagePreview(width int, filename string) (string, error) { 61 | imageContent, err := os.Open(filename) 62 | if err != nil { 63 | return "", err 64 | } 65 | defer imageContent.Close() 66 | 67 | img, _, err := image.Decode(imageContent) 68 | if err != nil { 69 | return "", err 70 | } 71 | 72 | imageString := ToString(width, img) 73 | 74 | return imageString, nil 75 | } 76 | 77 | func ImageToBytes(image image.Image) ([]byte, error) { 78 | buf := new(bytes.Buffer) 79 | err := png.Encode(buf, image) 80 | if err != nil { 81 | return nil, err 82 | } 83 | 84 | return buf.Bytes(), nil 85 | } 86 | -------------------------------------------------------------------------------- /internal/tui/layout/layout.go: -------------------------------------------------------------------------------- 1 | package layout 2 | 3 | import ( 4 | "reflect" 5 | 6 | "github.com/charmbracelet/bubbles/key" 7 | tea "github.com/charmbracelet/bubbletea" 8 | ) 9 | 10 | type Focusable interface { 11 | Focus() tea.Cmd 12 | Blur() tea.Cmd 13 | IsFocused() bool 14 | } 15 | 16 | type Sizeable interface { 17 | SetSize(width, height int) tea.Cmd 18 | GetSize() (int, int) 19 | } 20 | 21 | type Bindings interface { 22 | BindingKeys() []key.Binding 23 | } 24 | 25 | func KeyMapToSlice(t any) (bindings []key.Binding) { 26 | typ := reflect.TypeOf(t) 27 | if typ.Kind() != reflect.Struct { 28 | return nil 29 | } 30 | for i := range typ.NumField() { 31 | v := reflect.ValueOf(t).Field(i) 32 | bindings = append(bindings, v.Interface().(key.Binding)) 33 | } 34 | return 35 | } 36 | -------------------------------------------------------------------------------- /internal/tui/layout/overlay.go: -------------------------------------------------------------------------------- 1 | package layout 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/charmbracelet/lipgloss" 7 | chAnsi "github.com/charmbracelet/x/ansi" 8 | "github.com/muesli/ansi" 9 | "github.com/muesli/reflow/truncate" 10 | "github.com/muesli/termenv" 11 | "github.com/sst/opencode/internal/tui/styles" 12 | "github.com/sst/opencode/internal/tui/theme" 13 | "github.com/sst/opencode/internal/tui/util" 14 | ) 15 | 16 | // Most of this code is borrowed from 17 | // https://github.com/charmbracelet/lipgloss/pull/102 18 | // as well as the lipgloss library, with some modification for what I needed. 19 | 20 | // Split a string into lines, additionally returning the size of the widest 21 | // line. 22 | func getLines(s string) (lines []string, widest int) { 23 | lines = strings.Split(s, "\n") 24 | 25 | for _, l := range lines { 26 | w := ansi.PrintableRuneWidth(l) 27 | if widest < w { 28 | widest = w 29 | } 30 | } 31 | 32 | return lines, widest 33 | } 34 | 35 | // PlaceOverlay places fg on top of bg. 36 | func PlaceOverlay( 37 | x, y int, 38 | fg, bg string, 39 | shadow bool, opts ...WhitespaceOption, 40 | ) string { 41 | fgLines, fgWidth := getLines(fg) 42 | bgLines, bgWidth := getLines(bg) 43 | bgHeight := len(bgLines) 44 | fgHeight := len(fgLines) 45 | 46 | if shadow { 47 | t := theme.CurrentTheme() 48 | baseStyle := styles.BaseStyle() 49 | 50 | var shadowbg string = "" 51 | shadowchar := lipgloss.NewStyle(). 52 | Background(t.BackgroundDarker()). 53 | Foreground(t.Background()). 54 | Render("░") 55 | bgchar := baseStyle.Render(" ") 56 | for i := 0; i <= fgHeight; i++ { 57 | if i == 0 { 58 | shadowbg += bgchar + strings.Repeat(bgchar, fgWidth) + "\n" 59 | } else { 60 | shadowbg += bgchar + strings.Repeat(shadowchar, fgWidth) + "\n" 61 | } 62 | } 63 | 64 | fg = PlaceOverlay(0, 0, fg, shadowbg, false, opts...) 65 | fgLines, fgWidth = getLines(fg) 66 | fgHeight = len(fgLines) 67 | } 68 | 69 | if fgWidth >= bgWidth && fgHeight >= bgHeight { 70 | // FIXME: return fg or bg? 71 | return fg 72 | } 73 | // TODO: allow placement outside of the bg box? 74 | x = util.Clamp(x, 0, bgWidth-fgWidth) 75 | y = util.Clamp(y, 0, bgHeight-fgHeight) 76 | 77 | ws := &whitespace{} 78 | for _, opt := range opts { 79 | opt(ws) 80 | } 81 | 82 | var b strings.Builder 83 | for i, bgLine := range bgLines { 84 | if i > 0 { 85 | b.WriteByte('\n') 86 | } 87 | if i < y || i >= y+fgHeight { 88 | b.WriteString(bgLine) 89 | continue 90 | } 91 | 92 | pos := 0 93 | if x > 0 { 94 | left := truncate.String(bgLine, uint(x)) 95 | pos = ansi.PrintableRuneWidth(left) 96 | b.WriteString(left) 97 | if pos < x { 98 | b.WriteString(ws.render(x - pos)) 99 | pos = x 100 | } 101 | } 102 | 103 | fgLine := fgLines[i-y] 104 | b.WriteString(fgLine) 105 | pos += ansi.PrintableRuneWidth(fgLine) 106 | 107 | right := cutLeft(bgLine, pos) 108 | bgWidth := ansi.PrintableRuneWidth(bgLine) 109 | rightWidth := ansi.PrintableRuneWidth(right) 110 | if rightWidth <= bgWidth-pos { 111 | b.WriteString(ws.render(bgWidth - rightWidth - pos)) 112 | } 113 | 114 | b.WriteString(right) 115 | } 116 | 117 | return b.String() 118 | } 119 | 120 | // cutLeft cuts printable characters from the left. 121 | // This function is heavily based on muesli's ansi and truncate packages. 122 | func cutLeft(s string, cutWidth int) string { 123 | return chAnsi.Cut(s, cutWidth, lipgloss.Width(s)) 124 | } 125 | 126 | func max(a, b int) int { 127 | if a > b { 128 | return a 129 | } 130 | return b 131 | } 132 | 133 | type whitespace struct { 134 | style termenv.Style 135 | chars string 136 | } 137 | 138 | // Render whitespaces. 139 | func (w whitespace) render(width int) string { 140 | if w.chars == "" { 141 | w.chars = " " 142 | } 143 | 144 | r := []rune(w.chars) 145 | j := 0 146 | b := strings.Builder{} 147 | 148 | // Cycle through runes and print them into the whitespace. 149 | for i := 0; i < width; { 150 | b.WriteRune(r[j]) 151 | j++ 152 | if j >= len(r) { 153 | j = 0 154 | } 155 | i += ansi.PrintableRuneWidth(string(r[j])) 156 | } 157 | 158 | // Fill any extra gaps white spaces. This might be necessary if any runes 159 | // are more than one cell wide, which could leave a one-rune gap. 160 | short := width - ansi.PrintableRuneWidth(b.String()) 161 | if short > 0 { 162 | b.WriteString(strings.Repeat(" ", short)) 163 | } 164 | 165 | return w.style.Styled(b.String()) 166 | } 167 | 168 | // WhitespaceOption sets a styling rule for rendering whitespace. 169 | type WhitespaceOption func(*whitespace) 170 | -------------------------------------------------------------------------------- /internal/tui/page/page.go: -------------------------------------------------------------------------------- 1 | package page 2 | 3 | type PageID string 4 | 5 | // PageChangeMsg is used to change the current page 6 | type PageChangeMsg struct { 7 | ID PageID 8 | } 9 | -------------------------------------------------------------------------------- /internal/tui/state/state.go: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | import "github.com/sst/opencode/internal/session" 4 | 5 | type SessionSelectedMsg = *session.Session 6 | type SessionClearedMsg struct{} 7 | type CompactSessionMsg struct{} 8 | -------------------------------------------------------------------------------- /internal/tui/styles/background.go: -------------------------------------------------------------------------------- 1 | package styles 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | 8 | "github.com/charmbracelet/lipgloss" 9 | ) 10 | 11 | var ansiEscape = regexp.MustCompile("\x1b\\[[0-9;]*m") 12 | 13 | func getColorRGB(c lipgloss.TerminalColor) (uint8, uint8, uint8) { 14 | r, g, b, a := c.RGBA() 15 | 16 | // Un-premultiply alpha if needed 17 | if a > 0 && a < 0xffff { 18 | r = (r * 0xffff) / a 19 | g = (g * 0xffff) / a 20 | b = (b * 0xffff) / a 21 | } 22 | 23 | // Convert from 16-bit to 8-bit color 24 | return uint8(r >> 8), uint8(g >> 8), uint8(b >> 8) 25 | } 26 | 27 | // ForceReplaceBackgroundWithLipgloss replaces any ANSI background color codes 28 | // in `input` with a single 24‑bit background (48;2;R;G;B). 29 | func ForceReplaceBackgroundWithLipgloss(input string, newBgColor lipgloss.TerminalColor) string { 30 | // Precompute our new-bg sequence once 31 | r, g, b := getColorRGB(newBgColor) 32 | newBg := fmt.Sprintf("48;2;%d;%d;%d", r, g, b) 33 | 34 | return ansiEscape.ReplaceAllStringFunc(input, func(seq string) string { 35 | const ( 36 | escPrefixLen = 2 // "\x1b[" 37 | escSuffixLen = 1 // "m" 38 | ) 39 | 40 | raw := seq 41 | start := escPrefixLen 42 | end := len(raw) - escSuffixLen 43 | 44 | var sb strings.Builder 45 | // reserve enough space: original content minus bg codes + our newBg 46 | sb.Grow((end - start) + len(newBg) + 2) 47 | 48 | // scan from start..end, token by token 49 | for i := start; i < end; { 50 | // find the next ';' or end 51 | j := i 52 | for j < end && raw[j] != ';' { 53 | j++ 54 | } 55 | token := raw[i:j] 56 | 57 | // fast‑path: skip "48;5;N" or "48;2;R;G;B" 58 | if len(token) == 2 && token[0] == '4' && token[1] == '8' { 59 | k := j + 1 60 | if k < end { 61 | // find next token 62 | l := k 63 | for l < end && raw[l] != ';' { 64 | l++ 65 | } 66 | next := raw[k:l] 67 | if next == "5" { 68 | // skip "48;5;N" 69 | m := l + 1 70 | for m < end && raw[m] != ';' { 71 | m++ 72 | } 73 | i = m + 1 74 | continue 75 | } else if next == "2" { 76 | // skip "48;2;R;G;B" 77 | m := l + 1 78 | for count := 0; count < 3 && m < end; count++ { 79 | for m < end && raw[m] != ';' { 80 | m++ 81 | } 82 | m++ 83 | } 84 | i = m 85 | continue 86 | } 87 | } 88 | } 89 | 90 | // decide whether to keep this token 91 | // manually parse ASCII digits to int 92 | isNum := true 93 | val := 0 94 | for p := i; p < j; p++ { 95 | c := raw[p] 96 | if c < '0' || c > '9' { 97 | isNum = false 98 | break 99 | } 100 | val = val*10 + int(c-'0') 101 | } 102 | keep := !isNum || 103 | ((val < 40 || val > 47) && (val < 100 || val > 107) && val != 49) 104 | 105 | if keep { 106 | if sb.Len() > 0 { 107 | sb.WriteByte(';') 108 | } 109 | sb.WriteString(token) 110 | } 111 | // advance past this token (and the semicolon) 112 | i = j + 1 113 | } 114 | 115 | // append our new background 116 | if sb.Len() > 0 { 117 | sb.WriteByte(';') 118 | } 119 | sb.WriteString(newBg) 120 | 121 | return "\x1b[" + sb.String() + "m" 122 | }) 123 | } 124 | -------------------------------------------------------------------------------- /internal/tui/styles/icons.go: -------------------------------------------------------------------------------- 1 | package styles 2 | 3 | const ( 4 | OpenCodeIcon string = "ⓒ" 5 | 6 | ErrorIcon string = "ⓔ" 7 | WarningIcon string = "ⓦ" 8 | InfoIcon string = "ⓘ" 9 | HintIcon string = "ⓗ" 10 | SpinnerIcon string = "⟳" 11 | DocumentIcon string = "🖼" 12 | ) 13 | -------------------------------------------------------------------------------- /internal/tui/styles/styles.go: -------------------------------------------------------------------------------- 1 | package styles 2 | 3 | import ( 4 | "github.com/charmbracelet/lipgloss" 5 | "github.com/sst/opencode/internal/tui/theme" 6 | ) 7 | 8 | var ( 9 | ImageBakcground = "#212121" 10 | ) 11 | 12 | // Style generation functions that use the current theme 13 | 14 | // BaseStyle returns the base style with background and foreground colors 15 | func BaseStyle() lipgloss.Style { 16 | t := theme.CurrentTheme() 17 | return lipgloss.NewStyle(). 18 | Background(t.Background()). 19 | Foreground(t.Text()) 20 | } 21 | 22 | // Regular returns a basic unstyled lipgloss.Style 23 | func Regular() lipgloss.Style { 24 | return lipgloss.NewStyle() 25 | } 26 | 27 | func Muted() lipgloss.Style { 28 | return lipgloss.NewStyle().Foreground(theme.CurrentTheme().TextMuted()) 29 | } 30 | 31 | // Bold returns a bold style 32 | func Bold() lipgloss.Style { 33 | return Regular().Bold(true) 34 | } 35 | 36 | // Padded returns a style with horizontal padding 37 | func Padded() lipgloss.Style { 38 | return Regular().Padding(0, 1) 39 | } 40 | 41 | // Border returns a style with a normal border 42 | func Border() lipgloss.Style { 43 | t := theme.CurrentTheme() 44 | return Regular(). 45 | Border(lipgloss.NormalBorder()). 46 | BorderForeground(t.BorderNormal()) 47 | } 48 | 49 | // ThickBorder returns a style with a thick border 50 | func ThickBorder() lipgloss.Style { 51 | t := theme.CurrentTheme() 52 | return Regular(). 53 | Border(lipgloss.ThickBorder()). 54 | BorderForeground(t.BorderNormal()) 55 | } 56 | 57 | // DoubleBorder returns a style with a double border 58 | func DoubleBorder() lipgloss.Style { 59 | t := theme.CurrentTheme() 60 | return Regular(). 61 | Border(lipgloss.DoubleBorder()). 62 | BorderForeground(t.BorderNormal()) 63 | } 64 | 65 | // FocusedBorder returns a style with a border using the focused border color 66 | func FocusedBorder() lipgloss.Style { 67 | t := theme.CurrentTheme() 68 | return Regular(). 69 | Border(lipgloss.NormalBorder()). 70 | BorderForeground(t.BorderFocused()) 71 | } 72 | 73 | // DimBorder returns a style with a border using the dim border color 74 | func DimBorder() lipgloss.Style { 75 | t := theme.CurrentTheme() 76 | return Regular(). 77 | Border(lipgloss.NormalBorder()). 78 | BorderForeground(t.BorderDim()) 79 | } 80 | 81 | // PrimaryColor returns the primary color from the current theme 82 | func PrimaryColor() lipgloss.AdaptiveColor { 83 | return theme.CurrentTheme().Primary() 84 | } 85 | 86 | // SecondaryColor returns the secondary color from the current theme 87 | func SecondaryColor() lipgloss.AdaptiveColor { 88 | return theme.CurrentTheme().Secondary() 89 | } 90 | 91 | // AccentColor returns the accent color from the current theme 92 | func AccentColor() lipgloss.AdaptiveColor { 93 | return theme.CurrentTheme().Accent() 94 | } 95 | 96 | // ErrorColor returns the error color from the current theme 97 | func ErrorColor() lipgloss.AdaptiveColor { 98 | return theme.CurrentTheme().Error() 99 | } 100 | 101 | // WarningColor returns the warning color from the current theme 102 | func WarningColor() lipgloss.AdaptiveColor { 103 | return theme.CurrentTheme().Warning() 104 | } 105 | 106 | // SuccessColor returns the success color from the current theme 107 | func SuccessColor() lipgloss.AdaptiveColor { 108 | return theme.CurrentTheme().Success() 109 | } 110 | 111 | // InfoColor returns the info color from the current theme 112 | func InfoColor() lipgloss.AdaptiveColor { 113 | return theme.CurrentTheme().Info() 114 | } 115 | 116 | // TextColor returns the text color from the current theme 117 | func TextColor() lipgloss.AdaptiveColor { 118 | return theme.CurrentTheme().Text() 119 | } 120 | 121 | // TextMutedColor returns the muted text color from the current theme 122 | func TextMutedColor() lipgloss.AdaptiveColor { 123 | return theme.CurrentTheme().TextMuted() 124 | } 125 | 126 | // TextEmphasizedColor returns the emphasized text color from the current theme 127 | func TextEmphasizedColor() lipgloss.AdaptiveColor { 128 | return theme.CurrentTheme().TextEmphasized() 129 | } 130 | 131 | // BackgroundColor returns the background color from the current theme 132 | func BackgroundColor() lipgloss.AdaptiveColor { 133 | return theme.CurrentTheme().Background() 134 | } 135 | 136 | // BackgroundSecondaryColor returns the secondary background color from the current theme 137 | func BackgroundSecondaryColor() lipgloss.AdaptiveColor { 138 | return theme.CurrentTheme().BackgroundSecondary() 139 | } 140 | 141 | // BackgroundDarkerColor returns the darker background color from the current theme 142 | func BackgroundDarkerColor() lipgloss.AdaptiveColor { 143 | return theme.CurrentTheme().BackgroundDarker() 144 | } 145 | 146 | // BorderNormalColor returns the normal border color from the current theme 147 | func BorderNormalColor() lipgloss.AdaptiveColor { 148 | return theme.CurrentTheme().BorderNormal() 149 | } 150 | 151 | // BorderFocusedColor returns the focused border color from the current theme 152 | func BorderFocusedColor() lipgloss.AdaptiveColor { 153 | return theme.CurrentTheme().BorderFocused() 154 | } 155 | 156 | // BorderDimColor returns the dim border color from the current theme 157 | func BorderDimColor() lipgloss.AdaptiveColor { 158 | return theme.CurrentTheme().BorderDim() 159 | } 160 | -------------------------------------------------------------------------------- /internal/tui/theme/theme_test.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestThemeRegistration(t *testing.T) { 8 | // Get list of available themes 9 | availableThemes := AvailableThemes() 10 | 11 | // Check if "catppuccin" theme is registered 12 | catppuccinFound := false 13 | for _, themeName := range availableThemes { 14 | if themeName == "catppuccin" { 15 | catppuccinFound = true 16 | break 17 | } 18 | } 19 | 20 | if !catppuccinFound { 21 | t.Errorf("Catppuccin theme is not registered") 22 | } 23 | 24 | // Check if "gruvbox" theme is registered 25 | gruvboxFound := false 26 | for _, themeName := range availableThemes { 27 | if themeName == "gruvbox" { 28 | gruvboxFound = true 29 | break 30 | } 31 | } 32 | 33 | if !gruvboxFound { 34 | t.Errorf("Gruvbox theme is not registered") 35 | } 36 | 37 | // Check if "monokai" theme is registered 38 | monokaiFound := false 39 | for _, themeName := range availableThemes { 40 | if themeName == "monokai" { 41 | monokaiFound = true 42 | break 43 | } 44 | } 45 | 46 | if !monokaiFound { 47 | t.Errorf("Monokai theme is not registered") 48 | } 49 | 50 | // Check if "nord" theme is registered 51 | nordFound := false 52 | for _, themeName := range availableThemes { 53 | if themeName == "nord" { 54 | nordFound = true 55 | break 56 | } 57 | } 58 | if !nordFound { 59 | t.Errorf("Nord theme is not registered") 60 | } 61 | 62 | // Try to get the themes and make sure they're not nil 63 | catppuccin := GetTheme("catppuccin") 64 | if catppuccin == nil { 65 | t.Errorf("Catppuccin theme is nil") 66 | } 67 | 68 | gruvbox := GetTheme("gruvbox") 69 | if gruvbox == nil { 70 | t.Errorf("Gruvbox theme is nil") 71 | } 72 | 73 | monokai := GetTheme("monokai") 74 | if monokai == nil { 75 | t.Errorf("Monokai theme is nil") 76 | } 77 | 78 | // Test switching theme 79 | originalTheme := CurrentThemeName() 80 | 81 | err := SetTheme("gruvbox") 82 | if err != nil { 83 | t.Errorf("Failed to set theme to gruvbox: %v", err) 84 | } 85 | 86 | if CurrentThemeName() != "gruvbox" { 87 | t.Errorf("Theme not properly switched to gruvbox") 88 | } 89 | 90 | err = SetTheme("monokai") 91 | if err != nil { 92 | t.Errorf("Failed to set theme to monokai: %v", err) 93 | } 94 | 95 | if CurrentThemeName() != "monokai" { 96 | t.Errorf("Theme not properly switched to monokai") 97 | } 98 | 99 | // Switch back to original theme 100 | _ = SetTheme(originalTheme) 101 | } 102 | -------------------------------------------------------------------------------- /internal/tui/util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | ) 6 | 7 | func CmdHandler(msg tea.Msg) tea.Cmd { 8 | return func() tea.Msg { 9 | return msg 10 | } 11 | } 12 | 13 | func Clamp(v, low, high int) int { 14 | if high < low { 15 | low, high = high, low 16 | } 17 | return min(high, max(low, v)) 18 | } 19 | -------------------------------------------------------------------------------- /internal/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import "runtime/debug" 4 | 5 | // Build-time parameters set via -ldflags 6 | var Version = "unknown" 7 | 8 | // A user may install pug using `go install github.com/sst/opencode@latest`. 9 | // without -ldflags, in which case the version above is unset. As a workaround 10 | // we use the embedded build version that *is* set when using `go install` (and 11 | // is only set for `go install` and not for `go build`). 12 | func init() { 13 | info, ok := debug.ReadBuildInfo() 14 | if !ok { 15 | // < go v1.18 16 | return 17 | } 18 | mainVersion := info.Main.Version 19 | if mainVersion == "" || mainVersion == "(devel)" { 20 | // bin not built using `go install` 21 | return 22 | } 23 | // bin built using `go install` 24 | Version = mainVersion 25 | } 26 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/sst/opencode/cmd" 5 | "github.com/sst/opencode/internal/logging" 6 | "github.com/sst/opencode/internal/status" 7 | ) 8 | 9 | func main() { 10 | defer logging.RecoverPanic("main", func() { 11 | status.Error("Application terminated due to unhandled panic") 12 | }) 13 | 14 | cmd.Execute() 15 | } 16 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sst/opencode/c554430c59171e2dbec4b4d311e5f7d5a8107d0b/screenshot.png -------------------------------------------------------------------------------- /scripts/release: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Parse command line arguments 4 | minor=false 5 | while [ "$#" -gt 0 ]; do 6 | case "$1" in 7 | --minor) minor=true; shift 1;; 8 | *) echo "Unknown parameter: $1"; exit 1;; 9 | esac 10 | done 11 | 12 | git fetch --force --tags 13 | 14 | # Get the latest Git tag 15 | latest_tag=$(git tag --sort=committerdate | grep -E '[0-9]' | tail -1) 16 | 17 | # If there is no tag, exit the script 18 | if [ -z "$latest_tag" ]; then 19 | echo "No tags found" 20 | exit 1 21 | fi 22 | 23 | echo "Latest tag: $latest_tag" 24 | 25 | # Split the tag into major, minor, and patch numbers 26 | IFS='.' read -ra VERSION <<< "$latest_tag" 27 | 28 | if [ "$minor" = true ]; then 29 | # Increment the minor version and reset patch to 0 30 | minor_number=${VERSION[1]} 31 | let "minor_number++" 32 | new_version="${VERSION[0]}.$minor_number.0" 33 | else 34 | # Increment the patch version 35 | patch_number=${VERSION[2]} 36 | let "patch_number++" 37 | new_version="${VERSION[0]}.${VERSION[1]}.$patch_number" 38 | fi 39 | 40 | echo "New version: $new_version" 41 | 42 | git tag $new_version 43 | git push --tags 44 | -------------------------------------------------------------------------------- /scripts/snapshot: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | goreleaser build --clean --snapshot --skip validate 4 | -------------------------------------------------------------------------------- /sqlc.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | sql: 3 | - engine: "sqlite" 4 | schema: "internal/db/migrations" 5 | queries: "internal/db/sql" 6 | gen: 7 | go: 8 | package: "db" 9 | out: "internal/db" 10 | emit_json_tags: true 11 | emit_prepared_queries: true 12 | emit_interface: true 13 | emit_exact_table_names: false 14 | emit_empty_slices: true 15 | -------------------------------------------------------------------------------- /www/.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | # generated types 4 | .astro/ 5 | 6 | # dependencies 7 | node_modules/ 8 | 9 | # logs 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | 16 | # environment variables 17 | .env 18 | .env.production 19 | 20 | # macOS-specific files 21 | .DS_Store 22 | -------------------------------------------------------------------------------- /www/README.md: -------------------------------------------------------------------------------- 1 | # Starlight Starter Kit: Basics 2 | 3 | [![Built with Starlight](https://astro.badg.es/v2/built-with-starlight/tiny.svg)](https://starlight.astro.build) 4 | 5 | ``` 6 | npm create astro@latest -- --template starlight 7 | ``` 8 | 9 | [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/starlight/tree/main/examples/basics) 10 | [![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/starlight/tree/main/examples/basics) 11 | [![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/withastro/starlight&create_from_path=examples/basics) 12 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fwithastro%2Fstarlight%2Ftree%2Fmain%2Fexamples%2Fbasics&project-name=my-starlight-docs&repository-name=my-starlight-docs) 13 | 14 | > 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun! 15 | 16 | ## 🚀 Project Structure 17 | 18 | Inside of your Astro + Starlight project, you'll see the following folders and files: 19 | 20 | ``` 21 | . 22 | ├── public/ 23 | ├── src/ 24 | │ ├── assets/ 25 | │ ├── content/ 26 | │ │ ├── docs/ 27 | │ └── content.config.ts 28 | ├── astro.config.mjs 29 | ├── package.json 30 | └── tsconfig.json 31 | ``` 32 | 33 | Starlight looks for `.md` or `.mdx` files in the `src/content/docs/` directory. Each file is exposed as a route based on its file name. 34 | 35 | Images can be added to `src/assets/` and embedded in Markdown with a relative link. 36 | 37 | Static assets, like favicons, can be placed in the `public/` directory. 38 | 39 | ## 🧞 Commands 40 | 41 | All commands are run from the root of the project, from a terminal: 42 | 43 | | Command | Action | 44 | | :------------------------ | :----------------------------------------------- | 45 | | `npm install` | Installs dependencies | 46 | | `npm run dev` | Starts local dev server at `localhost:4321` | 47 | | `npm run build` | Build your production site to `./dist/` | 48 | | `npm run preview` | Preview your build locally, before deploying | 49 | | `npm run astro ...` | Run CLI commands like `astro add`, `astro check` | 50 | | `npm run astro -- --help` | Get help using the Astro CLI | 51 | 52 | ## 👀 Want to learn more? 53 | 54 | Check out [Starlight’s docs](https://starlight.astro.build/), read [the Astro documentation](https://docs.astro.build), or jump into the [Astro Discord server](https://astro.build/chat). 55 | -------------------------------------------------------------------------------- /www/astro.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { defineConfig } from "astro/config"; 3 | import starlight from "@astrojs/starlight"; 4 | import theme from "toolbeam-docs-theme"; 5 | import { rehypeHeadingIds } from "@astrojs/markdown-remark"; 6 | import rehypeAutolinkHeadings from "rehype-autolink-headings"; 7 | 8 | const discord = "https://discord.gg/sst"; 9 | const github = "https://github.com/sst/opencode"; 10 | 11 | // https://astro.build/config 12 | export default defineConfig({ 13 | devToolbar: { 14 | enabled: false, 15 | }, 16 | markdown: { 17 | rehypePlugins: [ 18 | rehypeHeadingIds, 19 | [rehypeAutolinkHeadings, { behavior: "wrap" }], 20 | ], 21 | }, 22 | integrations: [ 23 | starlight({ 24 | title: "OpenCode", 25 | social: [ 26 | { icon: "discord", label: "Discord", href: discord }, 27 | { icon: "github", label: "GitHub", href: github }, 28 | ], 29 | editLink: { 30 | baseUrl: `${github}/edit/master/www/`, 31 | }, 32 | markdown: { 33 | headingLinks: false, 34 | }, 35 | logo: { 36 | light: "./src/assets/logo-light.svg", 37 | dark: "./src/assets/logo-dark.svg", 38 | replacesTitle: true, 39 | }, 40 | sidebar: [ 41 | "docs", 42 | "docs/cli", 43 | "docs/config", 44 | "docs/models", 45 | "docs/themes", 46 | "docs/shortcuts", 47 | "docs/lsp-servers", 48 | "docs/mcp-servers", 49 | ], 50 | components: { 51 | Hero: "./src/components/Hero.astro", 52 | }, 53 | plugins: [theme({ 54 | // Optionally, add your own header links 55 | headerLinks: [ 56 | { name: "Home", url: "/" }, 57 | { name: "Docs", url: "/docs/" }, 58 | ], 59 | })], 60 | }), 61 | ], 62 | }); 63 | -------------------------------------------------------------------------------- /www/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "www", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "scripts": { 6 | "dev": "astro dev", 7 | "start": "astro dev", 8 | "build": "astro build", 9 | "preview": "astro preview", 10 | "astro": "astro" 11 | }, 12 | "dependencies": { 13 | "@astrojs/markdown-remark": "^6.3.1", 14 | "@astrojs/starlight": "^0.34.3", 15 | "@fontsource/ibm-plex-mono": "^5.2.5", 16 | "astro": "^5.7.13", 17 | "rehype-autolink-headings": "^7.1.0", 18 | "sharp": "^0.32.5", 19 | "toolbeam-docs-theme": "^0.2.3" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /www/public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /www/public/social-share.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sst/opencode/c554430c59171e2dbec4b4d311e5f7d5a8107d0b/www/public/social-share.png -------------------------------------------------------------------------------- /www/src/assets/lander/check.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /www/src/assets/lander/copy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /www/src/components/Hero.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Default from '@astrojs/starlight/components/Hero.astro'; 3 | import Lander from './Lander.astro'; 4 | 5 | const { slug } = Astro.locals.starlightRoute.entry; 6 | --- 7 | 8 | { slug === "" 9 | ? 10 | : 11 | } 12 | -------------------------------------------------------------------------------- /www/src/content.config.ts: -------------------------------------------------------------------------------- 1 | import { defineCollection } from 'astro:content'; 2 | import { docsLoader } from '@astrojs/starlight/loaders'; 3 | import { docsSchema } from '@astrojs/starlight/schema'; 4 | 5 | export const collections = { 6 | docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }), 7 | }; 8 | -------------------------------------------------------------------------------- /www/src/content/docs/docs/cli.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: CLI 3 | --- 4 | 5 | Once installed you can run the OpenCode CLI. 6 | 7 | ```bash 8 | opencode 9 | ``` 10 | 11 | Or pass in flags. For example, to start with debug logging: 12 | 13 | ```bash 14 | opencode -d 15 | ``` 16 | 17 | Or start with a specific working directory. 18 | 19 | ```bash 20 | opencode -c /path/to/project 21 | ``` 22 | 23 | ## Flags 24 | 25 | The OpenCode CLI takes the following flags. 26 | 27 | | Flag | Short | Description | 28 | | -- | -- | -- | 29 | | `--help` | `-h` | Display help | 30 | | `--debug` | `-d` | Enable debug mode | 31 | | `--cwd` | `-c` | Set current working directory | 32 | | `--prompt` | `-p` | Run a single prompt in non-interactive mode | 33 | | `--output-format` | `-f` | Output format for non-interactive mode, `text` or `json` | 34 | | `--quiet` | `-q` | Hide spinner in non-interactive mode | 35 | | `--verbose` | | Display logs to stderr in non-interactive mode | 36 | | `--allowedTools` | | Restrict the agent to only use specified tools | 37 | | `--excludedTools` | | Prevent the agent from using specified tools | 38 | 39 | ## Non-interactive 40 | 41 | By default, OpenCode runs in interactive mode. 42 | 43 | But you can also run OpenCode in non-interactive mode by passing a prompt directly as a command-line argument. This is useful for scripting, automation, or when you want a quick answer without launching the full TUI. 44 | 45 | For example, to run a single prompt use the `-p` flag. 46 | 47 | ```bash "-p" 48 | opencode -p "Explain the use of context in Go" 49 | ``` 50 | 51 | If you want to run without showing the spinner, use `-q`. 52 | 53 | ```bash "-q" 54 | opencode -p "Explain the use of context in Go" -q 55 | ``` 56 | 57 | In this mode, OpenCode will process your prompt, print the result to standard output, and then exit. All **permissions are auto-approved** for the session. 58 | 59 | #### Tool restrictions 60 | 61 | You can control which tools the AI assistant has access to in non-interactive mode. 62 | 63 | - `--allowedTools` 64 | 65 | A comma-separated list of tools that the agent is allowed to use. Only these tools will be available. 66 | 67 | ```bash "--allowedTools" 68 | opencode -p "Explain the use of context in Go" --allowedTools=view,ls,glob 69 | ``` 70 | 71 | - `--excludedTools` 72 | 73 | Comma-separated list of tools that the agent is not allowed to use. All other tools will be available. 74 | 75 | ```bash "--excludedTools" 76 | opencode -p "Explain the use of context in Go" --excludedTools=bash,edit 77 | ``` 78 | 79 | These flags are mutually exclusive. So you can either use `--allowedTools` or `--excludedTools`, but not both. 80 | 81 | #### Output formats 82 | 83 | In non-interactive mode, you can also set the CLI to return as JSON using `-f`. 84 | 85 | ```bash "-f json" 86 | opencode -p "Explain the use of context in Go" -f json 87 | ``` 88 | 89 | By default, this is set to `text`, to return plain text. 90 | -------------------------------------------------------------------------------- /www/src/content/docs/docs/config.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Config 3 | --- 4 | 5 | You can configure OpenCode using the OpenCode config. It can be places in: 6 | 7 | - `$HOME/.opencode.json` 8 | - `$XDG_CONFIG_HOME/opencode/.opencode.json` 9 | 10 | Or in the current directory, `./.opencode.json`. 11 | 12 | ## OpenCode config 13 | 14 | The config file has the following structure. 15 | 16 | ```json title=".opencode.json" 17 | { 18 | "data": { 19 | "directory": ".opencode" 20 | }, 21 | "providers": { 22 | "openai": { 23 | "apiKey": "your-api-key", 24 | "disabled": false 25 | }, 26 | "anthropic": { 27 | "apiKey": "your-api-key", 28 | "disabled": false 29 | }, 30 | "groq": { 31 | "apiKey": "your-api-key", 32 | "disabled": false 33 | }, 34 | "openrouter": { 35 | "apiKey": "your-api-key", 36 | "disabled": false 37 | } 38 | }, 39 | "agents": { 40 | "primary": { 41 | "model": "claude-3.7-sonnet", 42 | "maxTokens": 5000 43 | }, 44 | "task": { 45 | "model": "claude-3.7-sonnet", 46 | "maxTokens": 5000 47 | }, 48 | "title": { 49 | "model": "claude-3.7-sonnet", 50 | "maxTokens": 80 51 | } 52 | }, 53 | "mcpServers": { 54 | "example": { 55 | "type": "stdio", 56 | "command": "path/to/mcp-server", 57 | "env": [], 58 | "args": [] 59 | } 60 | }, 61 | "lsp": { 62 | "go": { 63 | "disabled": false, 64 | "command": "gopls" 65 | } 66 | }, 67 | "debug": false, 68 | "debugLSP": false 69 | } 70 | ``` 71 | 72 | ## Environment variables 73 | 74 | For the providers, you can also specify the keys using environment variables. 75 | 76 | | Environment Variable | Models | 77 | | -------------------------- | ----------- | 78 | | `ANTHROPIC_API_KEY` | Claude | 79 | | `OPENAI_API_KEY` | OpenAI | 80 | | `GEMINI_API_KEY` | Google Gemini | 81 | | `GROQ_API_KEY` | Groq | 82 | | `AWS_ACCESS_KEY_ID` | Amazon Bedrock | 83 | | `AWS_SECRET_ACCESS_KEY` | Amazon Bedrock | 84 | | `AWS_REGION` | Amazon Bedrock | 85 | | `AZURE_OPENAI_ENDPOINT` | Azure OpenAI | 86 | | `AZURE_OPENAI_API_KEY` | Azure OpenAI, optional when using Entra ID | 87 | | `AZURE_OPENAI_API_VERSION` | Azure OpenAI | 88 | 89 | -------------------------------------------------------------------------------- /www/src/content/docs/docs/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Intro 3 | --- 4 | 5 | OpenCode is an AI coding agent built natively for the terminal. It features: 6 | 7 | - Native TUI for a smoother, snappier experience 8 | - Uses LSPs to help the LLM make fewer mistakes 9 | - Opening multiple conversations with the same project 10 | - Use of any model through the AI SDK 11 | - Tracks and visualizes all the file changes 12 | - Editing longer messages with Vim 13 | 14 | ## Installation 15 | 16 | ```bash 17 | npm i -g opencode 18 | ``` 19 | 20 | If you don't have NPM installed, you can also install the OpenCode binary through the following. 21 | 22 | #### Using the install script 23 | 24 | ```bash 25 | curl -fsSL https://opencode.ai/install | bash 26 | ``` 27 | 28 | Or install a specific version. 29 | 30 | ```bash 31 | curl -fsSL https://opencode.ai/install | VERSION=0.1.0 bash 32 | ``` 33 | 34 | #### Using Homebrew on macOS and Linux 35 | 36 | ```bash 37 | brew install sst/tap/opencode 38 | ``` 39 | 40 | #### Using AUR in Arch Linux 41 | 42 | With yay. 43 | 44 | ```bash 45 | yay -S opencode-bin 46 | ``` 47 | 48 | Or with paru. 49 | 50 | ```bash 51 | paru -S opencode-bin 52 | ``` 53 | 54 | #### Using Go 55 | 56 | ```bash 57 | go install github.com/sst/opencode@latest 58 | ``` 59 | -------------------------------------------------------------------------------- /www/src/content/docs/docs/lsp-servers.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: LSP servers 3 | --- 4 | 5 | OpenCode integrates with _Language Server Protocol_, or LSP to improve how the LLM interacts with your codebase. 6 | 7 | LSP servers for different languages give the LLM: 8 | 9 | - **Diagnostics**: These include things like errors and lint warnings. So the LLM can generate code that has fewer mistakes without having to run the code. 10 | - **Quick actions**: The LSP can allow the LLM to better navigate the codebase through features like _go-to-definition_ and _find references_. 11 | 12 | ## Auto-detection 13 | 14 | By default, OpenCode will **automatically detect** the languages used in your project and add the right LSP servers. 15 | 16 | ## Manual configuration 17 | 18 | You can also manually configure LSP servers by adding them under the `lsp` section in your OpenCode config. 19 | 20 | ```json title=".opencode.json" 21 | { 22 | "lsp": { 23 | "go": { 24 | "disabled": false, 25 | "command": "gopls" 26 | }, 27 | "typescript": { 28 | "disabled": false, 29 | "command": "typescript-language-server", 30 | "args": ["--stdio"] 31 | } 32 | } 33 | } 34 | ``` 35 | -------------------------------------------------------------------------------- /www/src/content/docs/docs/mcp-servers.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: MCP servers 3 | --- 4 | 5 | You can add external tools to OpenCode using the _Model Context Protocol_, or MCP. OpenCode supports both: 6 | 7 | - Local servers that use standard input/output, `stdio` 8 | - Remote servers that use server-sent events `sse` 9 | 10 | ## Add MCP servers 11 | 12 | You can define MCP servers in your OpenCode config under the `mcpServers` section: 13 | 14 | ### Local 15 | 16 | To add a local or `stdio` MCP server. 17 | 18 | ```json title=".opencode.json" {4} 19 | { 20 | "mcpServers": { 21 | "local-example": { 22 | "type": "stdio", 23 | "command": "path/to/mcp-server", 24 | "env": [], 25 | "args": [] 26 | } 27 | } 28 | } 29 | ``` 30 | 31 | ### Remote 32 | 33 | To add a remote or `sse` MCP server. 34 | 35 | ```json title=".opencode.json" {4} 36 | { 37 | "mcpServers": { 38 | "remote-example": { 39 | "type": "sse", 40 | "url": "https://example.com/mcp", 41 | "headers": { 42 | "Authorization": "Bearer token" 43 | } 44 | } 45 | } 46 | } 47 | ``` 48 | 49 | ## Usage 50 | 51 | Once added, MCP tools are automatically available to the LLM alongside built-in tools. They follow the same permission model; requiring user approval before execution. 52 | -------------------------------------------------------------------------------- /www/src/content/docs/docs/models.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Models 3 | --- 4 | 5 | OpenCode uses the [AI SDK](https://ai-sdk.dev/) to have the support for **all the AI models**. 6 | 7 | Start by setting the [keys for the providers](/docs/config) you want to use in your OpenCode config. 8 | 9 | ## Model select 10 | 11 | You can now select the model you want from the menu by hitting `Ctrl+O`. 12 | 13 | ## Multiple models 14 | 15 | You can also use specific models for specific tasks. For example, you can use a smaller model to generate the title of the conversation or to run a sub task. 16 | 17 | ```json title=".opencode.json" 18 | { 19 | "agents": { 20 | "primary": { 21 | "model": "gpt-4", 22 | "maxTokens": 5000 23 | }, 24 | "task": { 25 | "model": "gpt-3.5-turbo", 26 | "maxTokens": 5000 27 | }, 28 | "title": { 29 | "model": "gpt-3.5-turbo", 30 | "maxTokens": 80 31 | } 32 | } 33 | } 34 | ``` 35 | -------------------------------------------------------------------------------- /www/src/content/docs/docs/shortcuts.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Keyboard shortcuts 3 | sidebar: 4 | label: Shortcuts 5 | --- 6 | 7 | Below are a list of keyboard shortcuts that OpenCode supports. 8 | 9 | ## Global 10 | 11 | | Shortcut | Action | 12 | | -------- | ------------------------------------------------------- | 13 | | `Ctrl+C` | Quit application | 14 | | `Ctrl+?` | Toggle help dialog | 15 | | `?` | Toggle help dialog (when not in editing mode) | 16 | | `Ctrl+L` | View logs | 17 | | `Ctrl+A` | Switch session | 18 | | `Ctrl+K` | Command dialog | 19 | | `Ctrl+O` | Toggle model selection dialog | 20 | | `Esc` | Close current overlay/dialog or return to previous mode | 21 | 22 | ## Chat pane 23 | 24 | | Shortcut | Action | 25 | | -------- | --------------------------------------- | 26 | | `Ctrl+N` | Create new session | 27 | | `Ctrl+X` | Cancel current operation/generation | 28 | | `i` | Focus editor (when not in writing mode) | 29 | | `Esc` | Exit writing mode and focus messages | 30 | 31 | ## Editor view 32 | 33 | | Shortcut | Action | 34 | | ------------------- | ----------------------------------------- | 35 | | `Ctrl+S` | Send message (when editor is focused) | 36 | | `Enter` or `Ctrl+S` | Send message (when editor is not focused) | 37 | | `Ctrl+E` | Open external editor | 38 | | `Esc` | Blur editor and focus messages | 39 | 40 | ## Session dialog 41 | 42 | | Shortcut | Action | 43 | | ---------- | ---------------- | 44 | | `↑` or `k` | Previous session | 45 | | `↓` or `j` | Next session | 46 | | `Enter` | Select session | 47 | | `Esc` | Close dialog | 48 | 49 | ## Model dialog 50 | 51 | | Shortcut | Action | 52 | | ---------- | ----------------- | 53 | | `↑` or `k` | Move up | 54 | | `↓` or `j` | Move down | 55 | | `←` or `h` | Previous provider | 56 | | `→` or `l` | Next provider | 57 | | `Esc` | Close dialog | 58 | 59 | ## Permission dialog 60 | 61 | | Shortcut | Action | 62 | | ----------------------- | ---------------------------- | 63 | | `←` or `left` | Switch options left | 64 | | `→` or `right` or `tab` | Switch options right | 65 | | `Enter` or `space` | Confirm selection | 66 | | `a` | Allow permission | 67 | | `A` | Allow permission for session | 68 | | `d` | Deny permission | 69 | -------------------------------------------------------------------------------- /www/src/content/docs/docs/themes.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Themes 3 | --- 4 | 5 | OpenCode supports most common terminal themes and you can create your own custom theme. 6 | 7 | ## Built-in themes 8 | 9 | The following predefined themes are available: 10 | 11 | - `opencode` 12 | - `catppuccin` 13 | - `dracula` 14 | - `flexoki` 15 | - `gruvbox` 16 | - `monokai` 17 | - `nord` 18 | - `onedark` 19 | - `tokyonight` 20 | - `tron` 21 | - `custom` 22 | 23 | Where `opencode` is the default theme and `custom` let's you define your own theme. 24 | 25 | ## Setting a theme 26 | 27 | You can set your theme in your OpenCode config. 28 | 29 | ```json title=".opencode.json" 30 | { 31 | "tui": { 32 | "theme": "monokai" 33 | } 34 | } 35 | ``` 36 | 37 | ## Create a theme 38 | 39 | You can create your own custom theme by setting the `theme: custom` and providing color definitions through the `customTheme`. 40 | 41 | ```json title=".opencode.json" 42 | { 43 | "tui": { 44 | "theme": "custom", 45 | "customTheme": { 46 | "primary": "#ffcc00", 47 | "secondary": "#00ccff", 48 | "accent": { "dark": "#aa00ff", "light": "#ddccff" }, 49 | "error": "#ff0000" 50 | } 51 | } 52 | } 53 | ``` 54 | 55 | #### Color keys 56 | 57 | You can define any of the following color keys in your `customTheme`. 58 | 59 | | Type | Color keys | 60 | | --- | --- | 61 | | Base colors | `primary`, `secondary`, `accent` | 62 | | Status colors | `error`, `warning`, `success`, `info` | 63 | | Text colors | `text`, `textMuted`, `textEmphasized` | 64 | | Background colors | `background`, `backgroundSecondary`, `backgroundDarker` | 65 | | Border colors | `borderNormal`, `borderFocused`, `borderDim` | 66 | | Diff view colors | `diffAdded`, `diffRemoved`, `diffContext`, etc. | 67 | 68 | You don't need to define all the color keys. Any undefined colors will fall back to the default `opencode` theme colors. 69 | 70 | #### Color definitions 71 | 72 | Color keys can take: 73 | 74 | 1. **Hex string**: A single hex color string, like `"#aabbcc"`, that'll be used for both light and dark terminal backgrounds. 75 | 76 | 2. **Light and dark colors**: An object with `dark` and `light` hex colors that'll be set based on the terminal's background. 77 | -------------------------------------------------------------------------------- /www/src/content/docs/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: OpenCode 3 | description: The AI coding agent built for the terminal. 4 | template: splash 5 | hero: 6 | title: The AI coding agent built for the terminal. 7 | tagline: The AI coding agent built for the terminal. 8 | image: 9 | dark: ../../assets/logo-dark.svg 10 | light: ../../assets/logo-light.svg 11 | alt: OpenCode logo 12 | --- 13 | -------------------------------------------------------------------------------- /www/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strict", 3 | "include": [".astro/types.d.ts", "**/*"], 4 | "exclude": ["dist"] 5 | } 6 | --------------------------------------------------------------------------------