├── .github └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── .goreleaser.yml ├── .opencode.json ├── LICENSE ├── README.md ├── cmd ├── root.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 │ ├── messages.sql.go │ ├── migrations │ │ ├── 20250424200609_initial.sql │ │ └── 20250515105448_add_summary_message_id.sql │ ├── models.go │ ├── querier.go │ ├── sessions.sql.go │ └── sql │ │ ├── files.sql │ │ ├── messages.sql │ │ └── sessions.sql ├── diff │ ├── diff.go │ └── patch.go ├── fileutil │ └── fileutil.go ├── format │ ├── format.go │ └── spinner.go ├── history │ └── file.go ├── llm │ ├── agent │ │ ├── agent-tool.go │ │ ├── agent.go │ │ ├── mcp-tools.go │ │ └── tools.go │ ├── models │ │ ├── anthropic.go │ │ ├── azure.go │ │ ├── gemini.go │ │ ├── groq.go │ │ ├── local.go │ │ ├── models.go │ │ ├── openai.go │ │ ├── openrouter.go │ │ ├── vertexai.go │ │ └── xai.go │ ├── prompt │ │ ├── coder.go │ │ ├── prompt.go │ │ ├── prompt_test.go │ │ ├── summarizer.go │ │ ├── task.go │ │ └── title.go │ ├── provider │ │ ├── anthropic.go │ │ ├── azure.go │ │ ├── bedrock.go │ │ ├── gemini.go │ │ ├── openai.go │ │ ├── provider.go │ │ └── vertexai.go │ └── tools │ │ ├── bash.go │ │ ├── diagnostics.go │ │ ├── edit.go │ │ ├── fetch.go │ │ ├── file.go │ │ ├── glob.go │ │ ├── grep.go │ │ ├── ls.go │ │ ├── ls_test.go │ │ ├── patch.go │ │ ├── shell │ │ └── shell.go │ │ ├── sourcegraph.go │ │ ├── tools.go │ │ ├── view.go │ │ └── write.go ├── logging │ ├── logger.go │ ├── message.go │ └── writer.go ├── lsp │ ├── client.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 │ └── events.go ├── session │ └── session.go ├── tui │ ├── components │ │ ├── chat │ │ │ ├── chat.go │ │ │ ├── editor.go │ │ │ ├── list.go │ │ │ ├── message.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 │ │ ├── logs │ │ │ ├── details.go │ │ │ └── table.go │ │ └── util │ │ │ └── simple-list.go │ ├── image │ │ └── images.go │ ├── layout │ │ ├── container.go │ │ ├── layout.go │ │ ├── overlay.go │ │ └── split.go │ ├── page │ │ ├── chat.go │ │ ├── logs.go │ │ └── page.go │ ├── styles │ │ ├── background.go │ │ ├── icons.go │ │ ├── markdown.go │ │ └── styles.go │ ├── theme │ │ ├── catppuccin.go │ │ ├── dracula.go │ │ ├── flexoki.go │ │ ├── gruvbox.go │ │ ├── manager.go │ │ ├── monokai.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 ├── scripts ├── check_hidden_chars.sh ├── release └── snapshot └── sqlc.yaml /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 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.HOMEBREW_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 | 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/opencode-ai/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-ai 38 | homepage: "https://github.com/opencode-ai/opencode" 39 | description: "terminal based agent that can build anything" 40 | maintainers: 41 | - "kujtimiihoxha " 42 | license: "MIT" 43 | private_key: "{{ .Env.AUR_KEY }}" 44 | git_url: "ssh://aur@aur.archlinux.org/opencode-ai-bin.git" 45 | provides: 46 | - opencode 47 | conflicts: 48 | - opencode 49 | package: |- 50 | install -Dm755 ./opencode "${pkgdir}/usr/bin/opencode" 51 | brews: 52 | - repository: 53 | owner: opencode-ai 54 | name: homebrew-tap 55 | nfpms: 56 | - maintainer: kujtimiihoxha 57 | description: terminal based agent that can build anything 58 | formats: 59 | - deb 60 | - rpm 61 | file_name_template: >- 62 | {{ .ProjectName }}- 63 | {{- if eq .Os "darwin" }}mac 64 | {{- else }}{{ .Os }}{{ end }}-{{ .Arch }} 65 | 66 | changelog: 67 | sort: asc 68 | filters: 69 | exclude: 70 | - "^docs:" 71 | - "^doc:" 72 | - "^test:" 73 | - "^ci:" 74 | - "^ignore:" 75 | - "^example:" 76 | - "^wip:" 77 | -------------------------------------------------------------------------------- /.opencode.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./opencode-schema.json", 3 | "lsp": { 4 | "gopls": { 5 | "command": "gopls" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /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/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 | "coder": { 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 | -------------------------------------------------------------------------------- /install: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | APP=opencode 4 | 5 | RED='\033[0;31m' 6 | GREEN='\033[0;32m' 7 | YELLOW='\033[1;33m' 8 | ORANGE='\033[38;2;255;140;0m' 9 | NC='\033[0m' # No Color 10 | 11 | requested_version=${VERSION:-} 12 | 13 | os=$(uname -s | tr '[:upper:]' '[:lower:]') 14 | if [[ "$os" == "darwin" ]]; then 15 | os="mac" 16 | fi 17 | arch=$(uname -m) 18 | 19 | if [[ "$arch" == "aarch64" ]]; then 20 | arch="arm64" 21 | fi 22 | 23 | filename="$APP-$os-$arch.tar.gz" 24 | 25 | 26 | case "$filename" in 27 | *"-linux-"*) 28 | [[ "$arch" == "x86_64" || "$arch" == "arm64" || "$arch" == "i386" ]] || exit 1 29 | ;; 30 | *"-mac-"*) 31 | [[ "$arch" == "x86_64" || "$arch" == "arm64" ]] || exit 1 32 | ;; 33 | *) 34 | echo "${RED}Unsupported OS/Arch: $os/$arch${NC}" 35 | exit 1 36 | ;; 37 | esac 38 | 39 | INSTALL_DIR=$HOME/.opencode/bin 40 | mkdir -p "$INSTALL_DIR" 41 | 42 | if [ -z "$requested_version" ]; then 43 | url="https://github.com/opencode-ai/opencode/releases/latest/download/$filename" 44 | specific_version=$(curl -s https://api.github.com/repos/opencode-ai/opencode/releases/latest | awk -F'"' '/"tag_name": "/ {gsub(/^v/, "", $4); print $4}') 45 | 46 | if [[ $? -ne 0 ]]; then 47 | echo "${RED}Failed to fetch version information${NC}" 48 | exit 1 49 | fi 50 | else 51 | url="https://github.com/opencode-ai/opencode/releases/download/v${requested_version}/$filename" 52 | specific_version=$requested_version 53 | fi 54 | 55 | print_message() { 56 | local level=$1 57 | local message=$2 58 | local color="" 59 | 60 | case $level in 61 | info) color="${GREEN}" ;; 62 | warning) color="${YELLOW}" ;; 63 | error) color="${RED}" ;; 64 | esac 65 | 66 | echo -e "${color}${message}${NC}" 67 | } 68 | 69 | check_version() { 70 | if command -v opencode >/dev/null 2>&1; then 71 | opencode_path=$(which opencode) 72 | 73 | 74 | ## TODO: check if version is installed 75 | # installed_version=$(opencode version) 76 | installed_version="0.0.1" 77 | installed_version=$(echo $installed_version | awk '{print $2}') 78 | 79 | if [[ "$installed_version" != "$specific_version" ]]; then 80 | print_message info "Installed version: ${YELLOW}$installed_version." 81 | else 82 | print_message info "Version ${YELLOW}$specific_version${GREEN} already installed" 83 | exit 0 84 | fi 85 | fi 86 | } 87 | 88 | download_and_install() { 89 | print_message info "Downloading ${ORANGE}opencode ${GREEN}version: ${YELLOW}$specific_version ${GREEN}..." 90 | mkdir -p opencodetmp && cd opencodetmp 91 | curl -# -L $url | tar xz 92 | mv opencode $INSTALL_DIR 93 | cd .. && rm -rf opencodetmp 94 | } 95 | 96 | check_version 97 | download_and_install 98 | 99 | 100 | add_to_path() { 101 | local config_file=$1 102 | local command=$2 103 | 104 | if [[ -w $config_file ]]; then 105 | echo -e "\n# opencode" >> "$config_file" 106 | echo "$command" >> "$config_file" 107 | print_message info "Successfully added ${ORANGE}opencode ${GREEN}to \$PATH in $config_file" 108 | else 109 | print_message warning "Manually add the directory to $config_file (or similar):" 110 | print_message info " $command" 111 | fi 112 | } 113 | 114 | XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-$HOME/.config} 115 | 116 | current_shell=$(basename "$SHELL") 117 | case $current_shell in 118 | fish) 119 | config_files="$HOME/.config/fish/config.fish" 120 | ;; 121 | zsh) 122 | config_files="$HOME/.zshrc $HOME/.zshenv $XDG_CONFIG_HOME/zsh/.zshrc $XDG_CONFIG_HOME/zsh/.zshenv" 123 | ;; 124 | bash) 125 | config_files="$HOME/.bashrc $HOME/.bash_profile $HOME/.profile $XDG_CONFIG_HOME/bash/.bashrc $XDG_CONFIG_HOME/bash/.bash_profile" 126 | ;; 127 | ash) 128 | config_files="$HOME/.ashrc $HOME/.profile /etc/profile" 129 | ;; 130 | sh) 131 | config_files="$HOME/.ashrc $HOME/.profile /etc/profile" 132 | ;; 133 | *) 134 | # Default case if none of the above matches 135 | config_files="$HOME/.bashrc $HOME/.bash_profile $XDG_CONFIG_HOME/bash/.bashrc $XDG_CONFIG_HOME/bash/.bash_profile" 136 | ;; 137 | esac 138 | 139 | config_file="" 140 | for file in $config_files; do 141 | if [[ -f $file ]]; then 142 | config_file=$file 143 | break 144 | fi 145 | done 146 | 147 | if [[ -z $config_file ]]; then 148 | print_message error "No config file found for $current_shell. Checked files: ${config_files[@]}" 149 | exit 1 150 | fi 151 | 152 | if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then 153 | case $current_shell in 154 | fish) 155 | add_to_path "$config_file" "fish_add_path $INSTALL_DIR" 156 | ;; 157 | zsh) 158 | add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH" 159 | ;; 160 | bash) 161 | add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH" 162 | ;; 163 | ash) 164 | add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH" 165 | ;; 166 | sh) 167 | add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH" 168 | ;; 169 | *) 170 | print_message warning "Manually add the directory to $config_file (or similar):" 171 | print_message info " export PATH=$INSTALL_DIR:\$PATH" 172 | ;; 173 | esac 174 | fi 175 | 176 | if [ -n "${GITHUB_ACTIONS-}" ] && [ "${GITHUB_ACTIONS}" == "true" ]; then 177 | echo "$INSTALL_DIR" >> $GITHUB_PATH 178 | print_message info "Added $INSTALL_DIR to \$GITHUB_PATH" 179 | fi 180 | 181 | -------------------------------------------------------------------------------- /internal/app/lsp.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/opencode-ai/opencode/internal/config" 8 | "github.com/opencode-ai/opencode/internal/logging" 9 | "github.com/opencode-ai/opencode/internal/lsp" 10 | "github.com/opencode-ai/opencode/internal/lsp/watcher" 11 | ) 12 | 13 | func (app *App) initLSPClients(ctx context.Context) { 14 | cfg := config.Get() 15 | 16 | // Initialize LSP clients 17 | for name, clientConfig := range cfg.LSP { 18 | // Start each client initialization in its own goroutine 19 | go app.createAndStartLSPClient(ctx, name, clientConfig.Command, clientConfig.Args...) 20 | } 21 | logging.Info("LSP clients initialization started in background") 22 | } 23 | 24 | // createAndStartLSPClient creates a new LSP client, initializes it, and starts its workspace watcher 25 | func (app *App) createAndStartLSPClient(ctx context.Context, name string, command string, args ...string) { 26 | // Create a specific context for initialization with a timeout 27 | logging.Info("Creating LSP client", "name", name, "command", command, "args", args) 28 | 29 | // Create the LSP client 30 | lspClient, err := lsp.NewClient(ctx, command, args...) 31 | if err != nil { 32 | logging.Error("Failed to create LSP client for", name, err) 33 | return 34 | } 35 | 36 | // Create a longer timeout for initialization (some servers take time to start) 37 | initCtx, cancel := context.WithTimeout(ctx, 30*time.Second) 38 | defer cancel() 39 | 40 | // Initialize with the initialization context 41 | _, err = lspClient.InitializeLSPClient(initCtx, config.WorkingDirectory()) 42 | if err != nil { 43 | logging.Error("Initialize failed", "name", name, "error", err) 44 | // Clean up the client to prevent resource leaks 45 | lspClient.Close() 46 | return 47 | } 48 | 49 | // Wait for the server to be ready 50 | if err := lspClient.WaitForServerReady(initCtx); err != nil { 51 | logging.Error("Server failed to become ready", "name", name, "error", err) 52 | // We'll continue anyway, as some functionality might still work 53 | lspClient.SetServerState(lsp.StateError) 54 | } else { 55 | logging.Info("LSP server is ready", "name", name) 56 | lspClient.SetServerState(lsp.StateReady) 57 | } 58 | 59 | logging.Info("LSP client initialized", "name", name) 60 | 61 | // Create a child context that can be canceled when the app is shutting down 62 | watchCtx, cancelFunc := context.WithCancel(ctx) 63 | 64 | // Create a context with the server name for better identification 65 | watchCtx = context.WithValue(watchCtx, "serverName", name) 66 | 67 | // Create the workspace watcher 68 | workspaceWatcher := watcher.NewWorkspaceWatcher(lspClient) 69 | 70 | // Store the cancel function to be called during cleanup 71 | app.cancelFuncsMutex.Lock() 72 | app.watcherCancelFuncs = append(app.watcherCancelFuncs, cancelFunc) 73 | app.cancelFuncsMutex.Unlock() 74 | 75 | // Add the watcher to a WaitGroup to track active goroutines 76 | app.watcherWG.Add(1) 77 | 78 | // Add to map with mutex protection before starting goroutine 79 | app.clientsMutex.Lock() 80 | app.LSPClients[name] = lspClient 81 | app.clientsMutex.Unlock() 82 | 83 | go app.runWorkspaceWatcher(watchCtx, name, workspaceWatcher) 84 | } 85 | 86 | // runWorkspaceWatcher executes the workspace watcher for an LSP client 87 | func (app *App) runWorkspaceWatcher(ctx context.Context, name string, workspaceWatcher *watcher.WorkspaceWatcher) { 88 | defer app.watcherWG.Done() 89 | defer logging.RecoverPanic("LSP-"+name, func() { 90 | // Try to restart the client 91 | app.restartLSPClient(ctx, name) 92 | }) 93 | 94 | workspaceWatcher.WatchWorkspace(ctx, config.WorkingDirectory()) 95 | logging.Info("Workspace watcher stopped", "client", name) 96 | } 97 | 98 | // restartLSPClient attempts to restart a crashed or failed LSP client 99 | func (app *App) restartLSPClient(ctx context.Context, name string) { 100 | // Get the original configuration 101 | cfg := config.Get() 102 | clientConfig, exists := cfg.LSP[name] 103 | if !exists { 104 | logging.Error("Cannot restart client, configuration not found", "client", name) 105 | return 106 | } 107 | 108 | // Clean up the old client if it exists 109 | app.clientsMutex.Lock() 110 | oldClient, exists := app.LSPClients[name] 111 | if exists { 112 | delete(app.LSPClients, name) // Remove from map before potentially slow shutdown 113 | } 114 | app.clientsMutex.Unlock() 115 | 116 | if exists && oldClient != nil { 117 | // Try to shut it down gracefully, but don't block on errors 118 | shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 119 | _ = oldClient.Shutdown(shutdownCtx) 120 | cancel() 121 | } 122 | 123 | // Create a new client using the shared function 124 | app.createAndStartLSPClient(ctx, name, clientConfig.Command, clientConfig.Args...) 125 | logging.Info("Successfully restarted LSP client", "client", name) 126 | } 127 | -------------------------------------------------------------------------------- /internal/completions/files-folders.go: -------------------------------------------------------------------------------- 1 | package completions 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os/exec" 7 | "path/filepath" 8 | 9 | "github.com/lithammer/fuzzysearch/fuzzy" 10 | "github.com/opencode-ai/opencode/internal/fileutil" 11 | "github.com/opencode-ai/opencode/internal/logging" 12 | "github.com/opencode-ai/opencode/internal/tui/components/dialog" 13 | ) 14 | 15 | type filesAndFoldersContextGroup struct { 16 | prefix string 17 | } 18 | 19 | func (cg *filesAndFoldersContextGroup) GetId() string { 20 | return cg.prefix 21 | } 22 | 23 | func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI { 24 | return dialog.NewCompletionItem(dialog.CompletionItem{ 25 | Title: "Files & Folders", 26 | Value: "files", 27 | }) 28 | } 29 | 30 | func processNullTerminatedOutput(outputBytes []byte) []string { 31 | if len(outputBytes) > 0 && outputBytes[len(outputBytes)-1] == 0 { 32 | outputBytes = outputBytes[:len(outputBytes)-1] 33 | } 34 | 35 | if len(outputBytes) == 0 { 36 | return []string{} 37 | } 38 | 39 | split := bytes.Split(outputBytes, []byte{0}) 40 | matches := make([]string, 0, len(split)) 41 | 42 | for _, p := range split { 43 | if len(p) == 0 { 44 | continue 45 | } 46 | 47 | path := string(p) 48 | path = filepath.Join(".", path) 49 | 50 | if !fileutil.SkipHidden(path) { 51 | matches = append(matches, path) 52 | } 53 | } 54 | 55 | return matches 56 | } 57 | 58 | func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) { 59 | cmdRg := fileutil.GetRgCmd("") // No glob pattern for this use case 60 | cmdFzf := fileutil.GetFzfCmd(query) 61 | 62 | var matches []string 63 | // Case 1: Both rg and fzf available 64 | if cmdRg != nil && cmdFzf != nil { 65 | rgPipe, err := cmdRg.StdoutPipe() 66 | if err != nil { 67 | return nil, fmt.Errorf("failed to get rg stdout pipe: %w", err) 68 | } 69 | defer rgPipe.Close() 70 | 71 | cmdFzf.Stdin = rgPipe 72 | var fzfOut bytes.Buffer 73 | var fzfErr bytes.Buffer 74 | cmdFzf.Stdout = &fzfOut 75 | cmdFzf.Stderr = &fzfErr 76 | 77 | if err := cmdFzf.Start(); err != nil { 78 | return nil, fmt.Errorf("failed to start fzf: %w", err) 79 | } 80 | 81 | errRg := cmdRg.Run() 82 | errFzf := cmdFzf.Wait() 83 | 84 | if errRg != nil { 85 | logging.Warn(fmt.Sprintf("rg command failed during pipe: %v", errRg)) 86 | } 87 | 88 | if errFzf != nil { 89 | if exitErr, ok := errFzf.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { 90 | return []string{}, nil // No matches from fzf 91 | } 92 | return nil, fmt.Errorf("fzf command failed: %w\nStderr: %s", errFzf, fzfErr.String()) 93 | } 94 | 95 | matches = processNullTerminatedOutput(fzfOut.Bytes()) 96 | 97 | // Case 2: Only rg available 98 | } else if cmdRg != nil { 99 | logging.Debug("Using Ripgrep with fuzzy match fallback for file completions") 100 | var rgOut bytes.Buffer 101 | var rgErr bytes.Buffer 102 | cmdRg.Stdout = &rgOut 103 | cmdRg.Stderr = &rgErr 104 | 105 | if err := cmdRg.Run(); err != nil { 106 | return nil, fmt.Errorf("rg command failed: %w\nStderr: %s", err, rgErr.String()) 107 | } 108 | 109 | allFiles := processNullTerminatedOutput(rgOut.Bytes()) 110 | matches = fuzzy.Find(query, allFiles) 111 | 112 | // Case 3: Only fzf available 113 | } else if cmdFzf != nil { 114 | logging.Debug("Using FZF with doublestar fallback for file completions") 115 | files, _, err := fileutil.GlobWithDoublestar("**/*", ".", 0) 116 | if err != nil { 117 | return nil, fmt.Errorf("failed to list files for fzf: %w", err) 118 | } 119 | 120 | allFiles := make([]string, 0, len(files)) 121 | for _, file := range files { 122 | if !fileutil.SkipHidden(file) { 123 | allFiles = append(allFiles, file) 124 | } 125 | } 126 | 127 | var fzfIn bytes.Buffer 128 | for _, file := range allFiles { 129 | fzfIn.WriteString(file) 130 | fzfIn.WriteByte(0) 131 | } 132 | 133 | cmdFzf.Stdin = &fzfIn 134 | var fzfOut bytes.Buffer 135 | var fzfErr bytes.Buffer 136 | cmdFzf.Stdout = &fzfOut 137 | cmdFzf.Stderr = &fzfErr 138 | 139 | if err := cmdFzf.Run(); err != nil { 140 | if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { 141 | return []string{}, nil 142 | } 143 | return nil, fmt.Errorf("fzf command failed: %w\nStderr: %s", err, fzfErr.String()) 144 | } 145 | 146 | matches = processNullTerminatedOutput(fzfOut.Bytes()) 147 | 148 | // Case 4: Fallback to doublestar with fuzzy match 149 | } else { 150 | logging.Debug("Using doublestar with fuzzy match for file completions") 151 | allFiles, _, err := fileutil.GlobWithDoublestar("**/*", ".", 0) 152 | if err != nil { 153 | return nil, fmt.Errorf("failed to glob files: %w", err) 154 | } 155 | 156 | filteredFiles := make([]string, 0, len(allFiles)) 157 | for _, file := range allFiles { 158 | if !fileutil.SkipHidden(file) { 159 | filteredFiles = append(filteredFiles, file) 160 | } 161 | } 162 | 163 | matches = fuzzy.Find(query, filteredFiles) 164 | } 165 | 166 | return matches, nil 167 | } 168 | 169 | func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) { 170 | matches, err := cg.getFiles(query) 171 | if err != nil { 172 | return nil, err 173 | } 174 | 175 | items := make([]dialog.CompletionItemI, 0, len(matches)) 176 | for _, file := range matches { 177 | item := dialog.NewCompletionItem(dialog.CompletionItem{ 178 | Title: file, 179 | Value: file, 180 | }) 181 | items = append(items, item) 182 | } 183 | 184 | return items, nil 185 | } 186 | 187 | func NewFileAndFolderContextGroup() dialog.CompletionProvider { 188 | return &filesAndFoldersContextGroup{ 189 | prefix: "file", 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /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 | 62 | -------------------------------------------------------------------------------- /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/opencode-ai/opencode/internal/config" 13 | "github.com/opencode-ai/opencode/internal/logging" 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 | logging.Error("Failed to set pragma", pragma, err) 51 | } else { 52 | logging.Debug("Set pragma", "pragma", pragma) 53 | } 54 | } 55 | 56 | goose.SetBaseFS(FS) 57 | 58 | if err := goose.SetDialect("sqlite3"); err != nil { 59 | logging.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 | logging.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/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 | created_at, 21 | updated_at 22 | ) VALUES ( 23 | ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now') 24 | ) 25 | RETURNING id, session_id, role, parts, model, created_at, updated_at, finished_at 26 | ` 27 | 28 | type CreateMessageParams struct { 29 | ID string `json:"id"` 30 | SessionID string `json:"session_id"` 31 | Role string `json:"role"` 32 | Parts string `json:"parts"` 33 | Model sql.NullString `json:"model"` 34 | } 35 | 36 | func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error) { 37 | row := q.queryRow(ctx, q.createMessageStmt, createMessage, 38 | arg.ID, 39 | arg.SessionID, 40 | arg.Role, 41 | arg.Parts, 42 | arg.Model, 43 | ) 44 | var i Message 45 | err := row.Scan( 46 | &i.ID, 47 | &i.SessionID, 48 | &i.Role, 49 | &i.Parts, 50 | &i.Model, 51 | &i.CreatedAt, 52 | &i.UpdatedAt, 53 | &i.FinishedAt, 54 | ) 55 | return i, err 56 | } 57 | 58 | const deleteMessage = `-- name: DeleteMessage :exec 59 | DELETE FROM messages 60 | WHERE id = ? 61 | ` 62 | 63 | func (q *Queries) DeleteMessage(ctx context.Context, id string) error { 64 | _, err := q.exec(ctx, q.deleteMessageStmt, deleteMessage, id) 65 | return err 66 | } 67 | 68 | const deleteSessionMessages = `-- name: DeleteSessionMessages :exec 69 | DELETE FROM messages 70 | WHERE session_id = ? 71 | ` 72 | 73 | func (q *Queries) DeleteSessionMessages(ctx context.Context, sessionID string) error { 74 | _, err := q.exec(ctx, q.deleteSessionMessagesStmt, deleteSessionMessages, sessionID) 75 | return err 76 | } 77 | 78 | const getMessage = `-- name: GetMessage :one 79 | SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at 80 | FROM messages 81 | WHERE id = ? LIMIT 1 82 | ` 83 | 84 | func (q *Queries) GetMessage(ctx context.Context, id string) (Message, error) { 85 | row := q.queryRow(ctx, q.getMessageStmt, getMessage, id) 86 | var i Message 87 | err := row.Scan( 88 | &i.ID, 89 | &i.SessionID, 90 | &i.Role, 91 | &i.Parts, 92 | &i.Model, 93 | &i.CreatedAt, 94 | &i.UpdatedAt, 95 | &i.FinishedAt, 96 | ) 97 | return i, err 98 | } 99 | 100 | const listMessagesBySession = `-- name: ListMessagesBySession :many 101 | SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at 102 | FROM messages 103 | WHERE session_id = ? 104 | ORDER BY created_at ASC 105 | ` 106 | 107 | func (q *Queries) ListMessagesBySession(ctx context.Context, sessionID string) ([]Message, error) { 108 | rows, err := q.query(ctx, q.listMessagesBySessionStmt, listMessagesBySession, sessionID) 109 | if err != nil { 110 | return nil, err 111 | } 112 | defer rows.Close() 113 | items := []Message{} 114 | for rows.Next() { 115 | var i Message 116 | if err := rows.Scan( 117 | &i.ID, 118 | &i.SessionID, 119 | &i.Role, 120 | &i.Parts, 121 | &i.Model, 122 | &i.CreatedAt, 123 | &i.UpdatedAt, 124 | &i.FinishedAt, 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 | 139 | const updateMessage = `-- name: UpdateMessage :exec 140 | UPDATE messages 141 | SET 142 | parts = ?, 143 | finished_at = ?, 144 | updated_at = strftime('%s', 'now') 145 | WHERE id = ? 146 | ` 147 | 148 | type UpdateMessageParams struct { 149 | Parts string `json:"parts"` 150 | FinishedAt sql.NullInt64 `json:"finished_at"` 151 | ID string `json:"id"` 152 | } 153 | 154 | func (q *Queries) UpdateMessage(ctx context.Context, arg UpdateMessageParams) error { 155 | _, err := q.exec(ctx, q.updateMessageStmt, updateMessage, arg.Parts, arg.FinishedAt, arg.ID) 156 | return err 157 | } 158 | -------------------------------------------------------------------------------- /internal/db/migrations/20250424200609_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 | updated_at INTEGER NOT NULL, -- Unix timestamp in milliseconds 13 | created_at INTEGER NOT NULL -- Unix timestamp in milliseconds 14 | ); 15 | 16 | CREATE TRIGGER IF NOT EXISTS update_sessions_updated_at 17 | AFTER UPDATE ON sessions 18 | BEGIN 19 | UPDATE sessions SET updated_at = strftime('%s', 'now') 20 | WHERE id = new.id; 21 | END; 22 | 23 | -- Files 24 | CREATE TABLE IF NOT EXISTS files ( 25 | id TEXT PRIMARY KEY, 26 | session_id TEXT NOT NULL, 27 | path TEXT NOT NULL, 28 | content TEXT NOT NULL, 29 | version TEXT NOT NULL, 30 | created_at INTEGER NOT NULL, -- Unix timestamp in milliseconds 31 | updated_at INTEGER NOT NULL, -- Unix timestamp in milliseconds 32 | FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE, 33 | UNIQUE(path, session_id, version) 34 | ); 35 | 36 | CREATE INDEX IF NOT EXISTS idx_files_session_id ON files (session_id); 37 | CREATE INDEX IF NOT EXISTS idx_files_path ON files (path); 38 | 39 | CREATE TRIGGER IF NOT EXISTS update_files_updated_at 40 | AFTER UPDATE ON files 41 | BEGIN 42 | UPDATE files SET updated_at = strftime('%s', 'now') 43 | WHERE id = new.id; 44 | END; 45 | 46 | -- Messages 47 | CREATE TABLE IF NOT EXISTS messages ( 48 | id TEXT PRIMARY KEY, 49 | session_id TEXT NOT NULL, 50 | role TEXT NOT NULL, 51 | parts TEXT NOT NULL default '[]', 52 | model TEXT, 53 | created_at INTEGER NOT NULL, -- Unix timestamp in milliseconds 54 | updated_at INTEGER NOT NULL, -- Unix timestamp in milliseconds 55 | finished_at INTEGER, -- Unix timestamp in milliseconds 56 | FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE 57 | ); 58 | 59 | CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages (session_id); 60 | 61 | CREATE TRIGGER IF NOT EXISTS update_messages_updated_at 62 | AFTER UPDATE ON messages 63 | BEGIN 64 | UPDATE messages SET updated_at = strftime('%s', 'now') 65 | WHERE id = new.id; 66 | END; 67 | 68 | CREATE TRIGGER IF NOT EXISTS update_session_message_count_on_insert 69 | AFTER INSERT ON messages 70 | BEGIN 71 | UPDATE sessions SET 72 | message_count = message_count + 1 73 | WHERE id = new.session_id; 74 | END; 75 | 76 | CREATE TRIGGER IF NOT EXISTS update_session_message_count_on_delete 77 | AFTER DELETE ON messages 78 | BEGIN 79 | UPDATE sessions SET 80 | message_count = message_count - 1 81 | WHERE id = old.session_id; 82 | END; 83 | 84 | -- +goose StatementEnd 85 | 86 | -- +goose Down 87 | -- +goose StatementBegin 88 | DROP TRIGGER IF EXISTS update_sessions_updated_at; 89 | DROP TRIGGER IF EXISTS update_messages_updated_at; 90 | DROP TRIGGER IF EXISTS update_files_updated_at; 91 | 92 | DROP TRIGGER IF EXISTS update_session_message_count_on_delete; 93 | DROP TRIGGER IF EXISTS update_session_message_count_on_insert; 94 | 95 | DROP TABLE IF EXISTS sessions; 96 | DROP TABLE IF EXISTS messages; 97 | DROP TABLE IF EXISTS files; 98 | -- +goose StatementEnd 99 | -------------------------------------------------------------------------------- /internal/db/migrations/20250515105448_add_summary_message_id.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | ALTER TABLE sessions ADD COLUMN summary_message_id TEXT; 4 | -- +goose StatementEnd 5 | 6 | -- +goose Down 7 | -- +goose StatementBegin 8 | ALTER TABLE sessions DROP COLUMN summary_message_id; 9 | -- +goose StatementEnd 10 | -------------------------------------------------------------------------------- /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 | CreatedAt int64 `json:"created_at"` 18 | UpdatedAt int64 `json:"updated_at"` 19 | } 20 | 21 | type Message struct { 22 | ID string `json:"id"` 23 | SessionID string `json:"session_id"` 24 | Role string `json:"role"` 25 | Parts string `json:"parts"` 26 | Model sql.NullString `json:"model"` 27 | CreatedAt int64 `json:"created_at"` 28 | UpdatedAt int64 `json:"updated_at"` 29 | FinishedAt sql.NullInt64 `json:"finished_at"` 30 | } 31 | 32 | type Session struct { 33 | ID string `json:"id"` 34 | ParentSessionID sql.NullString `json:"parent_session_id"` 35 | Title string `json:"title"` 36 | MessageCount int64 `json:"message_count"` 37 | PromptTokens int64 `json:"prompt_tokens"` 38 | CompletionTokens int64 `json:"completion_tokens"` 39 | Cost float64 `json:"cost"` 40 | UpdatedAt int64 `json:"updated_at"` 41 | CreatedAt int64 `json:"created_at"` 42 | SummaryMessageID sql.NullString `json:"summary_message_id"` 43 | } 44 | -------------------------------------------------------------------------------- /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 | ) 10 | 11 | type Querier interface { 12 | CreateFile(ctx context.Context, arg CreateFileParams) (File, error) 13 | CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error) 14 | CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) 15 | DeleteFile(ctx context.Context, id string) error 16 | DeleteMessage(ctx context.Context, id string) error 17 | DeleteSession(ctx context.Context, id string) error 18 | DeleteSessionFiles(ctx context.Context, sessionID string) error 19 | DeleteSessionMessages(ctx context.Context, sessionID string) error 20 | GetFile(ctx context.Context, id string) (File, error) 21 | GetFileByPathAndSession(ctx context.Context, arg GetFileByPathAndSessionParams) (File, error) 22 | GetMessage(ctx context.Context, id string) (Message, error) 23 | GetSessionByID(ctx context.Context, id string) (Session, error) 24 | ListFilesByPath(ctx context.Context, path string) ([]File, error) 25 | ListFilesBySession(ctx context.Context, sessionID string) ([]File, error) 26 | ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error) 27 | ListMessagesBySession(ctx context.Context, sessionID string) ([]Message, error) 28 | ListNewFiles(ctx context.Context) ([]File, error) 29 | ListSessions(ctx context.Context) ([]Session, error) 30 | UpdateFile(ctx context.Context, arg UpdateFileParams) (File, error) 31 | UpdateMessage(ctx context.Context, arg UpdateMessageParams) error 32 | UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error) 33 | } 34 | 35 | var _ Querier = (*Queries)(nil) 36 | -------------------------------------------------------------------------------- /internal/db/sessions.sql.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.29.0 4 | // source: sessions.sql 5 | 6 | package db 7 | 8 | import ( 9 | "context" 10 | "database/sql" 11 | ) 12 | 13 | const createSession = `-- name: CreateSession :one 14 | INSERT INTO sessions ( 15 | id, 16 | parent_session_id, 17 | title, 18 | message_count, 19 | prompt_tokens, 20 | completion_tokens, 21 | cost, 22 | summary_message_id, 23 | updated_at, 24 | created_at 25 | ) VALUES ( 26 | ?, 27 | ?, 28 | ?, 29 | ?, 30 | ?, 31 | ?, 32 | ?, 33 | null, 34 | strftime('%s', 'now'), 35 | strftime('%s', 'now') 36 | ) RETURNING id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at, summary_message_id 37 | ` 38 | 39 | type CreateSessionParams struct { 40 | ID string `json:"id"` 41 | ParentSessionID sql.NullString `json:"parent_session_id"` 42 | Title string `json:"title"` 43 | MessageCount int64 `json:"message_count"` 44 | PromptTokens int64 `json:"prompt_tokens"` 45 | CompletionTokens int64 `json:"completion_tokens"` 46 | Cost float64 `json:"cost"` 47 | } 48 | 49 | func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) { 50 | row := q.queryRow(ctx, q.createSessionStmt, createSession, 51 | arg.ID, 52 | arg.ParentSessionID, 53 | arg.Title, 54 | arg.MessageCount, 55 | arg.PromptTokens, 56 | arg.CompletionTokens, 57 | arg.Cost, 58 | ) 59 | var i Session 60 | err := row.Scan( 61 | &i.ID, 62 | &i.ParentSessionID, 63 | &i.Title, 64 | &i.MessageCount, 65 | &i.PromptTokens, 66 | &i.CompletionTokens, 67 | &i.Cost, 68 | &i.UpdatedAt, 69 | &i.CreatedAt, 70 | &i.SummaryMessageID, 71 | ) 72 | return i, err 73 | } 74 | 75 | const deleteSession = `-- name: DeleteSession :exec 76 | DELETE FROM sessions 77 | WHERE id = ? 78 | ` 79 | 80 | func (q *Queries) DeleteSession(ctx context.Context, id string) error { 81 | _, err := q.exec(ctx, q.deleteSessionStmt, deleteSession, id) 82 | return err 83 | } 84 | 85 | const getSessionByID = `-- name: GetSessionByID :one 86 | SELECT id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at, summary_message_id 87 | FROM sessions 88 | WHERE id = ? LIMIT 1 89 | ` 90 | 91 | func (q *Queries) GetSessionByID(ctx context.Context, id string) (Session, error) { 92 | row := q.queryRow(ctx, q.getSessionByIDStmt, getSessionByID, id) 93 | var i Session 94 | err := row.Scan( 95 | &i.ID, 96 | &i.ParentSessionID, 97 | &i.Title, 98 | &i.MessageCount, 99 | &i.PromptTokens, 100 | &i.CompletionTokens, 101 | &i.Cost, 102 | &i.UpdatedAt, 103 | &i.CreatedAt, 104 | &i.SummaryMessageID, 105 | ) 106 | return i, err 107 | } 108 | 109 | const listSessions = `-- name: ListSessions :many 110 | SELECT id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at, summary_message_id 111 | FROM sessions 112 | WHERE parent_session_id is NULL 113 | ORDER BY created_at DESC 114 | ` 115 | 116 | func (q *Queries) ListSessions(ctx context.Context) ([]Session, error) { 117 | rows, err := q.query(ctx, q.listSessionsStmt, listSessions) 118 | if err != nil { 119 | return nil, err 120 | } 121 | defer rows.Close() 122 | items := []Session{} 123 | for rows.Next() { 124 | var i Session 125 | if err := rows.Scan( 126 | &i.ID, 127 | &i.ParentSessionID, 128 | &i.Title, 129 | &i.MessageCount, 130 | &i.PromptTokens, 131 | &i.CompletionTokens, 132 | &i.Cost, 133 | &i.UpdatedAt, 134 | &i.CreatedAt, 135 | &i.SummaryMessageID, 136 | ); err != nil { 137 | return nil, err 138 | } 139 | items = append(items, i) 140 | } 141 | if err := rows.Close(); err != nil { 142 | return nil, err 143 | } 144 | if err := rows.Err(); err != nil { 145 | return nil, err 146 | } 147 | return items, nil 148 | } 149 | 150 | const updateSession = `-- name: UpdateSession :one 151 | UPDATE sessions 152 | SET 153 | title = ?, 154 | prompt_tokens = ?, 155 | completion_tokens = ?, 156 | summary_message_id = ?, 157 | cost = ? 158 | WHERE id = ? 159 | RETURNING id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at, summary_message_id 160 | ` 161 | 162 | type UpdateSessionParams struct { 163 | Title string `json:"title"` 164 | PromptTokens int64 `json:"prompt_tokens"` 165 | CompletionTokens int64 `json:"completion_tokens"` 166 | SummaryMessageID sql.NullString `json:"summary_message_id"` 167 | Cost float64 `json:"cost"` 168 | ID string `json:"id"` 169 | } 170 | 171 | func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error) { 172 | row := q.queryRow(ctx, q.updateSessionStmt, updateSession, 173 | arg.Title, 174 | arg.PromptTokens, 175 | arg.CompletionTokens, 176 | arg.SummaryMessageID, 177 | arg.Cost, 178 | arg.ID, 179 | ) 180 | var i Session 181 | err := row.Scan( 182 | &i.ID, 183 | &i.ParentSessionID, 184 | &i.Title, 185 | &i.MessageCount, 186 | &i.PromptTokens, 187 | &i.CompletionTokens, 188 | &i.Cost, 189 | &i.UpdatedAt, 190 | &i.CreatedAt, 191 | &i.SummaryMessageID, 192 | ) 193 | return i, err 194 | } 195 | -------------------------------------------------------------------------------- /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 | created_at, 33 | updated_at 34 | ) VALUES ( 35 | ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now') 36 | ) 37 | RETURNING *; 38 | 39 | -- name: UpdateFile :one 40 | UPDATE files 41 | SET 42 | content = ?, 43 | version = ?, 44 | updated_at = strftime('%s', 'now') 45 | WHERE id = ? 46 | RETURNING *; 47 | 48 | -- name: DeleteFile :exec 49 | DELETE FROM files 50 | WHERE id = ?; 51 | 52 | -- name: DeleteSessionFiles :exec 53 | DELETE FROM files 54 | WHERE session_id = ?; 55 | 56 | -- name: ListLatestSessionFiles :many 57 | SELECT f.* 58 | FROM files f 59 | INNER JOIN ( 60 | SELECT path, MAX(created_at) as max_created_at 61 | FROM files 62 | GROUP BY path 63 | ) latest ON f.path = latest.path AND f.created_at = latest.max_created_at 64 | WHERE f.session_id = ? 65 | ORDER BY f.path; 66 | 67 | -- name: ListNewFiles :many 68 | SELECT * 69 | FROM files 70 | WHERE is_new = 1 71 | ORDER BY created_at DESC; 72 | -------------------------------------------------------------------------------- /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: CreateMessage :one 13 | INSERT INTO messages ( 14 | id, 15 | session_id, 16 | role, 17 | parts, 18 | model, 19 | created_at, 20 | updated_at 21 | ) VALUES ( 22 | ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now') 23 | ) 24 | RETURNING *; 25 | 26 | -- name: UpdateMessage :exec 27 | UPDATE messages 28 | SET 29 | parts = ?, 30 | finished_at = ?, 31 | updated_at = strftime('%s', 'now') 32 | WHERE id = ?; 33 | 34 | 35 | -- name: DeleteMessage :exec 36 | DELETE FROM messages 37 | WHERE id = ?; 38 | 39 | -- name: DeleteSessionMessages :exec 40 | DELETE FROM messages 41 | WHERE session_id = ?; 42 | -------------------------------------------------------------------------------- /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_message_id, 11 | updated_at, 12 | created_at 13 | ) VALUES ( 14 | ?, 15 | ?, 16 | ?, 17 | ?, 18 | ?, 19 | ?, 20 | ?, 21 | null, 22 | strftime('%s', 'now'), 23 | strftime('%s', 'now') 24 | ) RETURNING *; 25 | 26 | -- name: GetSessionByID :one 27 | SELECT * 28 | FROM sessions 29 | WHERE id = ? LIMIT 1; 30 | 31 | -- name: ListSessions :many 32 | SELECT * 33 | FROM sessions 34 | WHERE parent_session_id is NULL 35 | ORDER BY created_at DESC; 36 | 37 | -- name: UpdateSession :one 38 | UPDATE sessions 39 | SET 40 | title = ?, 41 | prompt_tokens = ?, 42 | completion_tokens = ?, 43 | summary_message_id = ?, 44 | cost = ? 45 | WHERE id = ? 46 | RETURNING *; 47 | 48 | 49 | -- name: DeleteSession :exec 50 | DELETE FROM sessions 51 | WHERE id = ?; 52 | -------------------------------------------------------------------------------- /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/opencode-ai/opencode/internal/logging" 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 | logging.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 | logging.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 | "strings" 7 | ) 8 | 9 | // OutputFormat represents the output format type for non-interactive mode 10 | type OutputFormat string 11 | 12 | const ( 13 | // Text format outputs the AI response as plain text. 14 | Text OutputFormat = "text" 15 | 16 | // JSON format outputs the AI response wrapped in a JSON object. 17 | JSON OutputFormat = "json" 18 | ) 19 | 20 | // String returns the string representation of the OutputFormat 21 | func (f OutputFormat) String() string { 22 | return string(f) 23 | } 24 | 25 | // SupportedFormats is a list of all supported output formats as strings 26 | var SupportedFormats = []string{ 27 | string(Text), 28 | string(JSON), 29 | } 30 | 31 | // Parse converts a string to an OutputFormat 32 | func Parse(s string) (OutputFormat, error) { 33 | s = strings.ToLower(strings.TrimSpace(s)) 34 | 35 | switch s { 36 | case string(Text): 37 | return Text, nil 38 | case string(JSON): 39 | return JSON, nil 40 | default: 41 | return "", fmt.Errorf("invalid format: %s", s) 42 | } 43 | } 44 | 45 | // IsValid checks if the provided format string is supported 46 | func IsValid(s string) bool { 47 | _, err := Parse(s) 48 | return err == nil 49 | } 50 | 51 | // GetHelpText returns a formatted string describing all supported formats 52 | func GetHelpText() string { 53 | return fmt.Sprintf(`Supported output formats: 54 | - %s: Plain text output (default) 55 | - %s: Output wrapped in a JSON object`, 56 | Text, JSON) 57 | } 58 | 59 | // FormatOutput formats the AI response according to the specified format 60 | func FormatOutput(content string, formatStr string) string { 61 | format, err := Parse(formatStr) 62 | if err != nil { 63 | // Default to text format on error 64 | return content 65 | } 66 | 67 | switch format { 68 | case JSON: 69 | return formatAsJSON(content) 70 | case Text: 71 | fallthrough 72 | default: 73 | return content 74 | } 75 | } 76 | 77 | // formatAsJSON wraps the content in a simple JSON object 78 | func formatAsJSON(content string) string { 79 | // Use the JSON package to properly escape the content 80 | response := struct { 81 | Response string `json:"response"` 82 | }{ 83 | Response: content, 84 | } 85 | 86 | jsonBytes, err := json.MarshalIndent(response, "", " ") 87 | if err != nil { 88 | // In case of an error, return a manually formatted JSON 89 | jsonEscaped := strings.Replace(content, "\\", "\\\\", -1) 90 | jsonEscaped = strings.Replace(jsonEscaped, "\"", "\\\"", -1) 91 | jsonEscaped = strings.Replace(jsonEscaped, "\n", "\\n", -1) 92 | jsonEscaped = strings.Replace(jsonEscaped, "\r", "\\r", -1) 93 | jsonEscaped = strings.Replace(jsonEscaped, "\t", "\\t", -1) 94 | 95 | return fmt.Sprintf("{\n \"response\": \"%s\"\n}", jsonEscaped) 96 | } 97 | 98 | return string(jsonBytes) 99 | } 100 | -------------------------------------------------------------------------------- /internal/format/spinner.go: -------------------------------------------------------------------------------- 1 | package format 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/charmbracelet/bubbles/spinner" 9 | tea "github.com/charmbracelet/bubbletea" 10 | ) 11 | 12 | // Spinner wraps the bubbles spinner for non-interactive mode 13 | type Spinner struct { 14 | model spinner.Model 15 | done chan struct{} 16 | prog *tea.Program 17 | ctx context.Context 18 | cancel context.CancelFunc 19 | } 20 | 21 | // spinnerModel is the tea.Model for the spinner 22 | type spinnerModel struct { 23 | spinner spinner.Model 24 | message string 25 | quitting bool 26 | } 27 | 28 | func (m spinnerModel) Init() tea.Cmd { 29 | return m.spinner.Tick 30 | } 31 | 32 | func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 33 | switch msg := msg.(type) { 34 | case tea.KeyMsg: 35 | m.quitting = true 36 | return m, tea.Quit 37 | case spinner.TickMsg: 38 | var cmd tea.Cmd 39 | m.spinner, cmd = m.spinner.Update(msg) 40 | return m, cmd 41 | case quitMsg: 42 | m.quitting = true 43 | return m, tea.Quit 44 | default: 45 | return m, nil 46 | } 47 | } 48 | 49 | func (m spinnerModel) View() string { 50 | if m.quitting { 51 | return "" 52 | } 53 | return fmt.Sprintf("%s %s", m.spinner.View(), m.message) 54 | } 55 | 56 | // quitMsg is sent when we want to quit the spinner 57 | type quitMsg struct{} 58 | 59 | // NewSpinner creates a new spinner with the given message 60 | func NewSpinner(message string) *Spinner { 61 | s := spinner.New() 62 | s.Spinner = spinner.Dot 63 | s.Style = s.Style.Foreground(s.Style.GetForeground()) 64 | 65 | ctx, cancel := context.WithCancel(context.Background()) 66 | 67 | model := spinnerModel{ 68 | spinner: s, 69 | message: message, 70 | } 71 | 72 | prog := tea.NewProgram(model, tea.WithOutput(os.Stderr), tea.WithoutCatchPanics()) 73 | 74 | return &Spinner{ 75 | model: s, 76 | done: make(chan struct{}), 77 | prog: prog, 78 | ctx: ctx, 79 | cancel: cancel, 80 | } 81 | } 82 | 83 | // Start begins the spinner animation 84 | func (s *Spinner) Start() { 85 | go func() { 86 | defer close(s.done) 87 | go func() { 88 | <-s.ctx.Done() 89 | s.prog.Send(quitMsg{}) 90 | }() 91 | _, err := s.prog.Run() 92 | if err != nil { 93 | fmt.Fprintf(os.Stderr, "Error running spinner: %v\n", err) 94 | } 95 | }() 96 | } 97 | 98 | // Stop ends the spinner animation 99 | func (s *Spinner) Stop() { 100 | s.cancel() 101 | <-s.done 102 | } 103 | -------------------------------------------------------------------------------- /internal/llm/agent/agent-tool.go: -------------------------------------------------------------------------------- 1 | package agent 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | 8 | "github.com/opencode-ai/opencode/internal/config" 9 | "github.com/opencode-ai/opencode/internal/llm/tools" 10 | "github.com/opencode-ai/opencode/internal/lsp" 11 | "github.com/opencode-ai/opencode/internal/message" 12 | "github.com/opencode-ai/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.Error != nil { 73 | return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", result.Error) 74 | } 75 | 76 | response := result.Message 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.Save(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/opencode-ai/opencode/internal/history" 7 | "github.com/opencode-ai/opencode/internal/llm/tools" 8 | "github.com/opencode-ai/opencode/internal/lsp" 9 | "github.com/opencode-ai/opencode/internal/message" 10 | "github.com/opencode-ai/opencode/internal/permission" 11 | "github.com/opencode-ai/opencode/internal/session" 12 | ) 13 | 14 | func CoderAgentTools( 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 | otherTools := GetMcpTools(ctx, permissions) 23 | if len(lspClients) > 0 { 24 | otherTools = append(otherTools, tools.NewDiagnosticsTool(lspClients)) 25 | } 26 | return append( 27 | []tools.BaseTool{ 28 | tools.NewBashTool(permissions), 29 | tools.NewEditTool(lspClients, permissions, history), 30 | tools.NewFetchTool(permissions), 31 | tools.NewGlobTool(), 32 | tools.NewGrepTool(), 33 | tools.NewLsTool(), 34 | tools.NewSourcegraphTool(), 35 | tools.NewViewTool(lspClients), 36 | tools.NewPatchTool(lspClients, permissions, history), 37 | tools.NewWriteTool(lspClients, permissions, history), 38 | NewAgentTool(sessions, messages, lspClients), 39 | }, otherTools..., 40 | ) 41 | } 42 | 43 | func TaskAgentTools(lspClients map[string]*lsp.Client) []tools.BaseTool { 44 | return []tools.BaseTool{ 45 | tools.NewGlobTool(), 46 | tools.NewGrepTool(), 47 | tools.NewLsTool(), 48 | tools.NewSourcegraphTool(), 49 | tools.NewViewTool(lspClients), 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /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 | Claude4Opus ModelID = "claude-4-opus" 13 | Claude4Sonnet ModelID = "claude-4-sonnet" 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 | Claude35Haiku: { 59 | ID: Claude35Haiku, 60 | Name: "Claude 3.5 Haiku", 61 | Provider: ProviderAnthropic, 62 | APIModel: "claude-3-5-haiku-latest", 63 | CostPer1MIn: 0.80, 64 | CostPer1MInCached: 1.0, 65 | CostPer1MOutCached: 0.08, 66 | CostPer1MOut: 4.0, 67 | ContextWindow: 200000, 68 | DefaultMaxTokens: 4096, 69 | SupportsAttachments: true, 70 | }, 71 | Claude3Opus: { 72 | ID: Claude3Opus, 73 | Name: "Claude 3 Opus", 74 | Provider: ProviderAnthropic, 75 | APIModel: "claude-3-opus-latest", 76 | CostPer1MIn: 15.0, 77 | CostPer1MInCached: 18.75, 78 | CostPer1MOutCached: 1.50, 79 | CostPer1MOut: 75.0, 80 | ContextWindow: 200000, 81 | DefaultMaxTokens: 4096, 82 | SupportsAttachments: true, 83 | }, 84 | Claude4Sonnet: { 85 | ID: Claude4Sonnet, 86 | Name: "Claude 4 Sonnet", 87 | Provider: ProviderAnthropic, 88 | APIModel: "claude-sonnet-4-20250514", 89 | CostPer1MIn: 3.0, 90 | CostPer1MInCached: 3.75, 91 | CostPer1MOutCached: 0.30, 92 | CostPer1MOut: 15.0, 93 | ContextWindow: 200000, 94 | DefaultMaxTokens: 50000, 95 | CanReason: true, 96 | SupportsAttachments: true, 97 | }, 98 | Claude4Opus: { 99 | ID: Claude4Opus, 100 | Name: "Claude 4 Opus", 101 | Provider: ProviderAnthropic, 102 | APIModel: "claude-opus-4-20250514", 103 | CostPer1MIn: 15.0, 104 | CostPer1MInCached: 18.75, 105 | CostPer1MOutCached: 1.50, 106 | CostPer1MOut: 75.0, 107 | ContextWindow: 200000, 108 | DefaultMaxTokens: 4096, 109 | SupportsAttachments: true, 110 | }, 111 | } 112 | -------------------------------------------------------------------------------- /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-05-06", 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 | ContextWindow: 128_000, // 10M when? 45 | SupportsAttachments: true, 46 | }, 47 | 48 | Llama4Maverick: { 49 | ID: Llama4Maverick, 50 | Name: "Llama4Maverick", 51 | Provider: ProviderGROQ, 52 | APIModel: "meta-llama/llama-4-maverick-17b-128e-instruct", 53 | CostPer1MIn: 0.20, 54 | CostPer1MInCached: 0, 55 | CostPer1MOutCached: 0, 56 | CostPer1MOut: 0.20, 57 | ContextWindow: 128_000, 58 | SupportsAttachments: true, 59 | }, 60 | 61 | Llama3_3_70BVersatile: { 62 | ID: Llama3_3_70BVersatile, 63 | Name: "Llama3_3_70BVersatile", 64 | Provider: ProviderGROQ, 65 | APIModel: "llama-3.3-70b-versatile", 66 | CostPer1MIn: 0.59, 67 | CostPer1MInCached: 0, 68 | CostPer1MOutCached: 0, 69 | CostPer1MOut: 0.79, 70 | ContextWindow: 128_000, 71 | SupportsAttachments: false, 72 | }, 73 | 74 | DeepseekR1DistillLlama70b: { 75 | ID: DeepseekR1DistillLlama70b, 76 | Name: "DeepseekR1DistillLlama70b", 77 | Provider: ProviderGROQ, 78 | APIModel: "deepseek-r1-distill-llama-70b", 79 | CostPer1MIn: 0.75, 80 | CostPer1MInCached: 0, 81 | CostPer1MOutCached: 0, 82 | CostPer1MOut: 0.99, 83 | ContextWindow: 128_000, 84 | CanReason: true, 85 | SupportsAttachments: false, 86 | }, 87 | } 88 | -------------------------------------------------------------------------------- /internal/llm/models/local.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "cmp" 5 | "encoding/json" 6 | "net/http" 7 | "net/url" 8 | "os" 9 | "regexp" 10 | "strings" 11 | "unicode" 12 | 13 | "github.com/opencode-ai/opencode/internal/logging" 14 | "github.com/spf13/viper" 15 | ) 16 | 17 | const ( 18 | ProviderLocal ModelProvider = "local" 19 | 20 | localModelsPath = "v1/models" 21 | lmStudioBetaModelsPath = "api/v0/models" 22 | ) 23 | 24 | func init() { 25 | if endpoint := os.Getenv("LOCAL_ENDPOINT"); endpoint != "" { 26 | localEndpoint, err := url.Parse(endpoint) 27 | if err != nil { 28 | logging.Debug("Failed to parse local endpoint", 29 | "error", err, 30 | "endpoint", endpoint, 31 | ) 32 | return 33 | } 34 | 35 | load := func(url *url.URL, path string) []localModel { 36 | url.Path = path 37 | return listLocalModels(url.String()) 38 | } 39 | 40 | models := load(localEndpoint, lmStudioBetaModelsPath) 41 | 42 | if len(models) == 0 { 43 | models = load(localEndpoint, localModelsPath) 44 | } 45 | 46 | if len(models) == 0 { 47 | logging.Debug("No local models found", 48 | "endpoint", endpoint, 49 | ) 50 | return 51 | } 52 | 53 | loadLocalModels(models) 54 | 55 | viper.SetDefault("providers.local.apiKey", "dummy") 56 | ProviderPopularity[ProviderLocal] = 0 57 | } 58 | } 59 | 60 | type localModelList struct { 61 | Data []localModel `json:"data"` 62 | } 63 | 64 | type localModel struct { 65 | ID string `json:"id"` 66 | Object string `json:"object"` 67 | Type string `json:"type"` 68 | Publisher string `json:"publisher"` 69 | Arch string `json:"arch"` 70 | CompatibilityType string `json:"compatibility_type"` 71 | Quantization string `json:"quantization"` 72 | State string `json:"state"` 73 | MaxContextLength int64 `json:"max_context_length"` 74 | LoadedContextLength int64 `json:"loaded_context_length"` 75 | } 76 | 77 | func listLocalModels(modelsEndpoint string) []localModel { 78 | res, err := http.Get(modelsEndpoint) 79 | if err != nil { 80 | logging.Debug("Failed to list local models", 81 | "error", err, 82 | "endpoint", modelsEndpoint, 83 | ) 84 | } 85 | defer res.Body.Close() 86 | 87 | if res.StatusCode != http.StatusOK { 88 | logging.Debug("Failed to list local models", 89 | "status", res.StatusCode, 90 | "endpoint", modelsEndpoint, 91 | ) 92 | } 93 | 94 | var modelList localModelList 95 | if err = json.NewDecoder(res.Body).Decode(&modelList); err != nil { 96 | logging.Debug("Failed to list local models", 97 | "error", err, 98 | "endpoint", modelsEndpoint, 99 | ) 100 | } 101 | 102 | var supportedModels []localModel 103 | for _, model := range modelList.Data { 104 | if strings.HasSuffix(modelsEndpoint, lmStudioBetaModelsPath) { 105 | if model.Object != "model" || model.Type != "llm" { 106 | logging.Debug("Skipping unsupported LMStudio model", 107 | "endpoint", modelsEndpoint, 108 | "id", model.ID, 109 | "object", model.Object, 110 | "type", model.Type, 111 | ) 112 | 113 | continue 114 | } 115 | } 116 | 117 | supportedModels = append(supportedModels, model) 118 | } 119 | 120 | return supportedModels 121 | } 122 | 123 | func loadLocalModels(models []localModel) { 124 | for i, m := range models { 125 | model := convertLocalModel(m) 126 | SupportedModels[model.ID] = model 127 | 128 | if i == 0 || m.State == "loaded" { 129 | viper.SetDefault("agents.coder.model", model.ID) 130 | viper.SetDefault("agents.summarizer.model", model.ID) 131 | viper.SetDefault("agents.task.model", model.ID) 132 | viper.SetDefault("agents.title.model", model.ID) 133 | } 134 | } 135 | } 136 | 137 | func convertLocalModel(model localModel) Model { 138 | return Model{ 139 | ID: ModelID("local." + model.ID), 140 | Name: friendlyModelName(model.ID), 141 | Provider: ProviderLocal, 142 | APIModel: model.ID, 143 | ContextWindow: cmp.Or(model.LoadedContextLength, 4096), 144 | DefaultMaxTokens: cmp.Or(model.LoadedContextLength, 4096), 145 | CanReason: true, 146 | SupportsAttachments: true, 147 | } 148 | } 149 | 150 | var modelInfoRegex = regexp.MustCompile(`(?i)^([a-z0-9]+)(?:[-_]?([rv]?\d[\.\d]*))?(?:[-_]?([a-z]+))?.*`) 151 | 152 | func friendlyModelName(modelID string) string { 153 | mainID := modelID 154 | tag := "" 155 | 156 | if slash := strings.LastIndex(mainID, "/"); slash != -1 { 157 | mainID = mainID[slash+1:] 158 | } 159 | 160 | if at := strings.Index(modelID, "@"); at != -1 { 161 | mainID = modelID[:at] 162 | tag = modelID[at+1:] 163 | } 164 | 165 | match := modelInfoRegex.FindStringSubmatch(mainID) 166 | if match == nil { 167 | return modelID 168 | } 169 | 170 | capitalize := func(s string) string { 171 | if s == "" { 172 | return "" 173 | } 174 | runes := []rune(s) 175 | runes[0] = unicode.ToUpper(runes[0]) 176 | return string(runes) 177 | } 178 | 179 | family := capitalize(match[1]) 180 | version := "" 181 | label := "" 182 | 183 | if len(match) > 2 && match[2] != "" { 184 | version = strings.ToUpper(match[2]) 185 | } 186 | 187 | if len(match) > 3 && match[3] != "" { 188 | label = capitalize(match[3]) 189 | } 190 | 191 | var parts []string 192 | if family != "" { 193 | parts = append(parts, family) 194 | } 195 | if version != "" { 196 | parts = append(parts, version) 197 | } 198 | if label != "" { 199 | parts = append(parts, label) 200 | } 201 | if tag != "" { 202 | parts = append(parts, tag) 203 | } 204 | 205 | return strings.Join(parts, " ") 206 | } 207 | -------------------------------------------------------------------------------- /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 | // Model IDs 26 | const ( // GEMINI 27 | // Bedrock 28 | BedrockClaude37Sonnet ModelID = "bedrock.claude-3.7-sonnet" 29 | ) 30 | 31 | const ( 32 | ProviderBedrock ModelProvider = "bedrock" 33 | // ForTests 34 | ProviderMock ModelProvider = "__mock" 35 | ) 36 | 37 | // Providers in order of popularity 38 | var ProviderPopularity = map[ModelProvider]int{ 39 | ProviderAnthropic: 1, 40 | ProviderOpenAI: 2, 41 | ProviderGemini: 3, 42 | ProviderGROQ: 4, 43 | ProviderOpenRouter: 5, 44 | ProviderBedrock: 6, 45 | ProviderAzure: 7, 46 | ProviderVertexAI: 8, 47 | } 48 | 49 | var SupportedModels = map[ModelID]Model{ 50 | // 51 | // // GEMINI 52 | // GEMINI25: { 53 | // ID: GEMINI25, 54 | // Name: "Gemini 2.5 Pro", 55 | // Provider: ProviderGemini, 56 | // APIModel: "gemini-2.5-pro-exp-03-25", 57 | // CostPer1MIn: 0, 58 | // CostPer1MInCached: 0, 59 | // CostPer1MOutCached: 0, 60 | // CostPer1MOut: 0, 61 | // }, 62 | // 63 | // GRMINI20Flash: { 64 | // ID: GRMINI20Flash, 65 | // Name: "Gemini 2.0 Flash", 66 | // Provider: ProviderGemini, 67 | // APIModel: "gemini-2.0-flash", 68 | // CostPer1MIn: 0.1, 69 | // CostPer1MInCached: 0, 70 | // CostPer1MOutCached: 0.025, 71 | // CostPer1MOut: 0.4, 72 | // }, 73 | // 74 | // // Bedrock 75 | BedrockClaude37Sonnet: { 76 | ID: BedrockClaude37Sonnet, 77 | Name: "Bedrock: Claude 3.7 Sonnet", 78 | Provider: ProviderBedrock, 79 | APIModel: "anthropic.claude-3-7-sonnet-20250219-v1:0", 80 | CostPer1MIn: 3.0, 81 | CostPer1MInCached: 3.75, 82 | CostPer1MOutCached: 0.30, 83 | CostPer1MOut: 15.0, 84 | }, 85 | } 86 | 87 | func init() { 88 | maps.Copy(SupportedModels, AnthropicModels) 89 | maps.Copy(SupportedModels, OpenAIModels) 90 | maps.Copy(SupportedModels, GeminiModels) 91 | maps.Copy(SupportedModels, GroqModels) 92 | maps.Copy(SupportedModels, AzureModels) 93 | maps.Copy(SupportedModels, OpenRouterModels) 94 | maps.Copy(SupportedModels, XAIModels) 95 | maps.Copy(SupportedModels, VertexAIGeminiModels) 96 | } 97 | -------------------------------------------------------------------------------- /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/opencode-ai/opencode/internal/config" 11 | "github.com/opencode-ai/opencode/internal/llm/models" 12 | "github.com/opencode-ai/opencode/internal/logging" 13 | ) 14 | 15 | func GetAgentPrompt(agentName config.AgentName, provider models.ModelProvider) string { 16 | basePrompt := "" 17 | switch agentName { 18 | case config.AgentCoder: 19 | basePrompt = CoderPrompt(provider) 20 | case config.AgentTitle: 21 | basePrompt = TitlePrompt(provider) 22 | case config.AgentTask: 23 | basePrompt = TaskPrompt(provider) 24 | case config.AgentSummarizer: 25 | basePrompt = SummarizerPrompt(provider) 26 | default: 27 | basePrompt = "You are a helpful assistant" 28 | } 29 | 30 | if agentName == config.AgentCoder || agentName == config.AgentTask { 31 | // Add context from project-specific instruction files if they exist 32 | contextContent := getContextFromPaths() 33 | logging.Debug("Context content", "Context", contextContent) 34 | if contextContent != "" { 35 | return fmt.Sprintf("%s\n\n# Project-Specific Context\n Make sure to follow the instructions in the context below\n%s", basePrompt, contextContent) 36 | } 37 | } 38 | return basePrompt 39 | } 40 | 41 | var ( 42 | onceContext sync.Once 43 | contextContent string 44 | ) 45 | 46 | func getContextFromPaths() string { 47 | onceContext.Do(func() { 48 | var ( 49 | cfg = config.Get() 50 | workDir = cfg.WorkingDir 51 | contextPaths = cfg.ContextPaths 52 | ) 53 | 54 | contextContent = processContextPaths(workDir, contextPaths) 55 | }) 56 | 57 | return contextContent 58 | } 59 | 60 | func processContextPaths(workDir string, paths []string) string { 61 | var ( 62 | wg sync.WaitGroup 63 | resultCh = make(chan string) 64 | ) 65 | 66 | // Track processed files to avoid duplicates 67 | processedFiles := make(map[string]bool) 68 | var processedMutex sync.Mutex 69 | 70 | for _, path := range paths { 71 | wg.Add(1) 72 | go func(p string) { 73 | defer wg.Done() 74 | 75 | if strings.HasSuffix(p, "/") { 76 | filepath.WalkDir(filepath.Join(workDir, p), func(path string, d os.DirEntry, err error) error { 77 | if err != nil { 78 | return err 79 | } 80 | if !d.IsDir() { 81 | // Check if we've already processed this file (case-insensitive) 82 | processedMutex.Lock() 83 | lowerPath := strings.ToLower(path) 84 | if !processedFiles[lowerPath] { 85 | processedFiles[lowerPath] = true 86 | processedMutex.Unlock() 87 | 88 | if result := processFile(path); result != "" { 89 | resultCh <- result 90 | } 91 | } else { 92 | processedMutex.Unlock() 93 | } 94 | } 95 | return nil 96 | }) 97 | } else { 98 | fullPath := filepath.Join(workDir, p) 99 | 100 | // Check if we've already processed this file (case-insensitive) 101 | processedMutex.Lock() 102 | lowerPath := strings.ToLower(fullPath) 103 | if !processedFiles[lowerPath] { 104 | processedFiles[lowerPath] = true 105 | processedMutex.Unlock() 106 | 107 | result := processFile(fullPath) 108 | if result != "" { 109 | resultCh <- result 110 | } 111 | } else { 112 | processedMutex.Unlock() 113 | } 114 | } 115 | }(path) 116 | } 117 | 118 | go func() { 119 | wg.Wait() 120 | close(resultCh) 121 | }() 122 | 123 | results := make([]string, 0) 124 | for result := range resultCh { 125 | results = append(results, result) 126 | } 127 | 128 | return strings.Join(results, "\n") 129 | } 130 | 131 | func processFile(filePath string) string { 132 | content, err := os.ReadFile(filePath) 133 | if err != nil { 134 | return "" 135 | } 136 | return "# From:" + filePath + "\n" + string(content) 137 | } 138 | -------------------------------------------------------------------------------- /internal/llm/prompt/prompt_test.go: -------------------------------------------------------------------------------- 1 | package prompt 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/opencode-ai/opencode/internal/config" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestGetContextFromPaths(t *testing.T) { 15 | t.Parallel() 16 | 17 | tmpDir := t.TempDir() 18 | _, err := config.Load(tmpDir, false) 19 | if err != nil { 20 | t.Fatalf("Failed to load config: %v", err) 21 | } 22 | cfg := config.Get() 23 | cfg.WorkingDir = tmpDir 24 | cfg.ContextPaths = []string{ 25 | "file.txt", 26 | "directory/", 27 | } 28 | testFiles := []string{ 29 | "file.txt", 30 | "directory/file_a.txt", 31 | "directory/file_b.txt", 32 | "directory/file_c.txt", 33 | } 34 | 35 | createTestFiles(t, tmpDir, testFiles) 36 | 37 | context := getContextFromPaths() 38 | 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) 39 | assert.Equal(t, expectedContext, context) 40 | } 41 | 42 | func createTestFiles(t *testing.T, tmpDir string, testFiles []string) { 43 | t.Helper() 44 | for _, path := range testFiles { 45 | fullPath := filepath.Join(tmpDir, path) 46 | if path[len(path)-1] == '/' { 47 | err := os.MkdirAll(fullPath, 0755) 48 | require.NoError(t, err) 49 | } else { 50 | dir := filepath.Dir(fullPath) 51 | err := os.MkdirAll(dir, 0755) 52 | require.NoError(t, err) 53 | err = os.WriteFile(fullPath, []byte(path+": test content"), 0644) 54 | require.NoError(t, err) 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /internal/llm/prompt/summarizer.go: -------------------------------------------------------------------------------- 1 | package prompt 2 | 3 | import "github.com/opencode-ai/opencode/internal/llm/models" 4 | 5 | func SummarizerPrompt(_ models.ModelProvider) string { 6 | return `You are a helpful AI assistant tasked with summarizing conversations. 7 | 8 | When asked to summarize, provide a detailed but concise summary of the conversation. 9 | Focus on information that would be helpful for continuing the conversation, including: 10 | - What was done 11 | - What is currently being worked on 12 | - Which files are being modified 13 | - What needs to be done next 14 | 15 | Your summary should be comprehensive enough to provide context but concise enough to be quickly understood.` 16 | } 17 | -------------------------------------------------------------------------------- /internal/llm/prompt/task.go: -------------------------------------------------------------------------------- 1 | package prompt 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/opencode-ai/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/opencode-ai/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/opencode-ai/opencode/internal/llm/tools" 11 | "github.com/opencode-ai/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 | 102 | -------------------------------------------------------------------------------- /internal/llm/provider/vertexai.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "os" 6 | 7 | "github.com/opencode-ai/opencode/internal/logging" 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 | logging.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/glob.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "os/exec" 9 | "path/filepath" 10 | "sort" 11 | "strings" 12 | 13 | "github.com/opencode-ai/opencode/internal/config" 14 | "github.com/opencode-ai/opencode/internal/fileutil" 15 | "github.com/opencode-ai/opencode/internal/logging" 16 | ) 17 | 18 | const ( 19 | GlobToolName = "glob" 20 | globDescription = `Fast file pattern matching tool that finds files by name and pattern, returning matching paths sorted by modification time (newest first). 21 | 22 | WHEN TO USE THIS TOOL: 23 | - Use when you need to find files by name patterns or extensions 24 | - Great for finding specific file types across a directory structure 25 | - Useful for discovering files that match certain naming conventions 26 | 27 | HOW TO USE: 28 | - Provide a glob pattern to match against file paths 29 | - Optionally specify a starting directory (defaults to current working directory) 30 | - Results are sorted with most recently modified files first 31 | 32 | GLOB PATTERN SYNTAX: 33 | - '*' matches any sequence of non-separator characters 34 | - '**' matches any sequence of characters, including separators 35 | - '?' matches any single non-separator character 36 | - '[...]' matches any character in the brackets 37 | - '[!...]' matches any character not in the brackets 38 | 39 | COMMON PATTERN EXAMPLES: 40 | - '*.js' - Find all JavaScript files in the current directory 41 | - '**/*.js' - Find all JavaScript files in any subdirectory 42 | - 'src/**/*.{ts,tsx}' - Find all TypeScript files in the src directory 43 | - '*.{html,css,js}' - Find all HTML, CSS, and JS files 44 | 45 | LIMITATIONS: 46 | - Results are limited to 100 files (newest first) 47 | - Does not search file contents (use Grep tool for that) 48 | - Hidden files (starting with '.') are skipped 49 | 50 | TIPS: 51 | - For the most useful results, combine with the Grep tool: first find files with Glob, then search their contents with Grep 52 | - When doing iterative exploration that may require multiple rounds of searching, consider using the Agent tool instead 53 | - Always check if results are truncated and refine your search pattern if needed` 54 | ) 55 | 56 | type GlobParams struct { 57 | Pattern string `json:"pattern"` 58 | Path string `json:"path"` 59 | } 60 | 61 | type GlobResponseMetadata struct { 62 | NumberOfFiles int `json:"number_of_files"` 63 | Truncated bool `json:"truncated"` 64 | } 65 | 66 | type globTool struct{} 67 | 68 | func NewGlobTool() BaseTool { 69 | return &globTool{} 70 | } 71 | 72 | func (g *globTool) Info() ToolInfo { 73 | return ToolInfo{ 74 | Name: GlobToolName, 75 | Description: globDescription, 76 | Parameters: map[string]any{ 77 | "pattern": map[string]any{ 78 | "type": "string", 79 | "description": "The glob pattern to match files against", 80 | }, 81 | "path": map[string]any{ 82 | "type": "string", 83 | "description": "The directory to search in. Defaults to the current working directory.", 84 | }, 85 | }, 86 | Required: []string{"pattern"}, 87 | } 88 | } 89 | 90 | func (g *globTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) { 91 | var params GlobParams 92 | if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil { 93 | return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil 94 | } 95 | 96 | if params.Pattern == "" { 97 | return NewTextErrorResponse("pattern is required"), nil 98 | } 99 | 100 | searchPath := params.Path 101 | if searchPath == "" { 102 | searchPath = config.WorkingDirectory() 103 | } 104 | 105 | files, truncated, err := globFiles(params.Pattern, searchPath, 100) 106 | if err != nil { 107 | return ToolResponse{}, fmt.Errorf("error finding files: %w", err) 108 | } 109 | 110 | var output string 111 | if len(files) == 0 { 112 | output = "No files found" 113 | } else { 114 | output = strings.Join(files, "\n") 115 | if truncated { 116 | output += "\n\n(Results are truncated. Consider using a more specific path or pattern.)" 117 | } 118 | } 119 | 120 | return WithResponseMetadata( 121 | NewTextResponse(output), 122 | GlobResponseMetadata{ 123 | NumberOfFiles: len(files), 124 | Truncated: truncated, 125 | }, 126 | ), nil 127 | } 128 | 129 | func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) { 130 | cmdRg := fileutil.GetRgCmd(pattern) 131 | if cmdRg != nil { 132 | cmdRg.Dir = searchPath 133 | matches, err := runRipgrep(cmdRg, searchPath, limit) 134 | if err == nil { 135 | return matches, len(matches) >= limit && limit > 0, nil 136 | } 137 | logging.Warn(fmt.Sprintf("Ripgrep execution failed: %v. Falling back to doublestar.", err)) 138 | } 139 | 140 | return fileutil.GlobWithDoublestar(pattern, searchPath, limit) 141 | } 142 | 143 | func runRipgrep(cmd *exec.Cmd, searchRoot string, limit int) ([]string, error) { 144 | out, err := cmd.CombinedOutput() 145 | if err != nil { 146 | if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 { 147 | return nil, nil 148 | } 149 | return nil, fmt.Errorf("ripgrep: %w\n%s", err, out) 150 | } 151 | 152 | var matches []string 153 | for _, p := range bytes.Split(out, []byte{0}) { 154 | if len(p) == 0 { 155 | continue 156 | } 157 | absPath := string(p) 158 | if !filepath.IsAbs(absPath) { 159 | absPath = filepath.Join(searchRoot, absPath) 160 | } 161 | if fileutil.SkipHidden(absPath) { 162 | continue 163 | } 164 | matches = append(matches, absPath) 165 | } 166 | 167 | sort.SliceStable(matches, func(i, j int) bool { 168 | return len(matches[i]) < len(matches[j]) 169 | }) 170 | 171 | if limit > 0 && len(matches) > limit { 172 | matches = matches[:limit] 173 | } 174 | return matches, nil 175 | } 176 | -------------------------------------------------------------------------------- /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/logging/logger.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "os" 7 | "runtime/debug" 8 | "time" 9 | ) 10 | 11 | func Info(msg string, args ...any) { 12 | slog.Info(msg, args...) 13 | } 14 | 15 | func Debug(msg string, args ...any) { 16 | slog.Debug(msg, args...) 17 | } 18 | 19 | func Warn(msg string, args ...any) { 20 | slog.Warn(msg, args...) 21 | } 22 | 23 | func Error(msg string, args ...any) { 24 | slog.Error(msg, args...) 25 | } 26 | 27 | func InfoPersist(msg string, args ...any) { 28 | args = append(args, persistKeyArg, true) 29 | slog.Info(msg, args...) 30 | } 31 | 32 | func DebugPersist(msg string, args ...any) { 33 | args = append(args, persistKeyArg, true) 34 | slog.Debug(msg, args...) 35 | } 36 | 37 | func WarnPersist(msg string, args ...any) { 38 | args = append(args, persistKeyArg, true) 39 | slog.Warn(msg, args...) 40 | } 41 | 42 | func ErrorPersist(msg string, args ...any) { 43 | args = append(args, persistKeyArg, true) 44 | slog.Error(msg, args...) 45 | } 46 | 47 | // RecoverPanic is a common function to handle panics gracefully. 48 | // It logs the error, creates a panic log file with stack trace, 49 | // and executes an optional cleanup function before returning. 50 | func RecoverPanic(name string, cleanup func()) { 51 | if r := recover(); r != nil { 52 | // Log the panic 53 | ErrorPersist(fmt.Sprintf("Panic in %s: %v", name, r)) 54 | 55 | // Create a timestamped panic log file 56 | timestamp := time.Now().Format("20060102-150405") 57 | filename := fmt.Sprintf("opencode-panic-%s-%s.log", name, timestamp) 58 | 59 | file, err := os.Create(filename) 60 | if err != nil { 61 | ErrorPersist(fmt.Sprintf("Failed to create panic log: %v", err)) 62 | } else { 63 | defer file.Close() 64 | 65 | // Write panic information and stack trace 66 | fmt.Fprintf(file, "Panic in %s: %v\n\n", name, r) 67 | fmt.Fprintf(file, "Time: %s\n\n", time.Now().Format(time.RFC3339)) 68 | fmt.Fprintf(file, "Stack Trace:\n%s\n", debug.Stack()) 69 | 70 | InfoPersist(fmt.Sprintf("Panic details written to %s", filename)) 71 | } 72 | 73 | // Execute cleanup function if provided 74 | if cleanup != nil { 75 | cleanup() 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /internal/logging/message.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // LogMessage is the event payload for a log message 8 | type LogMessage struct { 9 | ID string 10 | Time time.Time 11 | Level string 12 | Persist bool // used when we want to show the mesage in the status bar 13 | PersistTime time.Duration // used when we want to show the mesage in the status bar 14 | Message string `json:"msg"` 15 | Attributes []Attr 16 | } 17 | 18 | type Attr struct { 19 | Key string 20 | Value string 21 | } 22 | -------------------------------------------------------------------------------- /internal/logging/writer.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "strings" 8 | "sync" 9 | "time" 10 | 11 | "github.com/go-logfmt/logfmt" 12 | "github.com/opencode-ai/opencode/internal/pubsub" 13 | ) 14 | 15 | const ( 16 | persistKeyArg = "$_persist" 17 | PersistTimeArg = "$_persist_time" 18 | ) 19 | 20 | type LogData struct { 21 | messages []LogMessage 22 | *pubsub.Broker[LogMessage] 23 | lock sync.Mutex 24 | } 25 | 26 | func (l *LogData) Add(msg LogMessage) { 27 | l.lock.Lock() 28 | defer l.lock.Unlock() 29 | l.messages = append(l.messages, msg) 30 | l.Publish(pubsub.CreatedEvent, msg) 31 | } 32 | 33 | func (l *LogData) List() []LogMessage { 34 | l.lock.Lock() 35 | defer l.lock.Unlock() 36 | return l.messages 37 | } 38 | 39 | var defaultLogData = &LogData{ 40 | messages: make([]LogMessage, 0), 41 | Broker: pubsub.NewBroker[LogMessage](), 42 | } 43 | 44 | type writer struct{} 45 | 46 | func (w *writer) Write(p []byte) (int, error) { 47 | d := logfmt.NewDecoder(bytes.NewReader(p)) 48 | for d.ScanRecord() { 49 | msg := LogMessage{ 50 | ID: fmt.Sprintf("%d", time.Now().UnixNano()), 51 | Time: time.Now(), 52 | } 53 | for d.ScanKeyval() { 54 | switch string(d.Key()) { 55 | case "time": 56 | parsed, err := time.Parse(time.RFC3339, string(d.Value())) 57 | if err != nil { 58 | return 0, fmt.Errorf("parsing time: %w", err) 59 | } 60 | msg.Time = parsed 61 | case "level": 62 | msg.Level = strings.ToLower(string(d.Value())) 63 | case "msg": 64 | msg.Message = string(d.Value()) 65 | default: 66 | if string(d.Key()) == persistKeyArg { 67 | msg.Persist = true 68 | } else if string(d.Key()) == PersistTimeArg { 69 | parsed, err := time.ParseDuration(string(d.Value())) 70 | if err != nil { 71 | continue 72 | } 73 | msg.PersistTime = parsed 74 | } else { 75 | msg.Attributes = append(msg.Attributes, Attr{ 76 | Key: string(d.Key()), 77 | Value: string(d.Value()), 78 | }) 79 | } 80 | } 81 | } 82 | defaultLogData.Add(msg) 83 | } 84 | if d.Err() != nil { 85 | return 0, d.Err() 86 | } 87 | return len(p), nil 88 | } 89 | 90 | func NewWriter() *writer { 91 | w := &writer{} 92 | return w 93 | } 94 | 95 | func Subscribe(ctx context.Context) <-chan pubsub.Event[LogMessage] { 96 | return defaultLogData.Subscribe(ctx) 97 | } 98 | 99 | func List() []LogMessage { 100 | return defaultLogData.List() 101 | } 102 | -------------------------------------------------------------------------------- /internal/lsp/handlers.go: -------------------------------------------------------------------------------- 1 | package lsp 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/opencode-ai/opencode/internal/config" 7 | "github.com/opencode-ai/opencode/internal/logging" 8 | "github.com/opencode-ai/opencode/internal/lsp/protocol" 9 | "github.com/opencode-ai/opencode/internal/lsp/util" 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 | logging.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 | logging.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 | logging.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 | logging.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 | logging.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 | logging.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/opencode-ai/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/permission/permission.go: -------------------------------------------------------------------------------- 1 | package permission 2 | 3 | import ( 4 | "errors" 5 | "path/filepath" 6 | "slices" 7 | "sync" 8 | 9 | "github.com/google/uuid" 10 | "github.com/opencode-ai/opencode/internal/config" 11 | "github.com/opencode-ai/opencode/internal/pubsub" 12 | ) 13 | 14 | var ErrorPermissionDenied = errors.New("permission denied") 15 | 16 | type CreatePermissionRequest struct { 17 | SessionID string `json:"session_id"` 18 | ToolName string `json:"tool_name"` 19 | Description string `json:"description"` 20 | Action string `json:"action"` 21 | Params any `json:"params"` 22 | Path string `json:"path"` 23 | } 24 | 25 | type PermissionRequest struct { 26 | ID string `json:"id"` 27 | SessionID string `json:"session_id"` 28 | ToolName string `json:"tool_name"` 29 | Description string `json:"description"` 30 | Action string `json:"action"` 31 | Params any `json:"params"` 32 | Path string `json:"path"` 33 | } 34 | 35 | type Service interface { 36 | pubsub.Suscriber[PermissionRequest] 37 | GrantPersistant(permission PermissionRequest) 38 | Grant(permission PermissionRequest) 39 | Deny(permission PermissionRequest) 40 | Request(opts CreatePermissionRequest) bool 41 | AutoApproveSession(sessionID string) 42 | } 43 | 44 | type permissionService struct { 45 | *pubsub.Broker[PermissionRequest] 46 | 47 | sessionPermissions []PermissionRequest 48 | pendingRequests sync.Map 49 | autoApproveSessions []string 50 | } 51 | 52 | func (s *permissionService) GrantPersistant(permission PermissionRequest) { 53 | respCh, ok := s.pendingRequests.Load(permission.ID) 54 | if ok { 55 | respCh.(chan bool) <- true 56 | } 57 | s.sessionPermissions = append(s.sessionPermissions, permission) 58 | } 59 | 60 | func (s *permissionService) Grant(permission PermissionRequest) { 61 | respCh, ok := s.pendingRequests.Load(permission.ID) 62 | if ok { 63 | respCh.(chan bool) <- true 64 | } 65 | } 66 | 67 | func (s *permissionService) Deny(permission PermissionRequest) { 68 | respCh, ok := s.pendingRequests.Load(permission.ID) 69 | if ok { 70 | respCh.(chan bool) <- false 71 | } 72 | } 73 | 74 | func (s *permissionService) Request(opts CreatePermissionRequest) bool { 75 | if slices.Contains(s.autoApproveSessions, opts.SessionID) { 76 | return true 77 | } 78 | dir := filepath.Dir(opts.Path) 79 | if dir == "." { 80 | dir = config.WorkingDirectory() 81 | } 82 | permission := PermissionRequest{ 83 | ID: uuid.New().String(), 84 | Path: dir, 85 | SessionID: opts.SessionID, 86 | ToolName: opts.ToolName, 87 | Description: opts.Description, 88 | Action: opts.Action, 89 | Params: opts.Params, 90 | } 91 | 92 | for _, p := range s.sessionPermissions { 93 | if p.ToolName == permission.ToolName && p.Action == permission.Action && p.SessionID == permission.SessionID && p.Path == permission.Path { 94 | return true 95 | } 96 | } 97 | 98 | respCh := make(chan bool, 1) 99 | 100 | s.pendingRequests.Store(permission.ID, respCh) 101 | defer s.pendingRequests.Delete(permission.ID) 102 | 103 | s.Publish(pubsub.CreatedEvent, permission) 104 | 105 | // Wait for the response with a timeout 106 | resp := <-respCh 107 | return resp 108 | } 109 | 110 | func (s *permissionService) AutoApproveSession(sessionID string) { 111 | s.autoApproveSessions = append(s.autoApproveSessions, sessionID) 112 | } 113 | 114 | func NewPermissionService() Service { 115 | return &permissionService{ 116 | Broker: pubsub.NewBroker[PermissionRequest](), 117 | sessionPermissions: make([]PermissionRequest, 0), 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /internal/pubsub/broker.go: -------------------------------------------------------------------------------- 1 | package pubsub 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | ) 7 | 8 | const bufferSize = 64 9 | 10 | type Broker[T any] struct { 11 | subs map[chan Event[T]]struct{} 12 | mu sync.RWMutex 13 | done chan struct{} 14 | subCount int 15 | maxEvents int 16 | } 17 | 18 | func NewBroker[T any]() *Broker[T] { 19 | return NewBrokerWithOptions[T](bufferSize, 1000) 20 | } 21 | 22 | func NewBrokerWithOptions[T any](channelBufferSize, maxEvents int) *Broker[T] { 23 | b := &Broker[T]{ 24 | subs: make(map[chan Event[T]]struct{}), 25 | done: make(chan struct{}), 26 | subCount: 0, 27 | maxEvents: maxEvents, 28 | } 29 | return b 30 | } 31 | 32 | func (b *Broker[T]) Shutdown() { 33 | select { 34 | case <-b.done: // Already closed 35 | return 36 | default: 37 | close(b.done) 38 | } 39 | 40 | b.mu.Lock() 41 | defer b.mu.Unlock() 42 | 43 | for ch := range b.subs { 44 | delete(b.subs, ch) 45 | close(ch) 46 | } 47 | 48 | b.subCount = 0 49 | } 50 | 51 | func (b *Broker[T]) Subscribe(ctx context.Context) <-chan Event[T] { 52 | b.mu.Lock() 53 | defer b.mu.Unlock() 54 | 55 | select { 56 | case <-b.done: 57 | ch := make(chan Event[T]) 58 | close(ch) 59 | return ch 60 | default: 61 | } 62 | 63 | sub := make(chan Event[T], bufferSize) 64 | b.subs[sub] = struct{}{} 65 | b.subCount++ 66 | 67 | go func() { 68 | <-ctx.Done() 69 | 70 | b.mu.Lock() 71 | defer b.mu.Unlock() 72 | 73 | select { 74 | case <-b.done: 75 | return 76 | default: 77 | } 78 | 79 | delete(b.subs, sub) 80 | close(sub) 81 | b.subCount-- 82 | }() 83 | 84 | return sub 85 | } 86 | 87 | func (b *Broker[T]) GetSubscriberCount() int { 88 | b.mu.RLock() 89 | defer b.mu.RUnlock() 90 | return b.subCount 91 | } 92 | 93 | func (b *Broker[T]) Publish(t EventType, payload T) { 94 | b.mu.RLock() 95 | select { 96 | case <-b.done: 97 | b.mu.RUnlock() 98 | return 99 | default: 100 | } 101 | 102 | subscribers := make([]chan Event[T], 0, len(b.subs)) 103 | for sub := range b.subs { 104 | subscribers = append(subscribers, sub) 105 | } 106 | b.mu.RUnlock() 107 | 108 | event := Event[T]{Type: t, Payload: payload} 109 | 110 | for _, sub := range subscribers { 111 | select { 112 | case sub <- event: 113 | default: 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /internal/pubsub/events.go: -------------------------------------------------------------------------------- 1 | package pubsub 2 | 3 | import "context" 4 | 5 | const ( 6 | CreatedEvent EventType = "created" 7 | UpdatedEvent EventType = "updated" 8 | DeletedEvent EventType = "deleted" 9 | ) 10 | 11 | type Suscriber[T any] interface { 12 | Subscribe(context.Context) <-chan Event[T] 13 | } 14 | 15 | type ( 16 | // EventType identifies the type of event 17 | EventType string 18 | 19 | // Event represents an event in the lifecycle of a resource 20 | Event[T any] struct { 21 | Type EventType 22 | Payload T 23 | } 24 | 25 | Publisher[T any] interface { 26 | Publish(EventType, T) 27 | } 28 | ) 29 | -------------------------------------------------------------------------------- /internal/session/session.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | 7 | "github.com/google/uuid" 8 | "github.com/opencode-ai/opencode/internal/db" 9 | "github.com/opencode-ai/opencode/internal/pubsub" 10 | ) 11 | 12 | type Session struct { 13 | ID string 14 | ParentSessionID string 15 | Title string 16 | MessageCount int64 17 | PromptTokens int64 18 | CompletionTokens int64 19 | SummaryMessageID string 20 | Cost float64 21 | CreatedAt int64 22 | UpdatedAt int64 23 | } 24 | 25 | type Service interface { 26 | pubsub.Suscriber[Session] 27 | Create(ctx context.Context, title string) (Session, error) 28 | CreateTitleSession(ctx context.Context, parentSessionID string) (Session, error) 29 | CreateTaskSession(ctx context.Context, toolCallID, parentSessionID, title string) (Session, error) 30 | Get(ctx context.Context, id string) (Session, error) 31 | List(ctx context.Context) ([]Session, error) 32 | Save(ctx context.Context, session Session) (Session, error) 33 | Delete(ctx context.Context, id string) error 34 | } 35 | 36 | type service struct { 37 | *pubsub.Broker[Session] 38 | q db.Querier 39 | } 40 | 41 | func (s *service) Create(ctx context.Context, title string) (Session, error) { 42 | dbSession, err := s.q.CreateSession(ctx, db.CreateSessionParams{ 43 | ID: uuid.New().String(), 44 | Title: title, 45 | }) 46 | if err != nil { 47 | return Session{}, err 48 | } 49 | session := s.fromDBItem(dbSession) 50 | s.Publish(pubsub.CreatedEvent, session) 51 | return session, nil 52 | } 53 | 54 | func (s *service) CreateTaskSession(ctx context.Context, toolCallID, parentSessionID, title string) (Session, error) { 55 | dbSession, err := s.q.CreateSession(ctx, db.CreateSessionParams{ 56 | ID: toolCallID, 57 | ParentSessionID: sql.NullString{String: parentSessionID, Valid: true}, 58 | Title: title, 59 | }) 60 | if err != nil { 61 | return Session{}, err 62 | } 63 | session := s.fromDBItem(dbSession) 64 | s.Publish(pubsub.CreatedEvent, session) 65 | return session, nil 66 | } 67 | 68 | func (s *service) CreateTitleSession(ctx context.Context, parentSessionID string) (Session, error) { 69 | dbSession, err := s.q.CreateSession(ctx, db.CreateSessionParams{ 70 | ID: "title-" + parentSessionID, 71 | ParentSessionID: sql.NullString{String: parentSessionID, Valid: true}, 72 | Title: "Generate a title", 73 | }) 74 | if err != nil { 75 | return Session{}, err 76 | } 77 | session := s.fromDBItem(dbSession) 78 | s.Publish(pubsub.CreatedEvent, session) 79 | return session, nil 80 | } 81 | 82 | func (s *service) Delete(ctx context.Context, id string) error { 83 | session, err := s.Get(ctx, id) 84 | if err != nil { 85 | return err 86 | } 87 | err = s.q.DeleteSession(ctx, session.ID) 88 | if err != nil { 89 | return err 90 | } 91 | s.Publish(pubsub.DeletedEvent, session) 92 | return nil 93 | } 94 | 95 | func (s *service) Get(ctx context.Context, id string) (Session, error) { 96 | dbSession, err := s.q.GetSessionByID(ctx, id) 97 | if err != nil { 98 | return Session{}, err 99 | } 100 | return s.fromDBItem(dbSession), nil 101 | } 102 | 103 | func (s *service) Save(ctx context.Context, session Session) (Session, error) { 104 | dbSession, err := s.q.UpdateSession(ctx, db.UpdateSessionParams{ 105 | ID: session.ID, 106 | Title: session.Title, 107 | PromptTokens: session.PromptTokens, 108 | CompletionTokens: session.CompletionTokens, 109 | SummaryMessageID: sql.NullString{ 110 | String: session.SummaryMessageID, 111 | Valid: session.SummaryMessageID != "", 112 | }, 113 | Cost: session.Cost, 114 | }) 115 | if err != nil { 116 | return Session{}, err 117 | } 118 | session = s.fromDBItem(dbSession) 119 | s.Publish(pubsub.UpdatedEvent, session) 120 | return session, nil 121 | } 122 | 123 | func (s *service) List(ctx context.Context) ([]Session, error) { 124 | dbSessions, err := s.q.ListSessions(ctx) 125 | if err != nil { 126 | return nil, err 127 | } 128 | sessions := make([]Session, len(dbSessions)) 129 | for i, dbSession := range dbSessions { 130 | sessions[i] = s.fromDBItem(dbSession) 131 | } 132 | return sessions, nil 133 | } 134 | 135 | func (s service) fromDBItem(item db.Session) Session { 136 | return Session{ 137 | ID: item.ID, 138 | ParentSessionID: item.ParentSessionID.String, 139 | Title: item.Title, 140 | MessageCount: item.MessageCount, 141 | PromptTokens: item.PromptTokens, 142 | CompletionTokens: item.CompletionTokens, 143 | SummaryMessageID: item.SummaryMessageID.String, 144 | Cost: item.Cost, 145 | CreatedAt: item.CreatedAt, 146 | UpdatedAt: item.UpdatedAt, 147 | } 148 | } 149 | 150 | func NewService(q db.Querier) Service { 151 | broker := pubsub.NewBroker[Session]() 152 | return &service{ 153 | broker, 154 | q, 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /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/opencode-ai/opencode/internal/config" 10 | "github.com/opencode-ai/opencode/internal/message" 11 | "github.com/opencode-ai/opencode/internal/session" 12 | "github.com/opencode-ai/opencode/internal/tui/styles" 13 | "github.com/opencode-ai/opencode/internal/tui/theme" 14 | "github.com/opencode-ai/opencode/internal/version" 15 | ) 16 | 17 | type SendMsg struct { 18 | Text string 19 | Attachments []message.Attachment 20 | } 21 | 22 | type SessionSelectedMsg = session.Session 23 | 24 | type SessionClearedMsg struct{} 25 | 26 | type EditorFocusMsg bool 27 | 28 | func header(width int) string { 29 | return lipgloss.JoinVertical( 30 | lipgloss.Top, 31 | logo(width), 32 | repo(width), 33 | "", 34 | cwd(width), 35 | ) 36 | } 37 | 38 | func lspsConfigured(width int) string { 39 | cfg := config.Get() 40 | title := "LSP Configuration" 41 | title = ansi.Truncate(title, width, "…") 42 | 43 | t := theme.CurrentTheme() 44 | baseStyle := styles.BaseStyle() 45 | 46 | lsps := baseStyle. 47 | Width(width). 48 | Foreground(t.Primary()). 49 | Bold(true). 50 | Render(title) 51 | 52 | // Get LSP names and sort them for consistent ordering 53 | var lspNames []string 54 | for name := range cfg.LSP { 55 | lspNames = append(lspNames, name) 56 | } 57 | sort.Strings(lspNames) 58 | 59 | var lspViews []string 60 | for _, name := range lspNames { 61 | lsp := cfg.LSP[name] 62 | lspName := baseStyle. 63 | Foreground(t.Text()). 64 | Render(fmt.Sprintf("• %s", name)) 65 | 66 | cmd := lsp.Command 67 | cmd = ansi.Truncate(cmd, width-lipgloss.Width(lspName)-3, "…") 68 | 69 | lspPath := baseStyle. 70 | Foreground(t.TextMuted()). 71 | Render(fmt.Sprintf(" (%s)", cmd)) 72 | 73 | lspViews = append(lspViews, 74 | baseStyle. 75 | Width(width). 76 | Render( 77 | lipgloss.JoinHorizontal( 78 | lipgloss.Left, 79 | lspName, 80 | lspPath, 81 | ), 82 | ), 83 | ) 84 | } 85 | 86 | return baseStyle. 87 | Width(width). 88 | Render( 89 | lipgloss.JoinVertical( 90 | lipgloss.Left, 91 | lsps, 92 | lipgloss.JoinVertical( 93 | lipgloss.Left, 94 | lspViews..., 95 | ), 96 | ), 97 | ) 98 | } 99 | 100 | func logo(width int) string { 101 | logo := fmt.Sprintf("%s %s", styles.OpenCodeIcon, "OpenCode") 102 | t := theme.CurrentTheme() 103 | baseStyle := styles.BaseStyle() 104 | 105 | versionText := baseStyle. 106 | Foreground(t.TextMuted()). 107 | Render(version.Version) 108 | 109 | return baseStyle. 110 | Bold(true). 111 | Width(width). 112 | Render( 113 | lipgloss.JoinHorizontal( 114 | lipgloss.Left, 115 | logo, 116 | " ", 117 | versionText, 118 | ), 119 | ) 120 | } 121 | 122 | func repo(width int) string { 123 | repo := "https://github.com/opencode-ai/opencode" 124 | t := theme.CurrentTheme() 125 | 126 | return styles.BaseStyle(). 127 | Foreground(t.TextMuted()). 128 | Width(width). 129 | Render(repo) 130 | } 131 | 132 | func cwd(width int) string { 133 | cwd := fmt.Sprintf("cwd: %s", config.WorkingDirectory()) 134 | t := theme.CurrentTheme() 135 | 136 | return styles.BaseStyle(). 137 | Foreground(t.TextMuted()). 138 | Width(width). 139 | Render(cwd) 140 | } 141 | 142 | -------------------------------------------------------------------------------- /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/opencode-ai/opencode/internal/tui/components/util" 8 | "github.com/opencode-ai/opencode/internal/tui/layout" 9 | "github.com/opencode-ai/opencode/internal/tui/styles" 10 | "github.com/opencode-ai/opencode/internal/tui/theme" 11 | "github.com/opencode-ai/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/help.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/opencode-ai/opencode/internal/tui/styles" 10 | "github.com/opencode-ai/opencode/internal/tui/theme" 11 | ) 12 | 13 | type helpCmp struct { 14 | width int 15 | height int 16 | keys []key.Binding 17 | } 18 | 19 | func (h *helpCmp) Init() tea.Cmd { 20 | return nil 21 | } 22 | 23 | func (h *helpCmp) SetBindings(k []key.Binding) { 24 | h.keys = k 25 | } 26 | 27 | func (h *helpCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 28 | switch msg := msg.(type) { 29 | case tea.WindowSizeMsg: 30 | h.width = 90 31 | h.height = msg.Height 32 | } 33 | return h, nil 34 | } 35 | 36 | func removeDuplicateBindings(bindings []key.Binding) []key.Binding { 37 | seen := make(map[string]struct{}) 38 | result := make([]key.Binding, 0, len(bindings)) 39 | 40 | // Process bindings in reverse order 41 | for i := len(bindings) - 1; i >= 0; i-- { 42 | b := bindings[i] 43 | k := strings.Join(b.Keys(), " ") 44 | if _, ok := seen[k]; ok { 45 | // duplicate, skip 46 | continue 47 | } 48 | seen[k] = struct{}{} 49 | // Add to the beginning of result to maintain original order 50 | result = append([]key.Binding{b}, result...) 51 | } 52 | 53 | return result 54 | } 55 | 56 | func (h *helpCmp) render() string { 57 | t := theme.CurrentTheme() 58 | baseStyle := styles.BaseStyle() 59 | 60 | helpKeyStyle := styles.Bold(). 61 | Background(t.Background()). 62 | Foreground(t.Text()). 63 | Padding(0, 1, 0, 0) 64 | 65 | helpDescStyle := styles.Regular(). 66 | Background(t.Background()). 67 | Foreground(t.TextMuted()) 68 | 69 | // Compile list of bindings to render 70 | bindings := removeDuplicateBindings(h.keys) 71 | 72 | // Enumerate through each group of bindings, populating a series of 73 | // pairs of columns, one for keys, one for descriptions 74 | var ( 75 | pairs []string 76 | width int 77 | rows = 12 - 2 78 | ) 79 | 80 | for i := 0; i < len(bindings); i += rows { 81 | var ( 82 | keys []string 83 | descs []string 84 | ) 85 | for j := i; j < min(i+rows, len(bindings)); j++ { 86 | keys = append(keys, helpKeyStyle.Render(bindings[j].Help().Key)) 87 | descs = append(descs, helpDescStyle.Render(bindings[j].Help().Desc)) 88 | } 89 | 90 | // Render pair of columns; beyond the first pair, render a three space 91 | // left margin, in order to visually separate the pairs. 92 | var cols []string 93 | if len(pairs) > 0 { 94 | cols = []string{baseStyle.Render(" ")} 95 | } 96 | 97 | maxDescWidth := 0 98 | for _, desc := range descs { 99 | if maxDescWidth < lipgloss.Width(desc) { 100 | maxDescWidth = lipgloss.Width(desc) 101 | } 102 | } 103 | for i := range descs { 104 | remainingWidth := maxDescWidth - lipgloss.Width(descs[i]) 105 | if remainingWidth > 0 { 106 | descs[i] = descs[i] + baseStyle.Render(strings.Repeat(" ", remainingWidth)) 107 | } 108 | } 109 | maxKeyWidth := 0 110 | for _, key := range keys { 111 | if maxKeyWidth < lipgloss.Width(key) { 112 | maxKeyWidth = lipgloss.Width(key) 113 | } 114 | } 115 | for i := range keys { 116 | remainingWidth := maxKeyWidth - lipgloss.Width(keys[i]) 117 | if remainingWidth > 0 { 118 | keys[i] = keys[i] + baseStyle.Render(strings.Repeat(" ", remainingWidth)) 119 | } 120 | } 121 | 122 | cols = append(cols, 123 | strings.Join(keys, "\n"), 124 | strings.Join(descs, "\n"), 125 | ) 126 | 127 | pair := baseStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, cols...)) 128 | // check whether it exceeds the maximum width avail (the width of the 129 | // terminal, subtracting 2 for the borders). 130 | width += lipgloss.Width(pair) 131 | if width > h.width-2 { 132 | break 133 | } 134 | pairs = append(pairs, pair) 135 | } 136 | 137 | // https://github.com/charmbracelet/lipgloss/issues/209 138 | if len(pairs) > 1 { 139 | prefix := pairs[:len(pairs)-1] 140 | lastPair := pairs[len(pairs)-1] 141 | prefix = append(prefix, lipgloss.Place( 142 | lipgloss.Width(lastPair), // width 143 | lipgloss.Height(prefix[0]), // height 144 | lipgloss.Left, // x 145 | lipgloss.Top, // y 146 | lastPair, // content 147 | lipgloss.WithWhitespaceBackground(t.Background()), 148 | )) 149 | content := baseStyle.Width(h.width).Render( 150 | lipgloss.JoinHorizontal( 151 | lipgloss.Top, 152 | prefix..., 153 | ), 154 | ) 155 | return content 156 | } 157 | 158 | // Join pairs of columns and enclose in a border 159 | content := baseStyle.Width(h.width).Render( 160 | lipgloss.JoinHorizontal( 161 | lipgloss.Top, 162 | pairs..., 163 | ), 164 | ) 165 | return content 166 | } 167 | 168 | func (h *helpCmp) View() string { 169 | t := theme.CurrentTheme() 170 | baseStyle := styles.BaseStyle() 171 | 172 | content := h.render() 173 | header := baseStyle. 174 | Bold(true). 175 | Width(lipgloss.Width(content)). 176 | Foreground(t.Primary()). 177 | Render("Keyboard Shortcuts") 178 | 179 | return baseStyle.Padding(1). 180 | Border(lipgloss.RoundedBorder()). 181 | BorderForeground(t.TextMuted()). 182 | Width(h.width). 183 | BorderBackground(t.Background()). 184 | Render( 185 | lipgloss.JoinVertical(lipgloss.Center, 186 | header, 187 | baseStyle.Render(strings.Repeat(" ", lipgloss.Width(header))), 188 | content, 189 | ), 190 | ) 191 | } 192 | 193 | type HelpCmp interface { 194 | tea.Model 195 | SetBindings([]key.Binding) 196 | } 197 | 198 | func NewHelpCmp() HelpCmp { 199 | return &helpCmp{} 200 | } 201 | -------------------------------------------------------------------------------- /internal/tui/components/dialog/init.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 | 8 | "github.com/opencode-ai/opencode/internal/tui/styles" 9 | "github.com/opencode-ai/opencode/internal/tui/theme" 10 | "github.com/opencode-ai/opencode/internal/tui/util" 11 | ) 12 | 13 | // InitDialogCmp is a component that asks the user if they want to initialize the project. 14 | type InitDialogCmp struct { 15 | width, height int 16 | selected int 17 | keys initDialogKeyMap 18 | } 19 | 20 | // NewInitDialogCmp creates a new InitDialogCmp. 21 | func NewInitDialogCmp() InitDialogCmp { 22 | return InitDialogCmp{ 23 | selected: 0, 24 | keys: initDialogKeyMap{}, 25 | } 26 | } 27 | 28 | type initDialogKeyMap struct { 29 | Tab key.Binding 30 | Left key.Binding 31 | Right key.Binding 32 | Enter key.Binding 33 | Escape key.Binding 34 | Y key.Binding 35 | N key.Binding 36 | } 37 | 38 | // ShortHelp implements key.Map. 39 | func (k initDialogKeyMap) ShortHelp() []key.Binding { 40 | return []key.Binding{ 41 | key.NewBinding( 42 | key.WithKeys("tab", "left", "right"), 43 | key.WithHelp("tab/←/→", "toggle selection"), 44 | ), 45 | key.NewBinding( 46 | key.WithKeys("enter"), 47 | key.WithHelp("enter", "confirm"), 48 | ), 49 | key.NewBinding( 50 | key.WithKeys("esc", "q"), 51 | key.WithHelp("esc/q", "cancel"), 52 | ), 53 | key.NewBinding( 54 | key.WithKeys("y", "n"), 55 | key.WithHelp("y/n", "yes/no"), 56 | ), 57 | } 58 | } 59 | 60 | // FullHelp implements key.Map. 61 | func (k initDialogKeyMap) FullHelp() [][]key.Binding { 62 | return [][]key.Binding{k.ShortHelp()} 63 | } 64 | 65 | // Init implements tea.Model. 66 | func (m InitDialogCmp) Init() tea.Cmd { 67 | return nil 68 | } 69 | 70 | // Update implements tea.Model. 71 | func (m InitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 72 | switch msg := msg.(type) { 73 | case tea.KeyMsg: 74 | switch { 75 | case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))): 76 | return m, util.CmdHandler(CloseInitDialogMsg{Initialize: false}) 77 | case key.Matches(msg, key.NewBinding(key.WithKeys("tab", "left", "right", "h", "l"))): 78 | m.selected = (m.selected + 1) % 2 79 | return m, nil 80 | case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))): 81 | return m, util.CmdHandler(CloseInitDialogMsg{Initialize: m.selected == 0}) 82 | case key.Matches(msg, key.NewBinding(key.WithKeys("y"))): 83 | return m, util.CmdHandler(CloseInitDialogMsg{Initialize: true}) 84 | case key.Matches(msg, key.NewBinding(key.WithKeys("n"))): 85 | return m, util.CmdHandler(CloseInitDialogMsg{Initialize: false}) 86 | } 87 | case tea.WindowSizeMsg: 88 | m.width = msg.Width 89 | m.height = msg.Height 90 | } 91 | return m, nil 92 | } 93 | 94 | // View implements tea.Model. 95 | func (m InitDialogCmp) View() string { 96 | t := theme.CurrentTheme() 97 | baseStyle := styles.BaseStyle() 98 | 99 | // Calculate width needed for content 100 | maxWidth := 60 // Width for explanation text 101 | 102 | title := baseStyle. 103 | Foreground(t.Primary()). 104 | Bold(true). 105 | Width(maxWidth). 106 | Padding(0, 1). 107 | Render("Initialize Project") 108 | 109 | explanation := baseStyle. 110 | Foreground(t.Text()). 111 | Width(maxWidth). 112 | Padding(0, 1). 113 | Render("Initialization generates a new OpenCode.md file that contains information about your codebase, this file serves as memory for each project, you can freely add to it to help the agents be better at their job.") 114 | 115 | question := baseStyle. 116 | Foreground(t.Text()). 117 | Width(maxWidth). 118 | Padding(1, 1). 119 | Render("Would you like to initialize this project?") 120 | 121 | maxWidth = min(maxWidth, m.width-10) 122 | yesStyle := baseStyle 123 | noStyle := baseStyle 124 | 125 | if m.selected == 0 { 126 | yesStyle = yesStyle. 127 | Background(t.Primary()). 128 | Foreground(t.Background()). 129 | Bold(true) 130 | noStyle = noStyle. 131 | Background(t.Background()). 132 | Foreground(t.Primary()) 133 | } else { 134 | noStyle = noStyle. 135 | Background(t.Primary()). 136 | Foreground(t.Background()). 137 | Bold(true) 138 | yesStyle = yesStyle. 139 | Background(t.Background()). 140 | Foreground(t.Primary()) 141 | } 142 | 143 | yes := yesStyle.Padding(0, 3).Render("Yes") 144 | no := noStyle.Padding(0, 3).Render("No") 145 | 146 | buttons := lipgloss.JoinHorizontal(lipgloss.Center, yes, baseStyle.Render(" "), no) 147 | buttons = baseStyle. 148 | Width(maxWidth). 149 | Padding(1, 0). 150 | Render(buttons) 151 | 152 | content := lipgloss.JoinVertical( 153 | lipgloss.Left, 154 | title, 155 | baseStyle.Width(maxWidth).Render(""), 156 | explanation, 157 | question, 158 | buttons, 159 | baseStyle.Width(maxWidth).Render(""), 160 | ) 161 | 162 | return baseStyle.Padding(1, 2). 163 | Border(lipgloss.RoundedBorder()). 164 | BorderBackground(t.Background()). 165 | BorderForeground(t.TextMuted()). 166 | Width(lipgloss.Width(content) + 4). 167 | Render(content) 168 | } 169 | 170 | // SetSize sets the size of the component. 171 | func (m *InitDialogCmp) SetSize(width, height int) { 172 | m.width = width 173 | m.height = height 174 | } 175 | 176 | // Bindings implements layout.Bindings. 177 | func (m InitDialogCmp) Bindings() []key.Binding { 178 | return m.keys.ShortHelp() 179 | } 180 | 181 | // CloseInitDialogMsg is a message that is sent when the init dialog is closed. 182 | type CloseInitDialogMsg struct { 183 | Initialize bool 184 | } 185 | 186 | // ShowInitDialogMsg is a message that is sent to show the init dialog. 187 | type ShowInitDialogMsg struct { 188 | Show bool 189 | } 190 | -------------------------------------------------------------------------------- /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/opencode-ai/opencode/internal/tui/layout" 10 | "github.com/opencode-ai/opencode/internal/tui/styles" 11 | "github.com/opencode-ai/opencode/internal/tui/theme" 12 | "github.com/opencode-ai/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/logs/details.go: -------------------------------------------------------------------------------- 1 | package logs 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/charmbracelet/bubbles/key" 9 | "github.com/charmbracelet/bubbles/viewport" 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/charmbracelet/lipgloss" 12 | "github.com/opencode-ai/opencode/internal/logging" 13 | "github.com/opencode-ai/opencode/internal/tui/layout" 14 | "github.com/opencode-ai/opencode/internal/tui/styles" 15 | "github.com/opencode-ai/opencode/internal/tui/theme" 16 | ) 17 | 18 | type DetailComponent interface { 19 | tea.Model 20 | layout.Sizeable 21 | layout.Bindings 22 | } 23 | 24 | type detailCmp struct { 25 | width, height int 26 | currentLog logging.LogMessage 27 | viewport viewport.Model 28 | } 29 | 30 | func (i *detailCmp) Init() tea.Cmd { 31 | messages := logging.List() 32 | if len(messages) == 0 { 33 | return nil 34 | } 35 | i.currentLog = messages[0] 36 | return nil 37 | } 38 | 39 | func (i *detailCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 40 | switch msg := msg.(type) { 41 | case selectedLogMsg: 42 | if msg.ID != i.currentLog.ID { 43 | i.currentLog = logging.LogMessage(msg) 44 | i.updateContent() 45 | } 46 | } 47 | 48 | return i, nil 49 | } 50 | 51 | func (i *detailCmp) updateContent() { 52 | var content strings.Builder 53 | t := theme.CurrentTheme() 54 | 55 | // Format the header with timestamp and level 56 | timeStyle := lipgloss.NewStyle().Foreground(t.TextMuted()) 57 | levelStyle := getLevelStyle(i.currentLog.Level) 58 | 59 | header := lipgloss.JoinHorizontal( 60 | lipgloss.Center, 61 | timeStyle.Render(i.currentLog.Time.Format(time.RFC3339)), 62 | " ", 63 | levelStyle.Render(i.currentLog.Level), 64 | ) 65 | 66 | content.WriteString(lipgloss.NewStyle().Bold(true).Render(header)) 67 | content.WriteString("\n\n") 68 | 69 | // Message with styling 70 | messageStyle := lipgloss.NewStyle().Bold(true).Foreground(t.Text()) 71 | content.WriteString(messageStyle.Render("Message:")) 72 | content.WriteString("\n") 73 | content.WriteString(lipgloss.NewStyle().Padding(0, 2).Render(i.currentLog.Message)) 74 | content.WriteString("\n\n") 75 | 76 | // Attributes section 77 | if len(i.currentLog.Attributes) > 0 { 78 | attrHeaderStyle := lipgloss.NewStyle().Bold(true).Foreground(t.Text()) 79 | content.WriteString(attrHeaderStyle.Render("Attributes:")) 80 | content.WriteString("\n") 81 | 82 | // Create a table-like display for attributes 83 | keyStyle := lipgloss.NewStyle().Foreground(t.Primary()).Bold(true) 84 | valueStyle := lipgloss.NewStyle().Foreground(t.Text()) 85 | 86 | for _, attr := range i.currentLog.Attributes { 87 | attrLine := fmt.Sprintf("%s: %s", 88 | keyStyle.Render(attr.Key), 89 | valueStyle.Render(attr.Value), 90 | ) 91 | content.WriteString(lipgloss.NewStyle().Padding(0, 2).Render(attrLine)) 92 | content.WriteString("\n") 93 | } 94 | } 95 | 96 | i.viewport.SetContent(content.String()) 97 | } 98 | 99 | func getLevelStyle(level string) lipgloss.Style { 100 | style := lipgloss.NewStyle().Bold(true) 101 | t := theme.CurrentTheme() 102 | 103 | switch strings.ToLower(level) { 104 | case "info": 105 | return style.Foreground(t.Info()) 106 | case "warn", "warning": 107 | return style.Foreground(t.Warning()) 108 | case "error", "err": 109 | return style.Foreground(t.Error()) 110 | case "debug": 111 | return style.Foreground(t.Success()) 112 | default: 113 | return style.Foreground(t.Text()) 114 | } 115 | } 116 | 117 | func (i *detailCmp) View() string { 118 | t := theme.CurrentTheme() 119 | return styles.ForceReplaceBackgroundWithLipgloss(i.viewport.View(), t.Background()) 120 | } 121 | 122 | func (i *detailCmp) GetSize() (int, int) { 123 | return i.width, i.height 124 | } 125 | 126 | func (i *detailCmp) SetSize(width int, height int) tea.Cmd { 127 | i.width = width 128 | i.height = height 129 | i.viewport.Width = i.width 130 | i.viewport.Height = i.height 131 | i.updateContent() 132 | return nil 133 | } 134 | 135 | func (i *detailCmp) BindingKeys() []key.Binding { 136 | return layout.KeyMapToSlice(i.viewport.KeyMap) 137 | } 138 | 139 | func NewLogsDetails() DetailComponent { 140 | return &detailCmp{ 141 | viewport: viewport.New(0, 0), 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /internal/tui/components/logs/table.go: -------------------------------------------------------------------------------- 1 | package logs 2 | 3 | import ( 4 | "encoding/json" 5 | "slices" 6 | 7 | "github.com/charmbracelet/bubbles/key" 8 | "github.com/charmbracelet/bubbles/table" 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/opencode-ai/opencode/internal/logging" 11 | "github.com/opencode-ai/opencode/internal/pubsub" 12 | "github.com/opencode-ai/opencode/internal/tui/layout" 13 | "github.com/opencode-ai/opencode/internal/tui/styles" 14 | "github.com/opencode-ai/opencode/internal/tui/theme" 15 | "github.com/opencode-ai/opencode/internal/tui/util" 16 | ) 17 | 18 | type TableComponent interface { 19 | tea.Model 20 | layout.Sizeable 21 | layout.Bindings 22 | } 23 | 24 | type tableCmp struct { 25 | table table.Model 26 | } 27 | 28 | type selectedLogMsg logging.LogMessage 29 | 30 | func (i *tableCmp) Init() tea.Cmd { 31 | i.setRows() 32 | return nil 33 | } 34 | 35 | func (i *tableCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 36 | var cmds []tea.Cmd 37 | switch msg.(type) { 38 | case pubsub.Event[logging.LogMessage]: 39 | i.setRows() 40 | return i, nil 41 | } 42 | prevSelectedRow := i.table.SelectedRow() 43 | t, cmd := i.table.Update(msg) 44 | cmds = append(cmds, cmd) 45 | i.table = t 46 | selectedRow := i.table.SelectedRow() 47 | if selectedRow != nil { 48 | if prevSelectedRow == nil || selectedRow[0] == prevSelectedRow[0] { 49 | var log logging.LogMessage 50 | for _, row := range logging.List() { 51 | if row.ID == selectedRow[0] { 52 | log = row 53 | break 54 | } 55 | } 56 | if log.ID != "" { 57 | cmds = append(cmds, util.CmdHandler(selectedLogMsg(log))) 58 | } 59 | } 60 | } 61 | return i, tea.Batch(cmds...) 62 | } 63 | 64 | func (i *tableCmp) View() string { 65 | t := theme.CurrentTheme() 66 | defaultStyles := table.DefaultStyles() 67 | defaultStyles.Selected = defaultStyles.Selected.Foreground(t.Primary()) 68 | i.table.SetStyles(defaultStyles) 69 | return styles.ForceReplaceBackgroundWithLipgloss(i.table.View(), t.Background()) 70 | } 71 | 72 | func (i *tableCmp) GetSize() (int, int) { 73 | return i.table.Width(), i.table.Height() 74 | } 75 | 76 | func (i *tableCmp) SetSize(width int, height int) tea.Cmd { 77 | i.table.SetWidth(width) 78 | i.table.SetHeight(height) 79 | cloumns := i.table.Columns() 80 | for i, col := range cloumns { 81 | col.Width = (width / len(cloumns)) - 2 82 | cloumns[i] = col 83 | } 84 | i.table.SetColumns(cloumns) 85 | return nil 86 | } 87 | 88 | func (i *tableCmp) BindingKeys() []key.Binding { 89 | return layout.KeyMapToSlice(i.table.KeyMap) 90 | } 91 | 92 | func (i *tableCmp) setRows() { 93 | rows := []table.Row{} 94 | 95 | logs := logging.List() 96 | slices.SortFunc(logs, func(a, b logging.LogMessage) int { 97 | if a.Time.Before(b.Time) { 98 | return 1 99 | } 100 | if a.Time.After(b.Time) { 101 | return -1 102 | } 103 | return 0 104 | }) 105 | 106 | for _, log := range logs { 107 | bm, _ := json.Marshal(log.Attributes) 108 | 109 | row := table.Row{ 110 | log.ID, 111 | log.Time.Format("15:04:05"), 112 | log.Level, 113 | log.Message, 114 | string(bm), 115 | } 116 | rows = append(rows, row) 117 | } 118 | i.table.SetRows(rows) 119 | } 120 | 121 | func NewLogsTable() TableComponent { 122 | columns := []table.Column{ 123 | {Title: "ID", Width: 4}, 124 | {Title: "Time", Width: 4}, 125 | {Title: "Level", Width: 10}, 126 | {Title: "Message", Width: 10}, 127 | {Title: "Attributes", Width: 10}, 128 | } 129 | 130 | tableModel := table.New( 131 | table.WithColumns(columns), 132 | ) 133 | tableModel.Focus() 134 | return &tableCmp{ 135 | table: tableModel, 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /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/opencode-ai/opencode/internal/tui/layout" 8 | "github.com/opencode-ai/opencode/internal/tui/styles" 9 | "github.com/opencode-ai/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/images.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "fmt" 5 | "image" 6 | "os" 7 | "strings" 8 | 9 | "github.com/charmbracelet/lipgloss" 10 | "github.com/disintegration/imaging" 11 | "github.com/lucasb-eyer/go-colorful" 12 | ) 13 | 14 | func ValidateFileSize(filePath string, sizeLimit int64) (bool, error) { 15 | fileInfo, err := os.Stat(filePath) 16 | if err != nil { 17 | return false, fmt.Errorf("error getting file info: %w", err) 18 | } 19 | 20 | if fileInfo.Size() > sizeLimit { 21 | return true, nil 22 | } 23 | 24 | return false, nil 25 | } 26 | 27 | func ToString(width int, img image.Image) string { 28 | img = imaging.Resize(img, width, 0, imaging.Lanczos) 29 | b := img.Bounds() 30 | imageWidth := b.Max.X 31 | h := b.Max.Y 32 | str := strings.Builder{} 33 | 34 | for heightCounter := 0; heightCounter < h; heightCounter += 2 { 35 | for x := range imageWidth { 36 | c1, _ := colorful.MakeColor(img.At(x, heightCounter)) 37 | color1 := lipgloss.Color(c1.Hex()) 38 | 39 | var color2 lipgloss.Color 40 | if heightCounter+1 < h { 41 | c2, _ := colorful.MakeColor(img.At(x, heightCounter+1)) 42 | color2 = lipgloss.Color(c2.Hex()) 43 | } else { 44 | color2 = color1 45 | } 46 | 47 | str.WriteString(lipgloss.NewStyle().Foreground(color1). 48 | Background(color2).Render("▀")) 49 | } 50 | 51 | str.WriteString("\n") 52 | } 53 | 54 | return str.String() 55 | } 56 | 57 | func ImagePreview(width int, filename string) (string, error) { 58 | imageContent, err := os.Open(filename) 59 | if err != nil { 60 | return "", err 61 | } 62 | defer imageContent.Close() 63 | 64 | img, _, err := image.Decode(imageContent) 65 | if err != nil { 66 | return "", err 67 | } 68 | 69 | imageString := ToString(width, img) 70 | 71 | return imageString, nil 72 | } 73 | -------------------------------------------------------------------------------- /internal/tui/layout/container.go: -------------------------------------------------------------------------------- 1 | package layout 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | tea "github.com/charmbracelet/bubbletea" 6 | "github.com/charmbracelet/lipgloss" 7 | "github.com/opencode-ai/opencode/internal/tui/theme" 8 | ) 9 | 10 | type Container interface { 11 | tea.Model 12 | Sizeable 13 | Bindings 14 | } 15 | type container struct { 16 | width int 17 | height int 18 | 19 | content tea.Model 20 | 21 | // Style options 22 | paddingTop int 23 | paddingRight int 24 | paddingBottom int 25 | paddingLeft int 26 | 27 | borderTop bool 28 | borderRight bool 29 | borderBottom bool 30 | borderLeft bool 31 | borderStyle lipgloss.Border 32 | } 33 | 34 | func (c *container) Init() tea.Cmd { 35 | return c.content.Init() 36 | } 37 | 38 | func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 39 | u, cmd := c.content.Update(msg) 40 | c.content = u 41 | return c, cmd 42 | } 43 | 44 | func (c *container) View() string { 45 | t := theme.CurrentTheme() 46 | style := lipgloss.NewStyle() 47 | width := c.width 48 | height := c.height 49 | 50 | style = style.Background(t.Background()) 51 | 52 | // Apply border if any side is enabled 53 | if c.borderTop || c.borderRight || c.borderBottom || c.borderLeft { 54 | // Adjust width and height for borders 55 | if c.borderTop { 56 | height-- 57 | } 58 | if c.borderBottom { 59 | height-- 60 | } 61 | if c.borderLeft { 62 | width-- 63 | } 64 | if c.borderRight { 65 | width-- 66 | } 67 | style = style.Border(c.borderStyle, c.borderTop, c.borderRight, c.borderBottom, c.borderLeft) 68 | style = style.BorderBackground(t.Background()).BorderForeground(t.BorderNormal()) 69 | } 70 | style = style. 71 | Width(width). 72 | Height(height). 73 | PaddingTop(c.paddingTop). 74 | PaddingRight(c.paddingRight). 75 | PaddingBottom(c.paddingBottom). 76 | PaddingLeft(c.paddingLeft) 77 | 78 | return style.Render(c.content.View()) 79 | } 80 | 81 | func (c *container) SetSize(width, height int) tea.Cmd { 82 | c.width = width 83 | c.height = height 84 | 85 | // If the content implements Sizeable, adjust its size to account for padding and borders 86 | if sizeable, ok := c.content.(Sizeable); ok { 87 | // Calculate horizontal space taken by padding and borders 88 | horizontalSpace := c.paddingLeft + c.paddingRight 89 | if c.borderLeft { 90 | horizontalSpace++ 91 | } 92 | if c.borderRight { 93 | horizontalSpace++ 94 | } 95 | 96 | // Calculate vertical space taken by padding and borders 97 | verticalSpace := c.paddingTop + c.paddingBottom 98 | if c.borderTop { 99 | verticalSpace++ 100 | } 101 | if c.borderBottom { 102 | verticalSpace++ 103 | } 104 | 105 | // Set content size with adjusted dimensions 106 | contentWidth := max(0, width-horizontalSpace) 107 | contentHeight := max(0, height-verticalSpace) 108 | return sizeable.SetSize(contentWidth, contentHeight) 109 | } 110 | return nil 111 | } 112 | 113 | func (c *container) GetSize() (int, int) { 114 | return c.width, c.height 115 | } 116 | 117 | func (c *container) BindingKeys() []key.Binding { 118 | if b, ok := c.content.(Bindings); ok { 119 | return b.BindingKeys() 120 | } 121 | return []key.Binding{} 122 | } 123 | 124 | type ContainerOption func(*container) 125 | 126 | func NewContainer(content tea.Model, options ...ContainerOption) Container { 127 | 128 | c := &container{ 129 | content: content, 130 | borderStyle: lipgloss.NormalBorder(), 131 | } 132 | 133 | for _, option := range options { 134 | option(c) 135 | } 136 | 137 | return c 138 | } 139 | 140 | // Padding options 141 | func WithPadding(top, right, bottom, left int) ContainerOption { 142 | return func(c *container) { 143 | c.paddingTop = top 144 | c.paddingRight = right 145 | c.paddingBottom = bottom 146 | c.paddingLeft = left 147 | } 148 | } 149 | 150 | func WithPaddingAll(padding int) ContainerOption { 151 | return WithPadding(padding, padding, padding, padding) 152 | } 153 | 154 | func WithPaddingHorizontal(padding int) ContainerOption { 155 | return func(c *container) { 156 | c.paddingLeft = padding 157 | c.paddingRight = padding 158 | } 159 | } 160 | 161 | func WithPaddingVertical(padding int) ContainerOption { 162 | return func(c *container) { 163 | c.paddingTop = padding 164 | c.paddingBottom = padding 165 | } 166 | } 167 | 168 | func WithBorder(top, right, bottom, left bool) ContainerOption { 169 | return func(c *container) { 170 | c.borderTop = top 171 | c.borderRight = right 172 | c.borderBottom = bottom 173 | c.borderLeft = left 174 | } 175 | } 176 | 177 | func WithBorderAll() ContainerOption { 178 | return WithBorder(true, true, true, true) 179 | } 180 | 181 | func WithBorderHorizontal() ContainerOption { 182 | return WithBorder(true, false, true, false) 183 | } 184 | 185 | func WithBorderVertical() ContainerOption { 186 | return WithBorder(false, true, false, true) 187 | } 188 | 189 | func WithBorderStyle(style lipgloss.Border) ContainerOption { 190 | return func(c *container) { 191 | c.borderStyle = style 192 | } 193 | } 194 | 195 | func WithRoundedBorder() ContainerOption { 196 | return WithBorderStyle(lipgloss.RoundedBorder()) 197 | } 198 | 199 | func WithThickBorder() ContainerOption { 200 | return WithBorderStyle(lipgloss.ThickBorder()) 201 | } 202 | 203 | func WithDoubleBorder() ContainerOption { 204 | return WithBorderStyle(lipgloss.DoubleBorder()) 205 | } 206 | -------------------------------------------------------------------------------- /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/opencode-ai/opencode/internal/tui/styles" 12 | "github.com/opencode-ai/opencode/internal/tui/theme" 13 | "github.com/opencode-ai/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/logs.go: -------------------------------------------------------------------------------- 1 | package page 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | tea "github.com/charmbracelet/bubbletea" 6 | "github.com/charmbracelet/lipgloss" 7 | "github.com/opencode-ai/opencode/internal/tui/components/logs" 8 | "github.com/opencode-ai/opencode/internal/tui/layout" 9 | "github.com/opencode-ai/opencode/internal/tui/styles" 10 | ) 11 | 12 | var LogsPage PageID = "logs" 13 | 14 | type LogPage interface { 15 | tea.Model 16 | layout.Sizeable 17 | layout.Bindings 18 | } 19 | type logsPage struct { 20 | width, height int 21 | table layout.Container 22 | details layout.Container 23 | } 24 | 25 | func (p *logsPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 26 | var cmds []tea.Cmd 27 | switch msg := msg.(type) { 28 | case tea.WindowSizeMsg: 29 | p.width = msg.Width 30 | p.height = msg.Height 31 | return p, p.SetSize(msg.Width, msg.Height) 32 | } 33 | 34 | table, cmd := p.table.Update(msg) 35 | cmds = append(cmds, cmd) 36 | p.table = table.(layout.Container) 37 | details, cmd := p.details.Update(msg) 38 | cmds = append(cmds, cmd) 39 | p.details = details.(layout.Container) 40 | 41 | return p, tea.Batch(cmds...) 42 | } 43 | 44 | func (p *logsPage) View() string { 45 | style := styles.BaseStyle().Width(p.width).Height(p.height) 46 | return style.Render(lipgloss.JoinVertical(lipgloss.Top, 47 | p.table.View(), 48 | p.details.View(), 49 | )) 50 | } 51 | 52 | func (p *logsPage) BindingKeys() []key.Binding { 53 | return p.table.BindingKeys() 54 | } 55 | 56 | // GetSize implements LogPage. 57 | func (p *logsPage) GetSize() (int, int) { 58 | return p.width, p.height 59 | } 60 | 61 | // SetSize implements LogPage. 62 | func (p *logsPage) SetSize(width int, height int) tea.Cmd { 63 | p.width = width 64 | p.height = height 65 | return tea.Batch( 66 | p.table.SetSize(width, height/2), 67 | p.details.SetSize(width, height/2), 68 | ) 69 | } 70 | 71 | func (p *logsPage) Init() tea.Cmd { 72 | return tea.Batch( 73 | p.table.Init(), 74 | p.details.Init(), 75 | ) 76 | } 77 | 78 | func NewLogsPage() LogPage { 79 | return &logsPage{ 80 | table: layout.NewContainer(logs.NewLogsTable(), layout.WithBorderAll()), 81 | details: layout.NewContainer(logs.NewLogsDetails(), layout.WithBorderAll()), 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /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/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 | CheckIcon string = "✓" 7 | ErrorIcon string = "✖" 8 | WarningIcon string = "⚠" 9 | InfoIcon string = "" 10 | HintIcon string = "i" 11 | SpinnerIcon string = "..." 12 | LoadingIcon string = "⟳" 13 | DocumentIcon string = "🖼" 14 | ) 15 | -------------------------------------------------------------------------------- /internal/tui/styles/styles.go: -------------------------------------------------------------------------------- 1 | package styles 2 | 3 | import ( 4 | "github.com/charmbracelet/lipgloss" 5 | "github.com/opencode-ai/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 | // Bold returns a bold style 28 | func Bold() lipgloss.Style { 29 | return Regular().Bold(true) 30 | } 31 | 32 | // Padded returns a style with horizontal padding 33 | func Padded() lipgloss.Style { 34 | return Regular().Padding(0, 1) 35 | } 36 | 37 | // Border returns a style with a normal border 38 | func Border() lipgloss.Style { 39 | t := theme.CurrentTheme() 40 | return Regular(). 41 | Border(lipgloss.NormalBorder()). 42 | BorderForeground(t.BorderNormal()) 43 | } 44 | 45 | // ThickBorder returns a style with a thick border 46 | func ThickBorder() lipgloss.Style { 47 | t := theme.CurrentTheme() 48 | return Regular(). 49 | Border(lipgloss.ThickBorder()). 50 | BorderForeground(t.BorderNormal()) 51 | } 52 | 53 | // DoubleBorder returns a style with a double border 54 | func DoubleBorder() lipgloss.Style { 55 | t := theme.CurrentTheme() 56 | return Regular(). 57 | Border(lipgloss.DoubleBorder()). 58 | BorderForeground(t.BorderNormal()) 59 | } 60 | 61 | // FocusedBorder returns a style with a border using the focused border color 62 | func FocusedBorder() lipgloss.Style { 63 | t := theme.CurrentTheme() 64 | return Regular(). 65 | Border(lipgloss.NormalBorder()). 66 | BorderForeground(t.BorderFocused()) 67 | } 68 | 69 | // DimBorder returns a style with a border using the dim border color 70 | func DimBorder() lipgloss.Style { 71 | t := theme.CurrentTheme() 72 | return Regular(). 73 | Border(lipgloss.NormalBorder()). 74 | BorderForeground(t.BorderDim()) 75 | } 76 | 77 | // PrimaryColor returns the primary color from the current theme 78 | func PrimaryColor() lipgloss.AdaptiveColor { 79 | return theme.CurrentTheme().Primary() 80 | } 81 | 82 | // SecondaryColor returns the secondary color from the current theme 83 | func SecondaryColor() lipgloss.AdaptiveColor { 84 | return theme.CurrentTheme().Secondary() 85 | } 86 | 87 | // AccentColor returns the accent color from the current theme 88 | func AccentColor() lipgloss.AdaptiveColor { 89 | return theme.CurrentTheme().Accent() 90 | } 91 | 92 | // ErrorColor returns the error color from the current theme 93 | func ErrorColor() lipgloss.AdaptiveColor { 94 | return theme.CurrentTheme().Error() 95 | } 96 | 97 | // WarningColor returns the warning color from the current theme 98 | func WarningColor() lipgloss.AdaptiveColor { 99 | return theme.CurrentTheme().Warning() 100 | } 101 | 102 | // SuccessColor returns the success color from the current theme 103 | func SuccessColor() lipgloss.AdaptiveColor { 104 | return theme.CurrentTheme().Success() 105 | } 106 | 107 | // InfoColor returns the info color from the current theme 108 | func InfoColor() lipgloss.AdaptiveColor { 109 | return theme.CurrentTheme().Info() 110 | } 111 | 112 | // TextColor returns the text color from the current theme 113 | func TextColor() lipgloss.AdaptiveColor { 114 | return theme.CurrentTheme().Text() 115 | } 116 | 117 | // TextMutedColor returns the muted text color from the current theme 118 | func TextMutedColor() lipgloss.AdaptiveColor { 119 | return theme.CurrentTheme().TextMuted() 120 | } 121 | 122 | // TextEmphasizedColor returns the emphasized text color from the current theme 123 | func TextEmphasizedColor() lipgloss.AdaptiveColor { 124 | return theme.CurrentTheme().TextEmphasized() 125 | } 126 | 127 | // BackgroundColor returns the background color from the current theme 128 | func BackgroundColor() lipgloss.AdaptiveColor { 129 | return theme.CurrentTheme().Background() 130 | } 131 | 132 | // BackgroundSecondaryColor returns the secondary background color from the current theme 133 | func BackgroundSecondaryColor() lipgloss.AdaptiveColor { 134 | return theme.CurrentTheme().BackgroundSecondary() 135 | } 136 | 137 | // BackgroundDarkerColor returns the darker background color from the current theme 138 | func BackgroundDarkerColor() lipgloss.AdaptiveColor { 139 | return theme.CurrentTheme().BackgroundDarker() 140 | } 141 | 142 | // BorderNormalColor returns the normal border color from the current theme 143 | func BorderNormalColor() lipgloss.AdaptiveColor { 144 | return theme.CurrentTheme().BorderNormal() 145 | } 146 | 147 | // BorderFocusedColor returns the focused border color from the current theme 148 | func BorderFocusedColor() lipgloss.AdaptiveColor { 149 | return theme.CurrentTheme().BorderFocused() 150 | } 151 | 152 | // BorderDimColor returns the dim border color from the current theme 153 | func BorderDimColor() lipgloss.AdaptiveColor { 154 | return theme.CurrentTheme().BorderDim() 155 | } 156 | -------------------------------------------------------------------------------- /internal/tui/theme/manager.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | import ( 4 | "fmt" 5 | "slices" 6 | "strings" 7 | "sync" 8 | 9 | "github.com/alecthomas/chroma/v2/styles" 10 | "github.com/opencode-ai/opencode/internal/config" 11 | "github.com/opencode-ai/opencode/internal/logging" 12 | ) 13 | 14 | // Manager handles theme registration, selection, and retrieval. 15 | // It maintains a registry of available themes and tracks the currently active theme. 16 | type Manager struct { 17 | themes map[string]Theme 18 | currentName string 19 | mu sync.RWMutex 20 | } 21 | 22 | // Global instance of the theme manager 23 | var globalManager = &Manager{ 24 | themes: make(map[string]Theme), 25 | currentName: "", 26 | } 27 | 28 | // RegisterTheme adds a new theme to the registry. 29 | // If this is the first theme registered, it becomes the default. 30 | func RegisterTheme(name string, theme Theme) { 31 | globalManager.mu.Lock() 32 | defer globalManager.mu.Unlock() 33 | 34 | globalManager.themes[name] = theme 35 | 36 | // If this is the first theme, make it the default 37 | if globalManager.currentName == "" { 38 | globalManager.currentName = name 39 | } 40 | } 41 | 42 | // SetTheme changes the active theme to the one with the specified name. 43 | // Returns an error if the theme doesn't exist. 44 | func SetTheme(name string) error { 45 | globalManager.mu.Lock() 46 | defer globalManager.mu.Unlock() 47 | 48 | delete(styles.Registry, "charm") 49 | if _, exists := globalManager.themes[name]; !exists { 50 | return fmt.Errorf("theme '%s' not found", name) 51 | } 52 | 53 | globalManager.currentName = name 54 | 55 | // Update the config file using viper 56 | if err := updateConfigTheme(name); err != nil { 57 | // Log the error but don't fail the theme change 58 | logging.Warn("Warning: Failed to update config file with new theme", "err", err) 59 | } 60 | 61 | return nil 62 | } 63 | 64 | // CurrentTheme returns the currently active theme. 65 | // If no theme is set, it returns nil. 66 | func CurrentTheme() Theme { 67 | globalManager.mu.RLock() 68 | defer globalManager.mu.RUnlock() 69 | 70 | if globalManager.currentName == "" { 71 | return nil 72 | } 73 | 74 | return globalManager.themes[globalManager.currentName] 75 | } 76 | 77 | // CurrentThemeName returns the name of the currently active theme. 78 | func CurrentThemeName() string { 79 | globalManager.mu.RLock() 80 | defer globalManager.mu.RUnlock() 81 | 82 | return globalManager.currentName 83 | } 84 | 85 | // AvailableThemes returns a list of all registered theme names. 86 | func AvailableThemes() []string { 87 | globalManager.mu.RLock() 88 | defer globalManager.mu.RUnlock() 89 | 90 | names := make([]string, 0, len(globalManager.themes)) 91 | for name := range globalManager.themes { 92 | names = append(names, name) 93 | } 94 | slices.SortFunc(names, func(a, b string) int { 95 | if a == "opencode" { 96 | return -1 97 | } else if b == "opencode" { 98 | return 1 99 | } 100 | return strings.Compare(a, b) 101 | }) 102 | return names 103 | } 104 | 105 | // GetTheme returns a specific theme by name. 106 | // Returns nil if the theme doesn't exist. 107 | func GetTheme(name string) Theme { 108 | globalManager.mu.RLock() 109 | defer globalManager.mu.RUnlock() 110 | 111 | return globalManager.themes[name] 112 | } 113 | 114 | // updateConfigTheme updates the theme setting in the configuration file 115 | func updateConfigTheme(themeName string) error { 116 | // Use the config package to update the theme 117 | return config.UpdateTheme(themeName) 118 | } 119 | -------------------------------------------------------------------------------- /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 | // Try to get the themes and make sure they're not nil 51 | catppuccin := GetTheme("catppuccin") 52 | if catppuccin == nil { 53 | t.Errorf("Catppuccin theme is nil") 54 | } 55 | 56 | gruvbox := GetTheme("gruvbox") 57 | if gruvbox == nil { 58 | t.Errorf("Gruvbox theme is nil") 59 | } 60 | 61 | monokai := GetTheme("monokai") 62 | if monokai == nil { 63 | t.Errorf("Monokai theme is nil") 64 | } 65 | 66 | // Test switching theme 67 | originalTheme := CurrentThemeName() 68 | 69 | err := SetTheme("gruvbox") 70 | if err != nil { 71 | t.Errorf("Failed to set theme to gruvbox: %v", err) 72 | } 73 | 74 | if CurrentThemeName() != "gruvbox" { 75 | t.Errorf("Theme not properly switched to gruvbox") 76 | } 77 | 78 | err = SetTheme("monokai") 79 | if err != nil { 80 | t.Errorf("Failed to set theme to monokai: %v", err) 81 | } 82 | 83 | if CurrentThemeName() != "monokai" { 84 | t.Errorf("Theme not properly switched to monokai") 85 | } 86 | 87 | // Switch back to original theme 88 | _ = SetTheme(originalTheme) 89 | } -------------------------------------------------------------------------------- /internal/tui/util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "time" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | ) 8 | 9 | func CmdHandler(msg tea.Msg) tea.Cmd { 10 | return func() tea.Msg { 11 | return msg 12 | } 13 | } 14 | 15 | func ReportError(err error) tea.Cmd { 16 | return CmdHandler(InfoMsg{ 17 | Type: InfoTypeError, 18 | Msg: err.Error(), 19 | }) 20 | } 21 | 22 | type InfoType int 23 | 24 | const ( 25 | InfoTypeInfo InfoType = iota 26 | InfoTypeWarn 27 | InfoTypeError 28 | ) 29 | 30 | func ReportInfo(info string) tea.Cmd { 31 | return CmdHandler(InfoMsg{ 32 | Type: InfoTypeInfo, 33 | Msg: info, 34 | }) 35 | } 36 | 37 | func ReportWarn(warn string) tea.Cmd { 38 | return CmdHandler(InfoMsg{ 39 | Type: InfoTypeWarn, 40 | Msg: warn, 41 | }) 42 | } 43 | 44 | type ( 45 | InfoMsg struct { 46 | Type InfoType 47 | Msg string 48 | TTL time.Duration 49 | } 50 | ClearStatusMsg struct{} 51 | ) 52 | 53 | func Clamp(v, low, high int) int { 54 | if high < low { 55 | low, high = high, low 56 | } 57 | return min(high, max(low, v)) 58 | } 59 | -------------------------------------------------------------------------------- /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/opencode-ai/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/opencode-ai/opencode/cmd" 5 | "github.com/opencode-ai/opencode/internal/logging" 6 | ) 7 | 8 | func main() { 9 | defer logging.RecoverPanic("main", func() { 10 | logging.ErrorPersist("Application terminated due to unhandled panic") 11 | }) 12 | 13 | cmd.Execute() 14 | } 15 | -------------------------------------------------------------------------------- /scripts/check_hidden_chars.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Script to check for hidden/invisible characters in Go files 4 | # This helps detect potential prompt injection attempts 5 | 6 | echo "Checking Go files for hidden characters..." 7 | 8 | # Find all Go files in the repository 9 | go_files=$(find . -name "*.go" -type f) 10 | 11 | # Counter for files with hidden characters 12 | files_with_hidden=0 13 | 14 | for file in $go_files; do 15 | # Check for specific Unicode hidden characters that could be used for prompt injection 16 | # This excludes normal whitespace like tabs and newlines 17 | # Looking for: 18 | # - Zero-width spaces (U+200B) 19 | # - Zero-width non-joiners (U+200C) 20 | # - Zero-width joiners (U+200D) 21 | # - Left-to-right/right-to-left marks (U+200E, U+200F) 22 | # - Bidirectional overrides (U+202A-U+202E) 23 | # - Byte order mark (U+FEFF) 24 | if hexdump -C "$file" | grep -E 'e2 80 8b|e2 80 8c|e2 80 8d|e2 80 8e|e2 80 8f|e2 80 aa|e2 80 ab|e2 80 ac|e2 80 ad|e2 80 ae|ef bb bf' > /dev/null 2>&1; then 25 | echo "Hidden characters found in: $file" 26 | 27 | # Show the file with potential issues 28 | echo " Hexdump showing suspicious characters:" 29 | hexdump -C "$file" | grep -E 'e2 80 8b|e2 80 8c|e2 80 8d|e2 80 8e|e2 80 8f|e2 80 aa|e2 80 ab|e2 80 ac|e2 80 ad|e2 80 ae|ef bb bf' | head -10 30 | 31 | files_with_hidden=$((files_with_hidden + 1)) 32 | fi 33 | done 34 | 35 | if [ $files_with_hidden -eq 0 ]; then 36 | echo "No hidden characters found in any Go files." 37 | else 38 | echo "Found hidden characters in $files_with_hidden Go file(s)." 39 | fi 40 | 41 | exit $files_with_hidden # Exit with number of affected files as status code -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------