Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df13b155f9 | ||
|
|
eeed5b8718 | ||
|
|
148ef90210 | ||
|
|
67023bb007 | ||
|
|
a316aed4fe | ||
|
|
9f7c0bd599 | ||
|
|
c7e1068f90 | ||
|
|
e2052d790b | ||
|
|
d3b2763c14 | ||
|
|
c6492de7ac | ||
|
|
d8fa0fb50c | ||
|
|
18ab8faa1d | ||
|
|
f35ce180e2 |
@@ -1,658 +1,46 @@
|
||||
◧ opencode
|
||||
```
|
||||
█▀▀█ █▀▀█ █▀▀ █▀▀▄ █▀▀ █▀▀█ █▀▀▄ █▀▀
|
||||
█░░█ █░░█ █▀▀ █░░█ █░░ █░░█ █░░█ █▀▀
|
||||
▀▀▀▀ █▀▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀
|
||||
```
|
||||
|
||||

|
||||
[](https://github.com/sst/opencode)
|
||||
|
||||
> **⚠️ Notice:** We are in progress of a complete overhaul in the `dontlook` branch - should be released mid June. The README below is for the current version
|
||||
AI coding agent, built for the terminal.
|
||||
|
||||
A powerful terminal-based AI assistant for developers, providing intelligent coding assistance directly in your terminal.
|
||||
|
||||
## Overview
|
||||
|
||||
OpenCode is a Go-based CLI application that brings AI assistance to your terminal. It provides a TUI (Terminal User Interface) for interacting with various AI models to help with coding tasks, debugging, and more.
|
||||
|
||||
## Features
|
||||
|
||||
- **Interactive TUI**: Built with [Bubble Tea](https://github.com/charmbracelet/bubbletea) for a smooth terminal experience
|
||||
- **Multiple AI Providers**: Support for OpenAI, Anthropic Claude, Google Gemini, AWS Bedrock, Groq, Azure OpenAI, and OpenRouter
|
||||
- **Session Management**: Save and manage multiple conversation sessions
|
||||
- **Tool Integration**: AI can execute commands, search files, and modify code
|
||||
- **Vim-like Editor**: Integrated editor with text input capabilities
|
||||
- **Persistent Storage**: SQLite database for storing conversations and sessions
|
||||
- **LSP Integration**: Language Server Protocol support for code intelligence
|
||||
- **File Change Tracking**: Track and visualize file changes during sessions
|
||||
- **External Editor Support**: Open your preferred editor for composing messages
|
||||
- **Named Arguments for Custom Commands**: Create powerful custom commands with multiple named placeholders
|
||||
Note: version 0.1.x is a full rewrite and we do not have proper documentation for it yet. Should have this out week of June 17th 2025
|
||||
|
||||
## Installation
|
||||
|
||||
### Using the Install Script
|
||||
If you have a previous version of opencode < 0.1.x installed you might have to remove it first.
|
||||
|
||||
```bash
|
||||
# Install the latest version
|
||||
### Curl
|
||||
|
||||
```
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
# Install a specific version
|
||||
curl -fsSL https://opencode.ai/install | VERSION=0.1.0 bash
|
||||
```
|
||||
|
||||
### Using Homebrew (macOS and Linux)
|
||||
### NPM
|
||||
|
||||
```bash
|
||||
```
|
||||
npm i -g opencode-ai@latest
|
||||
bun i -g opencode-ai@latest
|
||||
pnpm i -g opencode-ai@latest
|
||||
yarn global add opencode-ai@latest
|
||||
```
|
||||
|
||||
### Brew
|
||||
|
||||
```
|
||||
brew install sst/tap/opencode
|
||||
```
|
||||
|
||||
### Using AUR (Arch Linux)
|
||||
### AUR
|
||||
|
||||
```bash
|
||||
# Using yay
|
||||
yay -S opencode-bin
|
||||
|
||||
# Using paru
|
||||
```
|
||||
paru -S opencode-bin
|
||||
```
|
||||
|
||||
### Using Go
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
go install github.com/sst/opencode@latest
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
OpenCode looks for configuration in the following locations:
|
||||
|
||||
- `$HOME/.opencode.json`
|
||||
- `$XDG_CONFIG_HOME/opencode/.opencode.json`
|
||||
- `./.opencode.json` (local directory)
|
||||
|
||||
### Environment Variables
|
||||
|
||||
You can configure OpenCode using environment variables:
|
||||
|
||||
| Environment Variable | Purpose |
|
||||
| -------------------------- | ------------------------------------------------------ |
|
||||
| `ANTHROPIC_API_KEY` | For Claude models |
|
||||
| `OPENAI_API_KEY` | For OpenAI models |
|
||||
| `GEMINI_API_KEY` | For Google Gemini models |
|
||||
| `VERTEXAI_PROJECT` | For Google Cloud VertexAI (Gemini) |
|
||||
| `VERTEXAI_LOCATION` | For Google Cloud VertexAI (Gemini) |
|
||||
| `GROQ_API_KEY` | For Groq models |
|
||||
| `AWS_ACCESS_KEY_ID` | For AWS Bedrock (Claude) |
|
||||
| `AWS_SECRET_ACCESS_KEY` | For AWS Bedrock (Claude) |
|
||||
| `AWS_REGION` | For AWS Bedrock (Claude) |
|
||||
| `AZURE_OPENAI_ENDPOINT` | For Azure OpenAI models |
|
||||
| `AZURE_OPENAI_API_KEY` | For Azure OpenAI models (optional when using Entra ID) |
|
||||
| `AZURE_OPENAI_API_VERSION` | For Azure OpenAI models |
|
||||
|
||||
### Configuration File Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"directory": ".opencode"
|
||||
},
|
||||
"providers": {
|
||||
"openai": {
|
||||
"apiKey": "your-api-key",
|
||||
"disabled": false
|
||||
},
|
||||
"anthropic": {
|
||||
"apiKey": "your-api-key",
|
||||
"disabled": false
|
||||
},
|
||||
"groq": {
|
||||
"apiKey": "your-api-key",
|
||||
"disabled": false
|
||||
},
|
||||
"openrouter": {
|
||||
"apiKey": "your-api-key",
|
||||
"disabled": false
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"primary": {
|
||||
"model": "claude-3.7-sonnet",
|
||||
"maxTokens": 5000
|
||||
},
|
||||
"task": {
|
||||
"model": "claude-3.7-sonnet",
|
||||
"maxTokens": 5000
|
||||
},
|
||||
"title": {
|
||||
"model": "claude-3.7-sonnet",
|
||||
"maxTokens": 80
|
||||
}
|
||||
},
|
||||
"mcpServers": {
|
||||
"example": {
|
||||
"type": "stdio",
|
||||
"command": "path/to/mcp-server",
|
||||
"env": [],
|
||||
"args": []
|
||||
}
|
||||
},
|
||||
"lsp": {
|
||||
"go": {
|
||||
"disabled": false,
|
||||
"command": "gopls"
|
||||
}
|
||||
},
|
||||
"shell": {
|
||||
"path": "/bin/zsh",
|
||||
"args": ["-l"]
|
||||
},
|
||||
"debug": false,
|
||||
"debugLSP": false
|
||||
}
|
||||
```
|
||||
|
||||
## Supported AI Models
|
||||
|
||||
OpenCode supports a variety of AI models from different providers:
|
||||
|
||||
### OpenAI
|
||||
|
||||
- GPT-4.1 family (gpt-4.1, gpt-4.1-mini, gpt-4.1-nano)
|
||||
- GPT-4.5 Preview
|
||||
- GPT-4o family (gpt-4o, gpt-4o-mini)
|
||||
- O1 family (o1, o1-pro, o1-mini)
|
||||
- O3 family (o3, o3-mini)
|
||||
- O4 Mini
|
||||
|
||||
### Anthropic
|
||||
|
||||
- Claude 3.5 Sonnet
|
||||
- Claude 3.5 Haiku
|
||||
- Claude 3.7 Sonnet
|
||||
- Claude 3 Haiku
|
||||
- Claude 3 Opus
|
||||
|
||||
### Google
|
||||
|
||||
- Gemini 2.5
|
||||
- Gemini 2.5 Flash
|
||||
- Gemini 2.0 Flash
|
||||
- Gemini 2.0 Flash Lite
|
||||
|
||||
### AWS Bedrock
|
||||
|
||||
- Claude 3.7 Sonnet
|
||||
|
||||
### Groq
|
||||
|
||||
- Llama 4 Maverick (17b-128e-instruct)
|
||||
- Llama 4 Scout (17b-16e-instruct)
|
||||
- QWEN QWQ-32b
|
||||
- Deepseek R1 distill Llama 70b
|
||||
- Llama 3.3 70b Versatile
|
||||
|
||||
### Azure OpenAI
|
||||
|
||||
- GPT-4.1 family (gpt-4.1, gpt-4.1-mini, gpt-4.1-nano)
|
||||
- GPT-4.5 Preview
|
||||
- GPT-4o family (gpt-4o, gpt-4o-mini)
|
||||
- O1 family (o1, o1-mini)
|
||||
- O3 family (o3, o3-mini)
|
||||
- O4 Mini
|
||||
|
||||
### Google Cloud VertexAI
|
||||
|
||||
- Gemini 2.5
|
||||
- Gemini 2.5 Flash
|
||||
|
||||
## Using Bedrock Models
|
||||
|
||||
To use bedrock models with OpenCode you need three things.
|
||||
|
||||
1. Valid AWS credentials (the env vars: `AWS_SECRET_KEY_ID`, `AWS_SECRET_ACCESS_KEY` and `AWS_REGION`)
|
||||
2. Access to the corresponding model in AWS Bedrock in your region.
|
||||
a. You can request access in the AWS console on the Bedrock -> "Model access" page.
|
||||
3. A correct configuration file. You don't need the `providers` key. Instead you have to prefix your models per agent with `bedrock.` and then a valid model. For now only Claude 3.7 is supported.
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"primary": {
|
||||
"model": "bedrock.claude-3.7-sonnet",
|
||||
"maxTokens": 5000,
|
||||
"reasoningEffort": ""
|
||||
},
|
||||
"task": {
|
||||
"model": "bedrock.claude-3.7-sonnet",
|
||||
"maxTokens": 5000,
|
||||
"reasoningEffort": ""
|
||||
},
|
||||
"title": {
|
||||
"model": "bedrock.claude-3.7-sonnet",
|
||||
"maxTokens": 80,
|
||||
"reasoningEffort": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Interactive Mode Usage
|
||||
|
||||
```bash
|
||||
# Start OpenCode
|
||||
opencode
|
||||
|
||||
# Start with debug logging
|
||||
opencode -d
|
||||
|
||||
# Start with a specific working directory
|
||||
opencode -c /path/to/project
|
||||
```
|
||||
|
||||
## Non-interactive Prompt Mode
|
||||
|
||||
You can run OpenCode in non-interactive mode by passing a prompt directly as a command-line argument or by piping text into the command. This is useful for scripting, automation, or when you want a quick answer without launching the full TUI.
|
||||
|
||||
```bash
|
||||
# Run a single prompt and print the AI's response to the terminal
|
||||
opencode -p "Explain the use of context in Go"
|
||||
|
||||
# Pipe input to OpenCode (equivalent to using -p flag)
|
||||
echo "Explain the use of context in Go" | opencode
|
||||
|
||||
# Get response in JSON format
|
||||
opencode -p "Explain the use of context in Go" -f json
|
||||
# Or with piped input
|
||||
echo "Explain the use of context in Go" | opencode -f json
|
||||
|
||||
# Run without showing the spinner
|
||||
opencode -p "Explain the use of context in Go" -q
|
||||
# Or with piped input
|
||||
echo "Explain the use of context in Go" | opencode -q
|
||||
|
||||
# Enable verbose logging to stderr
|
||||
opencode -p "Explain the use of context in Go" --verbose
|
||||
# Or with piped input
|
||||
echo "Explain the use of context in Go" | opencode --verbose
|
||||
|
||||
# Restrict the agent to only use specific tools
|
||||
opencode -p "Explain the use of context in Go" --allowedTools=view,ls,glob
|
||||
# Or with piped input
|
||||
echo "Explain the use of context in Go" | opencode --allowedTools=view,ls,glob
|
||||
|
||||
# Prevent the agent from using specific tools
|
||||
opencode -p "Explain the use of context in Go" --excludedTools=bash,edit
|
||||
# Or with piped input
|
||||
echo "Explain the use of context in Go" | opencode --excludedTools=bash,edit
|
||||
```
|
||||
|
||||
In this mode, OpenCode will process your prompt, print the result to standard output, and then exit. All permissions are auto-approved for the session.
|
||||
|
||||
### Tool Restrictions
|
||||
|
||||
You can control which tools the AI assistant has access to in non-interactive mode:
|
||||
|
||||
- `--allowedTools`: Comma-separated list of tools that the agent is allowed to use. Only these tools will be available.
|
||||
- `--excludedTools`: Comma-separated list of tools that the agent is not allowed to use. All other tools will be available.
|
||||
|
||||
These flags are mutually exclusive - you can use either `--allowedTools` or `--excludedTools`, but not both at the same time.
|
||||
|
||||
### Output Formats
|
||||
|
||||
OpenCode supports the following output formats in non-interactive mode:
|
||||
|
||||
| Format | Description |
|
||||
| ------ | ------------------------------- |
|
||||
| `text` | Plain text output (default) |
|
||||
| `json` | Output wrapped in a JSON object |
|
||||
|
||||
The output format is implemented as a strongly-typed `OutputFormat` in the codebase, ensuring type safety and validation when processing outputs.
|
||||
|
||||
## Command-line Flags
|
||||
|
||||
| Flag | Short | Description |
|
||||
| ----------------- | ----- | --------------------------------------------------- |
|
||||
| `--help` | `-h` | Display help information |
|
||||
| `--debug` | `-d` | Enable debug mode |
|
||||
| `--cwd` | `-c` | Set current working directory |
|
||||
| `--prompt` | `-p` | Run a single prompt in non-interactive mode |
|
||||
| `--output-format` | `-f` | Output format for non-interactive mode (text, json) |
|
||||
| `--quiet` | `-q` | Hide spinner in non-interactive mode |
|
||||
| `--verbose` | | Display logs to stderr in non-interactive mode |
|
||||
| `--allowedTools` | | Restrict the agent to only use specified tools |
|
||||
| `--excludedTools` | | Prevent the agent from using specified tools |
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
### Global Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
| -------- | ------------------------------------------------------- |
|
||||
| `Ctrl+C` | Quit application |
|
||||
| `Ctrl+?` | Toggle help dialog |
|
||||
| `?` | Toggle help dialog (when not in editing mode) |
|
||||
| `Ctrl+L` | View logs |
|
||||
| `Ctrl+A` | Switch session |
|
||||
| `Ctrl+K` | Command dialog |
|
||||
| `Ctrl+O` | Toggle model selection dialog |
|
||||
| `Esc` | Close current overlay/dialog or return to previous mode |
|
||||
|
||||
### Chat Page Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
| -------- | --------------------------------------- |
|
||||
| `Ctrl+N` | Create new session |
|
||||
| `Ctrl+X` | Cancel current operation/generation |
|
||||
| `i` | Focus editor (when not in writing mode) |
|
||||
| `Esc` | Exit writing mode and focus messages |
|
||||
|
||||
### Editor Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
| ------------------- | ----------------------------------------- |
|
||||
| `Ctrl+S` | Send message (when editor is focused) |
|
||||
| `Enter` or `Ctrl+S` | Send message (when editor is not focused) |
|
||||
| `Ctrl+E` | Open external editor |
|
||||
| `Esc` | Blur editor and focus messages |
|
||||
|
||||
### Session Dialog Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
| ---------- | ---------------- |
|
||||
| `↑` or `k` | Previous session |
|
||||
| `↓` or `j` | Next session |
|
||||
| `Enter` | Select session |
|
||||
| `Esc` | Close dialog |
|
||||
|
||||
### Model Dialog Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
| ---------- | ----------------- |
|
||||
| `↑` or `k` | Move up |
|
||||
| `↓` or `j` | Move down |
|
||||
| `←` or `h` | Previous provider |
|
||||
| `→` or `l` | Next provider |
|
||||
| `Esc` | Close dialog |
|
||||
|
||||
### Permission Dialog Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
| ----------------------- | ---------------------------- |
|
||||
| `←` or `left` | Switch options left |
|
||||
| `→` or `right` or `tab` | Switch options right |
|
||||
| `Enter` or `space` | Confirm selection |
|
||||
| `a` | Allow permission |
|
||||
| `A` | Allow permission for session |
|
||||
| `d` | Deny permission |
|
||||
|
||||
### Logs Page Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
| ------------------ | ------------------- |
|
||||
| `Backspace` or `q` | Return to chat page |
|
||||
|
||||
## AI Assistant Tools
|
||||
|
||||
OpenCode's AI assistant has access to various tools to help with coding tasks:
|
||||
|
||||
### File and Code Tools
|
||||
|
||||
| Tool | Description | Parameters |
|
||||
| ------------- | --------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| `glob` | Find files by pattern | `pattern` (required), `path` (optional) |
|
||||
| `grep` | Search file contents | `pattern` (required), `path` (optional), `include` (optional), `literal_text` (optional) |
|
||||
| `ls` | List directory contents | `path` (optional), `ignore` (optional array of patterns) |
|
||||
| `view` | View file contents | `file_path` (required), `offset` (optional), `limit` (optional) |
|
||||
| `write` | Write to files | `file_path` (required), `content` (required) |
|
||||
| `edit` | Edit files | Various parameters for file editing |
|
||||
| `patch` | Apply patches to files | `file_path` (required), `diff` (required) |
|
||||
| `diagnostics` | Get diagnostics information | `file_path` (optional) |
|
||||
|
||||
### Other Tools
|
||||
|
||||
| Tool | Description | Parameters |
|
||||
| ------- | ------------------------------- | ----------------------------------------------------------- |
|
||||
| `bash` | Execute shell commands | `command` (required), `timeout` (optional) |
|
||||
| `fetch` | Fetch data from URLs | `url` (required), `format` (required), `timeout` (optional) |
|
||||
| `agent` | Run sub-tasks with the AI agent | `prompt` (required) |
|
||||
|
||||
### Shell Configuration
|
||||
|
||||
OpenCode allows you to configure the shell used by the `bash` tool. By default, it uses:
|
||||
|
||||
1. The shell specified in the config file (if provided)
|
||||
2. The shell from the `$SHELL` environment variable (if available)
|
||||
3. Falls back to `/bin/bash` if neither of the above is available
|
||||
|
||||
To configure a custom shell, add a `shell` section to your `.opencode.json` configuration file:
|
||||
|
||||
```json
|
||||
{
|
||||
"shell": {
|
||||
"path": "/bin/zsh",
|
||||
"args": ["-l"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You can specify any shell executable and custom arguments:
|
||||
|
||||
```json
|
||||
{
|
||||
"shell": {
|
||||
"path": "/usr/bin/fish",
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
OpenCode is built with a modular architecture:
|
||||
|
||||
- **cmd**: Command-line interface using Cobra
|
||||
- **internal/app**: Core application services
|
||||
- **internal/config**: Configuration management
|
||||
- **internal/db**: Database operations and migrations
|
||||
- **internal/llm**: LLM providers and tools integration
|
||||
- **internal/tui**: Terminal UI components and layouts
|
||||
- **internal/logging**: Logging infrastructure
|
||||
- **internal/message**: Message handling
|
||||
- **internal/session**: Session management
|
||||
- **internal/lsp**: Language Server Protocol integration
|
||||
|
||||
## Custom Commands
|
||||
|
||||
OpenCode supports custom commands that can be created by users to quickly send predefined prompts to the AI assistant.
|
||||
|
||||
### Creating Custom Commands
|
||||
|
||||
Custom commands are predefined prompts stored as Markdown files in one of three locations:
|
||||
|
||||
1. **User Commands** (prefixed with `user:`):
|
||||
|
||||
```
|
||||
$XDG_CONFIG_HOME/opencode/commands/
|
||||
```
|
||||
|
||||
(typically `~/.config/opencode/commands/` on Linux/macOS)
|
||||
|
||||
or
|
||||
|
||||
```
|
||||
$HOME/.opencode/commands/
|
||||
```
|
||||
|
||||
2. **Project Commands** (prefixed with `project:`):
|
||||
```
|
||||
<PROJECT DIR>/.opencode/commands/
|
||||
```
|
||||
|
||||
Each `.md` file in these directories becomes a custom command. The file name (without extension) becomes the command ID.
|
||||
|
||||
For example, creating a file at `~/.config/opencode/commands/prime-context.md` with content:
|
||||
|
||||
```markdown
|
||||
RUN git ls-files
|
||||
READ README.md
|
||||
```
|
||||
|
||||
This creates a command called `user:prime-context`.
|
||||
|
||||
### Command Arguments
|
||||
|
||||
OpenCode supports named arguments in custom commands using placeholders in the format `$NAME` (where NAME consists of uppercase letters, numbers, and underscores, and must start with a letter).
|
||||
|
||||
For example:
|
||||
|
||||
```markdown
|
||||
# Fetch Context for Issue $ISSUE_NUMBER
|
||||
|
||||
RUN gh issue view $ISSUE_NUMBER --json title,body,comments
|
||||
RUN git grep --author="$AUTHOR_NAME" -n .
|
||||
RUN grep -R "$SEARCH_PATTERN" $DIRECTORY
|
||||
```
|
||||
|
||||
When you run a command with arguments, OpenCode will prompt you to enter values for each unique placeholder. Named arguments provide several benefits:
|
||||
|
||||
- Clear identification of what each argument represents
|
||||
- Ability to use the same argument multiple times
|
||||
- Better organization for commands with multiple inputs
|
||||
|
||||
### Organizing Commands
|
||||
|
||||
You can organize commands in subdirectories:
|
||||
|
||||
```
|
||||
~/.config/opencode/commands/git/commit.md
|
||||
```
|
||||
|
||||
This creates a command with ID `user:git:commit`.
|
||||
|
||||
### Using Custom Commands
|
||||
|
||||
1. Press `Ctrl+K` to open the command dialog
|
||||
2. Select your custom command (prefixed with either `user:` or `project:`)
|
||||
3. Press Enter to execute the command
|
||||
|
||||
The content of the command file will be sent as a message to the AI assistant.
|
||||
|
||||
## MCP (Model Context Protocol)
|
||||
|
||||
OpenCode implements the Model Context Protocol (MCP) to extend its capabilities through external tools. MCP provides a standardized way for the AI assistant to interact with external services and tools.
|
||||
|
||||
### MCP Features
|
||||
|
||||
- **External Tool Integration**: Connect to external tools and services via a standardized protocol
|
||||
- **Tool Discovery**: Automatically discover available tools from MCP servers
|
||||
- **Multiple Connection Types**:
|
||||
- **Stdio**: Communicate with tools via standard input/output
|
||||
- **SSE**: Communicate with tools via Server-Sent Events
|
||||
- **Security**: Permission system for controlling access to MCP tools
|
||||
|
||||
### Configuring MCP Servers
|
||||
|
||||
MCP servers are defined in the configuration file under the `mcpServers` section:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"example": {
|
||||
"type": "stdio",
|
||||
"command": "path/to/mcp-server",
|
||||
"env": [],
|
||||
"args": []
|
||||
},
|
||||
"web-example": {
|
||||
"type": "sse",
|
||||
"url": "https://example.com/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### MCP Tool Usage
|
||||
|
||||
Once configured, MCP tools are automatically available to the AI assistant alongside built-in tools. They follow the same permission model as other tools, requiring user approval before execution.
|
||||
|
||||
## LSP (Language Server Protocol)
|
||||
|
||||
OpenCode integrates with Language Server Protocol to provide code intelligence features across multiple programming languages.
|
||||
|
||||
### LSP Features
|
||||
|
||||
- **Multi-language Support**: Connect to language servers for different programming languages
|
||||
- **Diagnostics**: Receive error checking and linting information
|
||||
- **File Watching**: Automatically notify language servers of file changes
|
||||
|
||||
### Configuring LSP
|
||||
|
||||
Language servers are configured in the configuration file under the `lsp` section:
|
||||
|
||||
```json
|
||||
{
|
||||
"lsp": {
|
||||
"go": {
|
||||
"disabled": false,
|
||||
"command": "gopls"
|
||||
},
|
||||
"typescript": {
|
||||
"disabled": false,
|
||||
"command": "typescript-language-server",
|
||||
"args": ["--stdio"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### LSP Integration with AI
|
||||
|
||||
The AI assistant can access LSP features through the `diagnostics` tool, allowing it to:
|
||||
|
||||
- Check for errors in your code
|
||||
- Suggest fixes based on diagnostics
|
||||
|
||||
While the LSP client implementation supports the full LSP protocol (including completions, hover, definition, etc.), currently only diagnostics are exposed to the AI assistant.
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Go 1.24.0 or higher
|
||||
|
||||
### Building from Source
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/sst/opencode.git
|
||||
cd opencode
|
||||
|
||||
# Build
|
||||
go build -o opencode
|
||||
|
||||
# Run
|
||||
./opencode
|
||||
```
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
OpenCode gratefully acknowledges the contributions and support from these key individuals:
|
||||
|
||||
- [@isaacphi](https://github.com/isaacphi) - For the [mcp-language-server](https://github.com/isaacphi/mcp-language-server) project which provided the foundation for our LSP client implementation
|
||||
- [@adamdottv](https://github.com/adamdottv) - For the design direction and UI/UX architecture
|
||||
|
||||
Special thanks to the broader open source community whose tools and libraries have made this project possible.
|
||||
|
||||
## License
|
||||
|
||||
OpenCode is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Here's how you can contribute:
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
|
||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
Please make sure to update tests as appropriate and follow the existing code style.
|
||||
We are working on proper keybinds - right now it's the various function keys press F1 to see them
|
||||
|
||||
@@ -12,23 +12,28 @@ requested_version=${VERSION:-}
|
||||
|
||||
os=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
if [[ "$os" == "darwin" ]]; then
|
||||
os="mac"
|
||||
os="darwin"
|
||||
fi
|
||||
arch=$(uname -m)
|
||||
|
||||
if [[ "$arch" == "aarch64" ]]; then
|
||||
arch="arm64"
|
||||
elif [[ "$arch" == "x86_64" ]]; then
|
||||
arch="x64"
|
||||
fi
|
||||
|
||||
filename="$APP-$os-$arch.tar.gz"
|
||||
filename="$APP-$os-$arch.zip"
|
||||
|
||||
|
||||
case "$filename" in
|
||||
*"-linux-"*)
|
||||
[[ "$arch" == "x86_64" || "$arch" == "arm64" || "$arch" == "i386" ]] || exit 1
|
||||
[[ "$arch" == "x64" || "$arch" == "arm64" ]] || exit 1
|
||||
;;
|
||||
*"-mac-"*)
|
||||
[[ "$arch" == "x86_64" || "$arch" == "arm64" ]] || exit 1
|
||||
*"-darwin-"*)
|
||||
[[ "$arch" == "x64" || "$arch" == "arm64" ]] || exit 1
|
||||
;;
|
||||
*"-windows-"*)
|
||||
[[ "$arch" == "x64" ]] || exit 1
|
||||
;;
|
||||
*)
|
||||
echo "${RED}Unsupported OS/Arch: $os/$arch${NC}"
|
||||
@@ -88,8 +93,9 @@ check_version() {
|
||||
download_and_install() {
|
||||
print_message info "Downloading ${ORANGE}opencode ${GREEN}version: ${YELLOW}$specific_version ${GREEN}..."
|
||||
mkdir -p opencodetmp && cd opencodetmp
|
||||
curl -# -L $url | tar xz
|
||||
mv opencode $INSTALL_DIR
|
||||
curl -# -L -o "$filename" "$url"
|
||||
unzip -q "$filename"
|
||||
mv opencode "$INSTALL_DIR"
|
||||
cd .. && rm -rf opencodetmp
|
||||
}
|
||||
|
||||
|
||||
@@ -226,7 +226,7 @@ if (!snapshot) {
|
||||
].join("\n")
|
||||
|
||||
await $`rm -rf ./dist/homebrew-tap`
|
||||
await $`git clone git@github.com:sst/homebrew-tap.git ./dist/homebrew-tap`
|
||||
await $`git clone https://${process.env["GITHUB_TOKEN"]}@github.com/sst/homebrew-tap.git ./dist/homebrew-tap`
|
||||
await Bun.file("./dist/homebrew-tap/opencode.rb").write(homebrewFormula)
|
||||
await $`cd ./dist/homebrew-tap && git add opencode.rb`
|
||||
await $`cd ./dist/homebrew-tap && git commit -m "Update to v${version}"`
|
||||
|
||||
@@ -92,12 +92,6 @@ export namespace Session {
|
||||
log.info("created", result)
|
||||
state().sessions.set(result.id, result)
|
||||
await Storage.writeJSON("session/info/" + result.id, result)
|
||||
if (!result.parentID)
|
||||
share(result.id).then((share) => {
|
||||
update(result.id, (draft) => {
|
||||
draft.share = share
|
||||
})
|
||||
})
|
||||
Bus.publish(Event.Updated, {
|
||||
info: result,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package completions
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/lithammer/fuzzysearch/fuzzy"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/components/dialog"
|
||||
)
|
||||
|
||||
type CommandCompletionProvider struct {
|
||||
app *app.App
|
||||
}
|
||||
|
||||
func NewCommandCompletionProvider(app *app.App) dialog.CompletionProvider {
|
||||
return &CommandCompletionProvider{app: app}
|
||||
}
|
||||
|
||||
func (c *CommandCompletionProvider) GetId() string {
|
||||
return "commands"
|
||||
}
|
||||
|
||||
func (c *CommandCompletionProvider) GetEntry() dialog.CompletionItemI {
|
||||
return dialog.NewCompletionItem(dialog.CompletionItem{
|
||||
Title: "Commands",
|
||||
Value: "commands",
|
||||
})
|
||||
}
|
||||
|
||||
func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
|
||||
if query == "" {
|
||||
// If no query, return all commands
|
||||
items := []dialog.CompletionItemI{}
|
||||
for _, cmd := range c.app.Commands {
|
||||
items = append(items, dialog.NewCompletionItem(dialog.CompletionItem{
|
||||
Title: " /" + cmd.Name,
|
||||
Value: "/" + cmd.Name,
|
||||
}))
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// Use fuzzy matching for commands
|
||||
var commandNames []string
|
||||
commandMap := make(map[string]dialog.CompletionItemI)
|
||||
|
||||
for _, cmd := range c.app.Commands {
|
||||
commandNames = append(commandNames, cmd.Name)
|
||||
commandMap[cmd.Name] = dialog.NewCompletionItem(dialog.CompletionItem{
|
||||
Title: " /" + cmd.Name,
|
||||
Value: "/" + cmd.Name,
|
||||
})
|
||||
}
|
||||
|
||||
// Find fuzzy matches
|
||||
matches := fuzzy.RankFind(query, commandNames)
|
||||
|
||||
// Sort by score (best matches first)
|
||||
sort.Sort(matches)
|
||||
|
||||
// Convert matches to completion items
|
||||
items := []dialog.CompletionItemI{}
|
||||
for _, match := range matches {
|
||||
if item, ok := commandMap[match.Target]; ok {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
package completions
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/components/dialog"
|
||||
"github.com/sst/opencode/pkg/client"
|
||||
)
|
||||
|
||||
type filesAndFoldersContextGroup struct {
|
||||
app *app.App
|
||||
prefix string
|
||||
}
|
||||
|
||||
@@ -20,7 +25,17 @@ func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI {
|
||||
}
|
||||
|
||||
func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) {
|
||||
return []string{}, nil
|
||||
response, err := cg.app.Client.PostFileSearchWithResponse(context.Background(), client.PostFileSearchJSONRequestBody{
|
||||
Query: query,
|
||||
})
|
||||
if err != nil {
|
||||
return []string{}, err
|
||||
}
|
||||
if response.JSON200 == nil {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
return *response.JSON200, nil
|
||||
}
|
||||
|
||||
func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
|
||||
@@ -41,8 +56,9 @@ func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.C
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func NewFileAndFolderContextGroup() dialog.CompletionProvider {
|
||||
func NewFileAndFolderContextGroup(app *app.App) dialog.CompletionProvider {
|
||||
return &filesAndFoldersContextGroup{
|
||||
app: app,
|
||||
prefix: "file",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package completions
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/components/dialog"
|
||||
)
|
||||
|
||||
type CompletionManager struct {
|
||||
providers map[string]dialog.CompletionProvider
|
||||
}
|
||||
|
||||
func NewCompletionManager(app *app.App) *CompletionManager {
|
||||
return &CompletionManager{
|
||||
providers: map[string]dialog.CompletionProvider{
|
||||
"files": NewFileAndFolderContextGroup(app),
|
||||
"commands": NewCommandCompletionProvider(app),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *CompletionManager) GetProvider(input string) dialog.CompletionProvider {
|
||||
if strings.HasPrefix(input, "/") {
|
||||
return m.providers["commands"]
|
||||
}
|
||||
return m.providers["files"]
|
||||
}
|
||||
|
||||
@@ -44,11 +44,6 @@ type EditorKeyMaps struct {
|
||||
HistoryDown key.Binding
|
||||
}
|
||||
|
||||
type bluredEditorKeyMaps struct {
|
||||
Send key.Binding
|
||||
Focus key.Binding
|
||||
OpenEditor key.Binding
|
||||
}
|
||||
type DeleteAttachmentKeyMaps struct {
|
||||
AttachmentDeleteMode key.Binding
|
||||
Escape key.Binding
|
||||
@@ -108,10 +103,18 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case dialog.ThemeChangedMsg:
|
||||
m.textarea = createTextArea(&m.textarea)
|
||||
case dialog.CompletionSelectedMsg:
|
||||
existingValue := m.textarea.Value()
|
||||
modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
|
||||
m.textarea.SetValue(modifiedValue)
|
||||
return m, nil
|
||||
if msg.IsCommand {
|
||||
// Execute the command directly
|
||||
commandName := strings.TrimPrefix(msg.CompletionValue, "/")
|
||||
m.textarea.Reset()
|
||||
return m, util.CmdHandler(commands.ExecuteCommandMsg{Name: commandName})
|
||||
} else {
|
||||
// For files, replace the text in the editor
|
||||
existingValue := m.textarea.Value()
|
||||
modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
|
||||
m.textarea.SetValue(modifiedValue)
|
||||
return m, nil
|
||||
}
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
@@ -378,12 +381,13 @@ func (m *editorComponent) send() tea.Cmd {
|
||||
}
|
||||
|
||||
// Check for slash command
|
||||
if strings.HasPrefix(value, "/") {
|
||||
commandName := strings.TrimPrefix(value, "/")
|
||||
if _, ok := m.app.Commands[commandName]; ok {
|
||||
return util.CmdHandler(commands.ExecuteCommandMsg{Name: commandName})
|
||||
}
|
||||
}
|
||||
// if strings.HasPrefix(value, "/") {
|
||||
// commandName := strings.TrimPrefix(value, "/")
|
||||
// if _, ok := m.app.Commands[commandName]; ok {
|
||||
// return util.CmdHandler(commands.ExecuteCommandMsg{Name: commandName})
|
||||
// }
|
||||
// }
|
||||
slog.Info("Send message", "value", value)
|
||||
|
||||
return tea.Batch(
|
||||
util.CmdHandler(SendMsg{
|
||||
@@ -452,6 +456,10 @@ func createTextArea(existing *textarea.Model) textarea.Model {
|
||||
return ta
|
||||
}
|
||||
|
||||
func (m *editorComponent) GetValue() string {
|
||||
return m.textarea.Value()
|
||||
}
|
||||
|
||||
func NewEditorComponent(app *app.App) layout.ModelWithView {
|
||||
s := spinner.New(spinner.WithSpinner(spinner.Ellipsis), spinner.WithStyle(styles.Muted().Width(3)))
|
||||
ta := createTextArea(nil)
|
||||
|
||||
@@ -216,7 +216,7 @@ func renderText(message client.MessageInfo, text string, author string) string {
|
||||
align = lipgloss.Left
|
||||
}
|
||||
|
||||
textWidth := lipgloss.Width(text)
|
||||
textWidth := max(lipgloss.Width(text), lipgloss.Width(info))
|
||||
markdownWidth := min(textWidth, width-padding-4) // -4 for the border and padding
|
||||
content := toMarkdown(text, markdownWidth, t.BackgroundSubtle())
|
||||
content = lipgloss.JoinVertical(align, content, info)
|
||||
@@ -299,13 +299,9 @@ func renderToolInvocation(
|
||||
body := ""
|
||||
error := ""
|
||||
finished := result != nil && *result != ""
|
||||
if finished {
|
||||
body = *result
|
||||
}
|
||||
|
||||
if e, ok := metadata.Get("error"); ok && e.(bool) == true {
|
||||
if m, ok := metadata.Get("message"); ok {
|
||||
body = "" // don't show the body if there's an error
|
||||
style = style.BorderLeftForeground(t.Error())
|
||||
error = styles.BaseStyle().
|
||||
Background(t.BackgroundSubtle()).
|
||||
@@ -336,58 +332,61 @@ func renderToolInvocation(
|
||||
case "opencode_read":
|
||||
toolArgs = renderArgs(&toolArgsMap, "filePath")
|
||||
title = fmt.Sprintf("Read: %s %s", toolArgs, elapsed)
|
||||
body = ""
|
||||
if preview, ok := metadata.Get("preview"); ok && toolArgsMap["filePath"] != nil {
|
||||
filename := toolArgsMap["filePath"].(string)
|
||||
body = preview.(string)
|
||||
body = renderFile(filename, body, WithTruncate(6))
|
||||
}
|
||||
case "opencode_edit":
|
||||
filename := toolArgsMap["filePath"].(string)
|
||||
title = fmt.Sprintf("Edit: %s %s", relative(filename), elapsed)
|
||||
if d, ok := metadata.Get("diff"); ok {
|
||||
patch := d.(string)
|
||||
var formattedDiff string
|
||||
if layout.Current.Viewport.Width < 80 {
|
||||
formattedDiff, _ = diff.FormatUnifiedDiff(
|
||||
filename,
|
||||
patch,
|
||||
diff.WithWidth(layout.Current.Container.Width-2),
|
||||
if filename, ok := toolArgsMap["filePath"].(string); ok {
|
||||
title = fmt.Sprintf("Edit: %s %s", relative(filename), elapsed)
|
||||
if d, ok := metadata.Get("diff"); ok {
|
||||
patch := d.(string)
|
||||
var formattedDiff string
|
||||
if layout.Current.Viewport.Width < 80 {
|
||||
formattedDiff, _ = diff.FormatUnifiedDiff(
|
||||
filename,
|
||||
patch,
|
||||
diff.WithWidth(layout.Current.Container.Width-2),
|
||||
)
|
||||
} else {
|
||||
diffWidth := min(layout.Current.Viewport.Width-2, 120)
|
||||
formattedDiff, _ = diff.FormatDiff(filename, patch, diff.WithTotalWidth(diffWidth))
|
||||
}
|
||||
formattedDiff = strings.TrimSpace(formattedDiff)
|
||||
formattedDiff = lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
BorderForeground(t.BackgroundSubtle()).
|
||||
BorderLeft(true).
|
||||
BorderRight(true).
|
||||
Render(formattedDiff)
|
||||
|
||||
if showResult {
|
||||
style = style.Width(lipgloss.Width(formattedDiff))
|
||||
title += "\n"
|
||||
}
|
||||
|
||||
body = strings.TrimSpace(formattedDiff)
|
||||
body = lipgloss.Place(
|
||||
layout.Current.Viewport.Width,
|
||||
lipgloss.Height(body)+1,
|
||||
lipgloss.Center,
|
||||
lipgloss.Top,
|
||||
body,
|
||||
)
|
||||
} else {
|
||||
diffWidth := min(layout.Current.Viewport.Width-2, 120)
|
||||
formattedDiff, _ = diff.FormatDiff(filename, patch, diff.WithTotalWidth(diffWidth))
|
||||
}
|
||||
formattedDiff = strings.TrimSpace(formattedDiff)
|
||||
formattedDiff = lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
BorderForeground(t.BackgroundSubtle()).
|
||||
BorderLeft(true).
|
||||
BorderRight(true).
|
||||
Render(formattedDiff)
|
||||
|
||||
if showResult {
|
||||
style = style.Width(lipgloss.Width(formattedDiff))
|
||||
title += "\n"
|
||||
}
|
||||
|
||||
body = strings.TrimSpace(formattedDiff)
|
||||
body = lipgloss.Place(
|
||||
layout.Current.Viewport.Width,
|
||||
lipgloss.Height(body)+1,
|
||||
lipgloss.Center,
|
||||
lipgloss.Top,
|
||||
body,
|
||||
)
|
||||
}
|
||||
case "opencode_write":
|
||||
filename := toolArgsMap["filePath"].(string)
|
||||
title = fmt.Sprintf("Write: %s %s", relative(filename), elapsed)
|
||||
content := toolArgsMap["content"].(string)
|
||||
body = renderFile(filename, content)
|
||||
if filename, ok := toolArgsMap["filePath"].(string); ok {
|
||||
title = fmt.Sprintf("Write: %s %s", relative(filename), elapsed)
|
||||
if content, ok := toolArgsMap["content"].(string); ok {
|
||||
body = renderFile(filename, content)
|
||||
}
|
||||
}
|
||||
case "opencode_bash":
|
||||
description := toolArgsMap["description"].(string)
|
||||
title = fmt.Sprintf("Shell: %s %s", description, elapsed)
|
||||
if description, ok := toolArgsMap["description"].(string); ok {
|
||||
title = fmt.Sprintf("Shell: %s %s", description, elapsed)
|
||||
}
|
||||
if stdout, ok := metadata.Get("stdout"); ok {
|
||||
command := toolArgsMap["command"].(string)
|
||||
stdout := stdout.(string)
|
||||
@@ -396,18 +395,20 @@ func renderToolInvocation(
|
||||
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
|
||||
}
|
||||
case "opencode_webfetch":
|
||||
toolArgs = renderArgs(&toolArgsMap, "url")
|
||||
title = fmt.Sprintf("Fetching: %s %s", toolArgs, elapsed)
|
||||
format := toolArgsMap["format"].(string)
|
||||
body = truncateHeight(body, 10)
|
||||
if format == "html" || format == "markdown" {
|
||||
body = toMarkdown(body, innerWidth, t.BackgroundSubtle())
|
||||
if format, ok := toolArgsMap["format"].(string); ok {
|
||||
body = *result
|
||||
body = truncateHeight(body, 10)
|
||||
if format == "html" || format == "markdown" {
|
||||
body = toMarkdown(body, innerWidth, t.BackgroundSubtle())
|
||||
}
|
||||
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
|
||||
}
|
||||
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
|
||||
case "opencode_todowrite":
|
||||
title = fmt.Sprintf("Planning %s", elapsed)
|
||||
|
||||
if to, ok := metadata.Get("todos"); ok && finished {
|
||||
body = ""
|
||||
todos := to.([]any)
|
||||
for _, todo := range todos {
|
||||
t := todo.(map[string]any)
|
||||
@@ -416,7 +417,7 @@ func renderToolInvocation(
|
||||
case "completed":
|
||||
body += fmt.Sprintf("- [x] %s\n", content)
|
||||
// case "in-progress":
|
||||
// body += fmt.Sprintf("- [ ] _%s_\n", content)
|
||||
// body += fmt.Sprintf("- [ ] %s\n", content)
|
||||
default:
|
||||
body += fmt.Sprintf("- [ ] %s\n", content)
|
||||
}
|
||||
@@ -427,6 +428,13 @@ func renderToolInvocation(
|
||||
default:
|
||||
toolName := renderToolName(toolCall.ToolName)
|
||||
title = fmt.Sprintf("%s: %s %s", toolName, toolArgs, elapsed)
|
||||
body = *result
|
||||
body = truncateHeight(body, 10)
|
||||
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
|
||||
}
|
||||
|
||||
if body == "" && error == "" {
|
||||
body = *result
|
||||
body = truncateHeight(body, 10)
|
||||
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ func (m *messagesComponent) header() string {
|
||||
base := styles.BaseStyle().Render
|
||||
muted := styles.Muted().Render
|
||||
headerLines := []string{}
|
||||
headerLines = append(headerLines, toMarkdown("# "+m.app.Session.Title, width-4, t.Background()))
|
||||
headerLines = append(headerLines, toMarkdown("# "+m.app.Session.Title, width-6, t.Background()))
|
||||
if m.app.Session.Share != nil && m.app.Session.Share.Url != "" {
|
||||
headerLines = append(headerLines, muted(m.app.Session.Share.Url))
|
||||
} else {
|
||||
@@ -256,6 +256,7 @@ func (m *messagesComponent) header() string {
|
||||
header = styles.BaseStyle().
|
||||
Width(width).
|
||||
PaddingLeft(2).
|
||||
PaddingRight(2).
|
||||
// Background(t.BackgroundElement()).
|
||||
BorderLeft(true).
|
||||
BorderRight(true).
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"github.com/charmbracelet/bubbles/v2/textarea"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
utilComponents "github.com/sst/opencode/internal/components/util"
|
||||
"github.com/sst/opencode/internal/components/list"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/status"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
@@ -20,7 +20,7 @@ type CompletionItem struct {
|
||||
}
|
||||
|
||||
type CompletionItemI interface {
|
||||
utilComponents.ListItem
|
||||
list.ListItem
|
||||
GetValue() string
|
||||
DisplayValue() string
|
||||
}
|
||||
@@ -30,18 +30,18 @@ func (ci *CompletionItem) Render(selected bool, width int) string {
|
||||
baseStyle := styles.BaseStyle()
|
||||
|
||||
itemStyle := baseStyle.
|
||||
Background(t.BackgroundElement()).
|
||||
Width(width).
|
||||
Padding(0, 1)
|
||||
|
||||
if selected {
|
||||
itemStyle = itemStyle.
|
||||
Background(t.Background()).
|
||||
Foreground(t.Primary()).
|
||||
Bold(true)
|
||||
}
|
||||
|
||||
title := itemStyle.Render(
|
||||
ci.GetValue(),
|
||||
ci.DisplayValue(),
|
||||
)
|
||||
|
||||
return title
|
||||
@@ -68,6 +68,7 @@ type CompletionProvider interface {
|
||||
type CompletionSelectedMsg struct {
|
||||
SearchString string
|
||||
CompletionValue string
|
||||
IsCommand bool
|
||||
}
|
||||
|
||||
type CompletionDialogCompleteItemMsg struct {
|
||||
@@ -80,6 +81,7 @@ type CompletionDialog interface {
|
||||
layout.ModelWithView
|
||||
SetWidth(width int)
|
||||
IsEmpty() bool
|
||||
SetProvider(provider CompletionProvider)
|
||||
}
|
||||
|
||||
type completionDialogComponent struct {
|
||||
@@ -88,7 +90,7 @@ type completionDialogComponent struct {
|
||||
width int
|
||||
height int
|
||||
pseudoSearchTextArea textarea.Model
|
||||
list utilComponents.List[CompletionItemI]
|
||||
list list.List[CompletionItemI]
|
||||
}
|
||||
|
||||
type completionDialogKeyMap struct {
|
||||
@@ -116,10 +118,14 @@ func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if this is a command completion
|
||||
isCommand := c.completionProvider.GetId() == "commands"
|
||||
|
||||
return tea.Batch(
|
||||
util.CmdHandler(CompletionSelectedMsg{
|
||||
SearchString: value,
|
||||
CompletionValue: item.GetValue(),
|
||||
IsCommand: isCommand,
|
||||
}),
|
||||
c.close(),
|
||||
)
|
||||
@@ -160,7 +166,7 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
u, cmd := c.list.Update(msg)
|
||||
c.list = u.(utilComponents.List[CompletionItemI])
|
||||
c.list = u.(list.List[CompletionItemI])
|
||||
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
@@ -171,8 +177,7 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if i == -1 {
|
||||
return c, nil
|
||||
}
|
||||
cmd := c.complete(item)
|
||||
return c, cmd
|
||||
return c, c.complete(item)
|
||||
case key.Matches(msg, completionDialogKeys.Cancel):
|
||||
// Only close on backspace when there are no characters left
|
||||
if msg.String() != "backspace" || len(c.pseudoSearchTextArea.Value()) <= 0 {
|
||||
@@ -203,21 +208,20 @@ func (c *completionDialogComponent) View() string {
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.BaseStyle()
|
||||
|
||||
// maxWidth := 40
|
||||
//
|
||||
// completions := c.list.GetItems()
|
||||
maxWidth := 40
|
||||
completions := c.list.GetItems()
|
||||
|
||||
// for _, cmd := range completions {
|
||||
// title := cmd.DisplayValue()
|
||||
// if len(title) > maxWidth-4 {
|
||||
// maxWidth = len(title) + 4
|
||||
// }
|
||||
// }
|
||||
for _, cmd := range completions {
|
||||
title := cmd.DisplayValue()
|
||||
if len(title) > maxWidth-4 {
|
||||
maxWidth = len(title) + 4
|
||||
}
|
||||
}
|
||||
|
||||
// c.list.SetMaxWidth(maxWidth)
|
||||
c.list.SetMaxWidth(maxWidth)
|
||||
|
||||
return baseStyle.Padding(0, 0).
|
||||
Background(t.BackgroundSubtle()).
|
||||
Background(t.BackgroundElement()).
|
||||
Border(lipgloss.ThickBorder()).
|
||||
BorderTop(false).
|
||||
BorderBottom(false).
|
||||
@@ -236,6 +240,17 @@ func (c *completionDialogComponent) IsEmpty() bool {
|
||||
return c.list.IsEmpty()
|
||||
}
|
||||
|
||||
func (c *completionDialogComponent) SetProvider(provider CompletionProvider) {
|
||||
if c.completionProvider.GetId() != provider.GetId() {
|
||||
c.completionProvider = provider
|
||||
items, err := provider.GetChildEntries("")
|
||||
if err != nil {
|
||||
status.Error(err.Error())
|
||||
}
|
||||
c.list.SetItems(items)
|
||||
}
|
||||
}
|
||||
|
||||
func NewCompletionDialogComponent(completionProvider CompletionProvider) CompletionDialog {
|
||||
ti := textarea.New()
|
||||
|
||||
@@ -244,10 +259,10 @@ func NewCompletionDialogComponent(completionProvider CompletionProvider) Complet
|
||||
status.Error(err.Error())
|
||||
}
|
||||
|
||||
li := utilComponents.NewListComponent(
|
||||
li := list.NewListComponent(
|
||||
items,
|
||||
7,
|
||||
"No matching files",
|
||||
"No matches",
|
||||
false,
|
||||
)
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/components/list"
|
||||
"github.com/sst/opencode/internal/components/modal"
|
||||
components "github.com/sst/opencode/internal/components/util"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/state"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
@@ -48,7 +48,7 @@ type sessionDialog struct {
|
||||
height int
|
||||
modal *modal.Modal
|
||||
selectedSessionID string
|
||||
list components.List[sessionItem]
|
||||
list list.List[sessionItem]
|
||||
}
|
||||
|
||||
func (s *sessionDialog) Init() tea.Cmd {
|
||||
@@ -77,7 +77,7 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
var cmd tea.Cmd
|
||||
listModel, cmd := s.list.Update(msg)
|
||||
s.list = listModel.(components.List[sessionItem])
|
||||
s.list = listModel.(list.List[sessionItem])
|
||||
return s, cmd
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ func NewSessionDialog(app *app.App) SessionDialog {
|
||||
sessionItems = append(sessionItems, sessionItem{session: sess})
|
||||
}
|
||||
|
||||
list := components.NewListComponent(
|
||||
list := list.NewListComponent(
|
||||
sessionItems,
|
||||
10, // maxVisibleSessions
|
||||
"No sessions available",
|
||||
|
||||
@@ -2,8 +2,8 @@ package dialog
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
list "github.com/sst/opencode/internal/components/list"
|
||||
"github.com/sst/opencode/internal/components/modal"
|
||||
components "github.com/sst/opencode/internal/components/util"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/status"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
@@ -49,7 +49,7 @@ type themeDialog struct {
|
||||
height int
|
||||
|
||||
modal *modal.Modal
|
||||
list components.List[themeItem]
|
||||
list list.List[themeItem]
|
||||
}
|
||||
|
||||
func (t *themeDialog) Init() tea.Cmd {
|
||||
@@ -84,7 +84,7 @@ func (t *themeDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
var cmd tea.Cmd
|
||||
listModel, cmd := t.list.Update(msg)
|
||||
t.list = listModel.(components.List[themeItem])
|
||||
t.list = listModel.(list.List[themeItem])
|
||||
return t, cmd
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ func NewThemeDialog() ThemeDialog {
|
||||
}
|
||||
}
|
||||
|
||||
list := components.NewListComponent(
|
||||
list := list.NewListComponent(
|
||||
themeItems,
|
||||
10, // maxVisibleThemes
|
||||
"No themes available",
|
||||
|
||||
+2
-8
@@ -1,11 +1,10 @@
|
||||
package utilComponents
|
||||
package list
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
)
|
||||
|
||||
type ListItem interface {
|
||||
@@ -116,18 +115,13 @@ func (c *listComponent[T]) SetSelectedIndex(idx int) {
|
||||
}
|
||||
|
||||
func (c *listComponent[T]) View() string {
|
||||
baseStyle := styles.BaseStyle()
|
||||
|
||||
items := c.items
|
||||
maxWidth := c.maxWidth
|
||||
maxVisibleItems := min(c.maxVisibleItems, len(items))
|
||||
startIdx := 0
|
||||
|
||||
if len(items) <= 0 {
|
||||
return baseStyle.
|
||||
Padding(0, 1).
|
||||
Width(maxWidth).
|
||||
Render(c.fallbackMsg)
|
||||
return c.fallbackMsg
|
||||
}
|
||||
|
||||
if len(items) > maxVisibleItems {
|
||||
@@ -19,6 +19,7 @@ type Container interface {
|
||||
MaxWidth() int
|
||||
Alignment() lipgloss.Position
|
||||
GetPosition() (x, y int)
|
||||
GetContent() ModelWithView
|
||||
}
|
||||
|
||||
type container struct {
|
||||
@@ -177,6 +178,11 @@ func (c *container) GetPosition() (x, y int) {
|
||||
return c.x, c.y
|
||||
}
|
||||
|
||||
// GetContent returns the content of the container
|
||||
func (c *container) GetContent() ModelWithView {
|
||||
return c.content
|
||||
}
|
||||
|
||||
type ContainerOption func(*container)
|
||||
|
||||
func NewContainer(content ModelWithView, options ...ContainerOption) Container {
|
||||
|
||||
@@ -22,6 +22,7 @@ type chatPage struct {
|
||||
messages layout.Container
|
||||
layout layout.FlexLayout
|
||||
completionDialog dialog.CompletionDialog
|
||||
completionManager *completions.CompletionManager
|
||||
showCompletionDialog bool
|
||||
}
|
||||
|
||||
@@ -94,13 +95,20 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
if p.showCompletionDialog {
|
||||
// Get the current text from the editor to determine which provider to use
|
||||
editorModel := p.editor.GetContent().(interface{ GetValue() string })
|
||||
currentInput := editorModel.GetValue()
|
||||
|
||||
provider := p.completionManager.GetProvider(currentInput)
|
||||
p.completionDialog.SetProvider(provider)
|
||||
|
||||
context, contextCmd := p.completionDialog.Update(msg)
|
||||
p.completionDialog = context.(dialog.CompletionDialog)
|
||||
cmds = append(cmds, contextCmd)
|
||||
|
||||
// Doesn't forward event if enter key is pressed
|
||||
// Doesn't forward event if enter key is pressed and there are completions
|
||||
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
||||
if keyMsg.String() == "enter" && !p.completionDialog.IsEmpty() {
|
||||
if keyMsg.String() == "enter" { // && !p.completionDialog.IsEmpty() {
|
||||
return p, tea.Batch(cmds...)
|
||||
}
|
||||
}
|
||||
@@ -149,8 +157,10 @@ func (p *chatPage) View() string {
|
||||
}
|
||||
|
||||
func NewChatPage(app *app.App) layout.ModelWithView {
|
||||
cg := completions.NewFileAndFolderContextGroup()
|
||||
completionDialog := dialog.NewCompletionDialogComponent(cg)
|
||||
completionManager := completions.NewCompletionManager(app)
|
||||
initialProvider := completionManager.GetProvider("")
|
||||
completionDialog := dialog.NewCompletionDialogComponent(initialProvider)
|
||||
|
||||
messagesContainer := layout.NewContainer(
|
||||
chat.NewMessagesComponent(app),
|
||||
)
|
||||
@@ -159,11 +169,13 @@ func NewChatPage(app *app.App) layout.ModelWithView {
|
||||
layout.WithMaxWidth(layout.Current.Container.Width),
|
||||
layout.WithAlignCenter(),
|
||||
)
|
||||
|
||||
return &chatPage{
|
||||
app: app,
|
||||
editor: editorContainer,
|
||||
messages: messagesContainer,
|
||||
completionDialog: completionDialog,
|
||||
app: app,
|
||||
editor: editorContainer,
|
||||
messages: messagesContainer,
|
||||
completionDialog: completionDialog,
|
||||
completionManager: completionManager,
|
||||
layout: layout.NewFlexLayout(
|
||||
layout.WithPanes(messagesContainer, editorContainer),
|
||||
layout.WithDirection(layout.FlexDirectionVertical),
|
||||
|
||||
@@ -2,6 +2,7 @@ package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/cursor"
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
@@ -78,33 +79,52 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
if a.modal != nil {
|
||||
isModalTrigger := false
|
||||
bypassModal := false
|
||||
|
||||
if _, ok := msg.(modal.CloseModalMsg); ok {
|
||||
a.modal = nil
|
||||
return a, nil
|
||||
}
|
||||
|
||||
if msg, ok := msg.(tea.KeyMsg); ok {
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
a.modal = nil
|
||||
return a, nil
|
||||
case "ctrl+c":
|
||||
if _, ok := a.modal.(dialog.QuitDialog); !ok {
|
||||
if _, ok := a.modal.(dialog.QuitDialog); ok {
|
||||
return a, tea.Quit
|
||||
} else {
|
||||
quitDialog := dialog.NewQuitDialog()
|
||||
a.modal = quitDialog
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
|
||||
// don't send commands to the modal
|
||||
for _, cmdDef := range a.app.Commands {
|
||||
if key.Matches(msg, cmdDef.KeyBinding) {
|
||||
isModalTrigger = true
|
||||
bypassModal = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !isModalTrigger {
|
||||
// thanks i hate this
|
||||
switch msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
bypassModal = true
|
||||
case client.EventSessionUpdated:
|
||||
bypassModal = true
|
||||
case client.EventMessageUpdated:
|
||||
bypassModal = true
|
||||
case cursor.BlinkMsg:
|
||||
bypassModal = true
|
||||
case spinner.TickMsg:
|
||||
bypassModal = true
|
||||
}
|
||||
|
||||
if !bypassModal {
|
||||
updatedModal, cmd := a.modal.Update(msg)
|
||||
a.modal = updatedModal.(layout.Modal)
|
||||
return a, cmd
|
||||
@@ -112,7 +132,6 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
switch msg := msg.(type) {
|
||||
|
||||
case commands.ExecuteCommandMsg:
|
||||
switch msg.Name {
|
||||
case "quit":
|
||||
@@ -143,6 +162,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
helpDialog := dialog.NewHelpDialog(helpBindings...)
|
||||
a.modal = helpDialog
|
||||
}
|
||||
slog.Info("Execute command", "cmds", cmds)
|
||||
return a, tea.Batch(cmds...)
|
||||
|
||||
case tea.BackgroundColorMsg:
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 276 KiB |
Reference in New Issue
Block a user