Compare commits

..
22 Commits
Author SHA1 Message Date
Dax Raad df13b155f9 disable autoshare
publish / publish (push) Has been cancelled
2025-06-13 17:30:17 -04:00
Dax Raad eeed5b8718 sync 2025-06-13 17:24:45 -04:00
Dax Raad 148ef90210 sync
publish / publish (push) Has been cancelled
2025-06-13 17:23:22 -04:00
adamdottv 67023bb007 wip: refactoring tui 2025-06-13 15:56:33 -05:00
Dax Raad a316aed4fe sync
publish / publish (push) Has been cancelled
2025-06-13 16:47:15 -04:00
Dax Raad 9f7c0bd599 sync 2025-06-13 16:46:48 -04:00
Dax Raad c7e1068f90 sync 2025-06-13 16:45:58 -04:00
Dax Raad e2052d790b sync 2025-06-13 16:43:53 -04:00
Dax Raad d3b2763c14 commit and push 2025-06-13 16:42:31 -04:00
Dax Raad c6492de7ac sync 2025-06-13 16:37:58 -04:00
Dax Raad d8fa0fb50c sync
publish / publish (push) Has been cancelled
2025-06-13 16:29:57 -04:00
Dax Raad 18ab8faa1d reset readme 2025-06-13 16:26:34 -04:00
Dax Raad f35ce180e2 ci
publish / publish (push) Has been cancelled
2025-06-13 16:23:38 -04:00
Dax Raad 2bee48a9bc homebrew
publish / publish (push) Has been cancelled
2025-06-13 16:17:27 -04:00
adamdottv 10ddd654cf wip: refactoring tui
publish / publish (push) Has been cancelled
2025-06-13 11:27:05 -05:00
adamdottv 61396b93ed wip: refactoring tui 2025-06-13 11:18:46 -05:00
adamdottv 62b9a30a9c wip: refactoring tui 2025-06-13 10:47:51 -05:00
adamdottv 5706c6ad3a wip: refactoring tui 2025-06-13 09:57:54 -05:00
adamdottv e8e03c895a wip: refactoring tui 2025-06-13 09:44:09 -05:00
adamdottv 38667682a7 wip: refactoring tui 2025-06-13 09:19:51 -05:00
adamdottv d7d5fc39fb wip: refactoring tui 2025-06-13 08:30:57 -05:00
adamdottv 0caf25adee wip: refactoring tui 2025-06-13 08:30:56 -05:00
31 changed files with 922 additions and 1612 deletions
+27 -639
View File
@@ -1,658 +1,46 @@
◧ opencode
```
█▀▀█ █▀▀█ █▀▀ █▀▀▄ █▀▀ █▀▀█ █▀▀▄ █▀▀
█░░█ █░░█ █▀▀ █░░█ █░░ █░░█ █░░█ █▀▀
▀▀▀▀ █▀▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀
```
![OpenCode Terminal UI](screenshot.png)
[![OpenCode Terminal UI](screenshot.png)](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
+13 -7
View File
@@ -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
}
@@ -1,24 +0,0 @@
# Maintainer: dax
# Maintainer: adam
pkgname='opencode-bin'
pkgver={{VERSION}}
options=('!debug' '!strip')
pkgrel=1
pkgdesc='The AI coding agent built for the terminal.'
url='https://github.com/sst/opencode'
arch=('aarch64' 'x86_64')
license=('MIT')
provides=('opencode')
conflicts=('opencode')
depends=('fzf' 'ripgrep')
source_aarch64=("${pkgname}_${pkgver}_aarch64.zip::{{ARM64_URL}}")
sha256sums_aarch64=('{{ARM64_SHA}}')
source_x86_64=("${pkgname}_${pkgver}_x86_64.zip::{{X64_URL}}")
sha256sums_x86_64=('{{X64_SHA}}')
package() {
install -Dm755 ./opencode "${pkgdir}/usr/bin/opencode"
}
+103 -23
View File
@@ -118,30 +118,52 @@ if (!snapshot) {
if (!dry)
await $`gh release create v${version} --title "v${version}" --notes ${notes} ./dist/*.zip`
// Calculate SHA values
const arm64Sha =
await $`sha256sum ./dist/opencode-linux-arm64.zip | cut -d' ' -f1`
.text()
.then((x) => x.trim())
const x64Sha =
await $`sha256sum ./dist/opencode-linux-x64.zip | cut -d' ' -f1`
.text()
.then((x) => x.trim())
const macX64Sha =
await $`sha256sum ./dist/opencode-darwin-x64.zip | cut -d' ' -f1`
.text()
.then((x) => x.trim())
const macArm64Sha =
await $`sha256sum ./dist/opencode-darwin-arm64.zip | cut -d' ' -f1`
.text()
.then((x) => x.trim())
// AUR package
const pkgbuildTemplate = await Bun.file("./script/PKGBUILD.template").text()
const pkgbuild = pkgbuildTemplate
.replace("{{VERSION}}", version.split("-")[0])
.replace(
"{{ARM64_URL}}",
`https://github.com/sst/opencode/releases/download/v${version}/opencode-linux-arm64.zip`,
)
.replace(
"{{ARM64_SHA}}",
await $`sha256sum ./dist/opencode-linux-arm64.zip | cut -d' ' -f1`
.text()
.then((x) => x.trim()),
)
.replace(
"{{X64_URL}}",
`https://github.com/sst/opencode/releases/download/v${version}/opencode-linux-x64.zip`,
)
.replace(
"{{X64_SHA}}",
await $`sha256sum ./dist/opencode-linux-x64.zip | cut -d' ' -f1`
.text()
.then((x) => x.trim()),
)
const pkgbuild = [
"# Maintainer: dax",
"# Maintainer: adam",
"",
"pkgname='opencode-bin'",
`pkgver=${version.split("-")[0]}`,
"options=('!debug' '!strip')",
"pkgrel=1",
"pkgdesc='The AI coding agent built for the terminal.'",
"url='https://github.com/sst/opencode'",
"arch=('aarch64' 'x86_64')",
"license=('MIT')",
"provides=('opencode')",
"conflicts=('opencode')",
"depends=('fzf' 'ripgrep')",
"",
`source_aarch64=("\${pkgname}_\${pkgver}_aarch64.zip::https://github.com/sst/opencode/releases/download/v${version}/opencode-linux-arm64.zip")`,
`sha256sums_aarch64=('${arm64Sha}')`,
"",
`source_x86_64=("\${pkgname}_\${pkgver}_x86_64.zip::https://github.com/sst/opencode/releases/download/v${version}/opencode-linux-x64.zip")`,
`sha256sums_x86_64=('${x64Sha}')`,
"",
"package() {",
' install -Dm755 ./opencode "${pkgdir}/usr/bin/opencode"',
"}",
"",
].join("\n")
await $`rm -rf ./dist/aur-opencode-bin`
@@ -151,4 +173,62 @@ if (!snapshot) {
await $`cd ./dist/aur-opencode-bin && git add PKGBUILD .SRCINFO`
await $`cd ./dist/aur-opencode-bin && git commit -m "Update to v${version}"`
if (!dry) await $`cd ./dist/aur-opencode-bin && git push`
// Homebrew formula
const homebrewFormula = [
"# typed: false",
"# frozen_string_literal: true",
"",
"# This file was generated by GoReleaser. DO NOT EDIT.",
"class Opencode < Formula",
` desc "The AI coding agent built for the terminal."`,
` homepage "https://github.com/sst/opencode"`,
` version "${version.split("-")[0]}"`,
"",
" on_macos do",
" if Hardware::CPU.intel?",
` url "https://github.com/sst/opencode/releases/download/v${version}/opencode-darwin-x64.zip"`,
` sha256 "${macX64Sha}"`,
"",
" def install",
' bin.install "opencode"',
" end",
" end",
" if Hardware::CPU.arm?",
` url "https://github.com/sst/opencode/releases/download/v${version}/opencode-darwin-arm64.zip"`,
` sha256 "${macArm64Sha}"`,
"",
" def install",
' bin.install "opencode"',
" end",
" end",
" end",
"",
" on_linux do",
" if Hardware::CPU.intel? and Hardware::CPU.is_64_bit?",
` url "https://github.com/sst/opencode/releases/download/v${version}/opencode-linux-x64.zip"`,
` sha256 "${x64Sha}"`,
" def install",
' bin.install "opencode"',
" end",
" end",
" if Hardware::CPU.arm? and Hardware::CPU.is_64_bit?",
` url "https://github.com/sst/opencode/releases/download/v${version}/opencode-linux-arm64.zip"`,
` sha256 "${arm64Sha}"`,
" def install",
' bin.install "opencode"',
" end",
" end",
" end",
"end",
"",
"",
].join("\n")
await $`rm -rf ./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}"`
if (!dry) await $`cd ./dist/homebrew-tap && git push`
}
+1 -1
View File
@@ -422,7 +422,7 @@ export namespace Server {
)
return c.json({
providers: Object.values(providers),
defaults: mapValues(
default: mapValues(
providers,
(item) => Provider.sort(Object.values(item.models))[0].id,
),
-6
View File
@@ -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,
})
-3
View File
@@ -123,9 +123,6 @@ func main() {
// Cancel subscriptions first
cancelSubs()
// Then shutdown the app
app_.Shutdown()
// Then cancel TUI message handler
tuiCancel()
+4 -30
View File
@@ -9,6 +9,7 @@ import (
"log/slog"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/state"
"github.com/sst/opencode/internal/status"
@@ -26,10 +27,7 @@ type App struct {
Session *client.SessionInfo
Messages []client.MessageInfo
Status status.Service
// UI state
filepickerOpen bool
completionDialogOpen bool
Commands commands.Registry
}
type AppInfo struct {
@@ -86,7 +84,7 @@ func New(ctx context.Context, version string, httpClient *client.ClientWithRespo
return nil, fmt.Errorf("no providers found")
}
appConfigPath := filepath.Join(Info.Path.Config, "tui.toml")
appConfigPath := filepath.Join(Info.Path.Config, "config")
appConfig, err := config.LoadConfig(appConfigPath)
if err != nil {
slog.Info("No TUI config found, using default values", "error", err)
@@ -117,6 +115,7 @@ func New(ctx context.Context, version string, httpClient *client.ClientWithRespo
Session: &client.SessionInfo{},
Messages: []client.MessageInfo{},
Status: status.GetService(),
Commands: commands.NewCommandRegistry(),
}
theme.SetTheme(appConfig.Theme)
@@ -324,28 +323,3 @@ func (a *App) ListProviders(ctx context.Context) ([]client.ProviderInfo, error)
providers := *resp.JSON200
return providers.Providers, nil
}
// IsFilepickerOpen returns whether the filepicker is currently open
func (app *App) IsFilepickerOpen() bool {
return app.filepickerOpen
}
// SetFilepickerOpen sets the state of the filepicker
func (app *App) SetFilepickerOpen(open bool) {
app.filepickerOpen = open
}
// IsCompletionDialogOpen returns whether the completion dialog is currently open
func (app *App) IsCompletionDialogOpen() bool {
return app.completionDialogOpen
}
// SetCompletionDialogOpen sets the state of the completion dialog
func (app *App) SetCompletionDialogOpen(open bool) {
app.completionDialogOpen = open
}
// Shutdown performs a clean shutdown of the application
func (app *App) Shutdown() {
// TODO: cleanup?
}
+71
View File
@@ -0,0 +1,71 @@
package commands
import (
"github.com/charmbracelet/bubbles/v2/key"
)
// Command represents a user-triggerable action.
type Command struct {
// Name is the identifier used for slash commands (e.g., "new").
Name string
// Description is a short explanation of what the command does.
Description string
// KeyBinding is the keyboard shortcut to trigger this command.
KeyBinding key.Binding
}
// Registry holds all the available commands.
type Registry map[string]Command
// ExecuteCommandMsg is a message sent when a command should be executed.
type ExecuteCommandMsg struct {
Name string
}
func NewCommandRegistry() Registry {
return Registry{
"help": {
Name: "help",
Description: "show help",
KeyBinding: key.NewBinding(
key.WithKeys("f1", "super+/", "super+h"),
),
},
"new": {
Name: "new",
Description: "new session",
KeyBinding: key.NewBinding(
key.WithKeys("f2", "super+n"),
),
},
"sessions": {
Name: "sessions",
Description: "switch session",
KeyBinding: key.NewBinding(
key.WithKeys("f3", "super+s"),
),
},
"model": {
Name: "model",
Description: "switch model",
KeyBinding: key.NewBinding(
key.WithKeys("f4", "super+m"),
),
},
"theme": {
Name: "theme",
Description: "switch theme",
KeyBinding: key.NewBinding(
key.WithKeys("f5", "super+t"),
),
},
"quit": {
Name: "quit",
Description: "quit",
KeyBinding: key.NewBinding(
key.WithKeys("f10", "ctrl+c", "super+q"),
),
},
}
}
@@ -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"]
}
+36 -25
View File
@@ -13,6 +13,7 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/image"
"github.com/sst/opencode/internal/layout"
@@ -43,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
@@ -107,16 +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
case dialog.AttachmentAddedMsg:
if len(m.attachments) >= maxAttachments {
status.Error(fmt.Sprintf("cannot add more than %d images", maxAttachments))
return m, cmd
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
}
m.attachments = append(m.attachments, msg.Attachment)
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
@@ -189,7 +187,9 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Handle history navigation with up/down arrow keys
// Only handle history navigation if the filepicker is not open and completion dialog is not open
if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryUp) && !m.app.IsFilepickerOpen() && !m.app.IsCompletionDialogOpen() {
if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryUp) {
// TODO: fix this
// && !m.app.IsFilepickerOpen() && !m.app.IsCompletionDialogOpen() {
// Get the current line number
currentLine := m.textarea.Line()
@@ -209,7 +209,9 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryDown) && !m.app.IsFilepickerOpen() && !m.app.IsCompletionDialogOpen() {
if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryDown) {
// TODO: fix this
// && !m.app.IsFilepickerOpen() && !m.app.IsCompletionDialogOpen() {
// Get the current line number and total lines
currentLine := m.textarea.Line()
value := m.textarea.Value()
@@ -271,6 +273,8 @@ func (m *editorComponent) View() string {
)
textarea = styles.BaseStyle().
Width(m.width).
PaddingTop(1).
PaddingBottom(1).
Background(t.BackgroundElement()).
Border(lipgloss.ThickBorder(), false, true).
BorderForeground(t.BackgroundSubtle()).
@@ -308,7 +312,7 @@ func (m *editorComponent) SetSize(width, height int) tea.Cmd {
m.width = width
m.height = height
m.textarea.SetWidth(width - 5) // account for the prompt and padding right
m.textarea.SetHeight(height - 2) // account for info underneath
m.textarea.SetHeight(height - 4) // account for info underneath
return nil
}
@@ -316,13 +320,6 @@ func (m *editorComponent) GetSize() (int, int) {
return m.width, m.height
}
func (m *editorComponent) BindingKeys() []key.Binding {
bindings := []key.Binding{}
bindings = append(bindings, layout.KeyMapToSlice(editorMaps)...)
bindings = append(bindings, layout.KeyMapToSlice(DeleteKeyMaps)...)
return bindings
}
func (m *editorComponent) openEditor(value string) tea.Cmd {
editor := os.Getenv("EDITOR")
if editor == "" {
@@ -365,7 +362,7 @@ func (m *editorComponent) openEditor(value string) tea.Cmd {
}
func (m *editorComponent) send() tea.Cmd {
value := m.textarea.Value()
value := strings.TrimSpace(m.textarea.Value())
m.textarea.Reset()
attachments := m.attachments
@@ -382,6 +379,16 @@ func (m *editorComponent) send() tea.Cmd {
if value == "" {
return nil
}
// 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})
// }
// }
slog.Info("Send message", "value", value)
return tea.Batch(
util.CmdHandler(SendMsg{
Text: value,
@@ -449,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)
+137 -62
View File
@@ -55,6 +55,10 @@ type blockRenderer struct {
fullWidth bool
paddingTop int
paddingBottom int
paddingLeft int
paddingRight int
marginTop int
marginBottom int
}
type renderingOption func(*blockRenderer)
@@ -77,6 +81,30 @@ func WithBorderColor(color compat.AdaptiveColor) renderingOption {
}
}
func WithMarginTop(padding int) renderingOption {
return func(c *blockRenderer) {
c.marginTop = padding
}
}
func WithMarginBottom(padding int) renderingOption {
return func(c *blockRenderer) {
c.marginBottom = padding
}
}
func WithPaddingLeft(padding int) renderingOption {
return func(c *blockRenderer) {
c.paddingLeft = padding
}
}
func WithPaddingRight(padding int) renderingOption {
return func(c *blockRenderer) {
c.paddingRight = padding
}
}
func WithPaddingTop(padding int) renderingOption {
return func(c *blockRenderer) {
c.paddingTop = padding
@@ -92,17 +120,23 @@ func WithPaddingBottom(padding int) renderingOption {
func renderContentBlock(content string, options ...renderingOption) string {
t := theme.CurrentTheme()
renderer := &blockRenderer{
fullWidth: false,
fullWidth: false,
paddingTop: 1,
paddingBottom: 1,
paddingLeft: 2,
paddingRight: 2,
}
for _, option := range options {
option(renderer)
}
style := styles.BaseStyle().
PaddingTop(1).
PaddingBottom(1).
PaddingLeft(2).
PaddingRight(2).
MarginTop(renderer.marginTop).
MarginBottom(renderer.marginBottom).
PaddingTop(renderer.paddingTop).
PaddingBottom(renderer.paddingBottom).
PaddingLeft(renderer.paddingLeft).
PaddingRight(renderer.paddingRight).
Background(t.BackgroundSubtle()).
Foreground(t.TextMuted()).
BorderStyle(lipgloss.ThickBorder())
@@ -142,12 +176,6 @@ func renderContentBlock(content string, options ...renderingOption) string {
style = style.Width(layout.Current.Container.Width)
}
content = style.Render(content)
if renderer.paddingTop > 0 {
content = strings.Repeat("\n", renderer.paddingTop) + content
}
if renderer.paddingBottom > 0 {
content = content + strings.Repeat("\n", renderer.paddingBottom)
}
content = lipgloss.PlaceHorizontal(
layout.Current.Container.Width,
align,
@@ -165,13 +193,12 @@ func renderText(message client.MessageInfo, text string, author string) string {
t := theme.CurrentTheme()
width := layout.Current.Container.Width
padding := 0
switch layout.Current.Size {
case layout.LayoutSizeSmall:
if layout.Current.Viewport.Width < 80 {
padding = 5
case layout.LayoutSizeNormal:
padding = 10
case layout.LayoutSizeLarge:
} else if layout.Current.Viewport.Width < 120 {
padding = 15
} else {
padding = 20
}
timestamp := time.UnixMilli(int64(message.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM")
@@ -189,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)
@@ -203,7 +230,7 @@ func renderText(message client.MessageInfo, text string, author string) string {
case client.Assistant:
return renderContentBlock(content,
WithAlign(lipgloss.Left),
WithBorderColor(t.Primary()),
WithBorderColor(t.Accent()),
)
}
return ""
@@ -220,16 +247,28 @@ func renderToolInvocation(
return ""
}
padding := 1
outerWidth := layout.Current.Container.Width - 1 // subtract 1 for the border
innerWidth := outerWidth - padding - 4 // -4 for the border and padding
outerWidth := layout.Current.Container.Width
innerWidth := outerWidth - 6
paddingTop := 0
paddingBottom := 0
if showResult {
paddingTop = 1
if result == nil || *result == "" {
paddingBottom = 1
}
}
t := theme.CurrentTheme()
style := styles.Muted().
Width(outerWidth).
PaddingLeft(padding).
Background(t.BackgroundSubtle()).
PaddingTop(paddingTop).
PaddingBottom(paddingBottom).
PaddingLeft(2).
PaddingRight(2).
BorderLeft(true).
BorderForeground(t.BorderSubtle()).
BorderRight(true).
BorderForeground(t.BackgroundSubtle()).
BorderStyle(lipgloss.ThickBorder())
if toolCall.State == "partial-call" {
@@ -260,17 +299,20 @@ 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()).
Foreground(t.Error()).
Render(m.(string))
error = renderContentBlock(error, WithBorderColor(t.Error()), WithFullWidth(), WithPaddingTop(1), WithPaddingBottom(1))
error = renderContentBlock(
error,
WithFullWidth(),
WithBorderColor(t.Error()),
WithMarginBottom(1),
)
}
}
@@ -290,57 +332,83 @@ 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)
diffWidth := min(layout.Current.Viewport.Width, 120)
formattedDiff, _ := diff.FormatDiff(filename, patch, diff.WithTotalWidth(diffWidth))
body = strings.TrimSpace(formattedDiff)
body = lipgloss.Place(
layout.Current.Viewport.Width,
lipgloss.Height(body)+2,
lipgloss.Center,
lipgloss.Center,
body,
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
)
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,
)
}
}
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)
body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
body = toMarkdown(body, innerWidth, t.BackgroundSubtle())
body = renderContentBlock(body, WithFullWidth(), WithPaddingTop(1), WithPaddingBottom(1))
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(), WithPaddingTop(1), WithPaddingBottom(1))
case "opencode_todowrite":
title = fmt.Sprintf("Planning... %s", elapsed)
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)
@@ -349,19 +417,26 @@ 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)
}
}
body = toMarkdown(body, innerWidth, t.BackgroundSubtle())
body = renderContentBlock(body, WithFullWidth(), WithPaddingTop(1), WithPaddingBottom(1))
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
}
default:
toolName := renderToolName(toolCall.ToolName)
title = fmt.Sprintf("%s: %s %s", toolName, toolArgs, elapsed)
body = *result
body = truncateHeight(body, 10)
body = renderContentBlock(body, WithFullWidth(), WithPaddingTop(1), WithPaddingBottom(1))
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
}
if body == "" && error == "" {
body = *result
body = truncateHeight(body, 10)
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
}
content := style.Render(title)
@@ -435,7 +510,7 @@ func renderFile(filename string, content string, options ...fileRenderingOption)
content = fmt.Sprintf("```%s\n%s\n```", extension(renderer.filename), content)
content = toMarkdown(content, width, t.BackgroundSubtle())
return renderContentBlock(content, WithFullWidth(), WithPaddingTop(1), WithPaddingBottom(1))
return renderContentBlock(content, WithFullWidth(), WithMarginBottom(1))
}
func renderToolAction(name string) string {
@@ -214,7 +214,7 @@ func (m *messagesComponent) renderView() {
case client.UnknownError:
clientError := errorValue.(client.UnknownError)
error = clientError.Data.Message
error = renderContentBlock(error, WithBorderColor(t.Error()), WithFullWidth(), WithPaddingTop(1), WithPaddingBottom(1))
error = renderContentBlock(error, WithBorderColor(t.Error()), WithFullWidth(), WithMarginTop(1), WithMarginBottom(1))
blocks = append(blocks, error)
previousBlockType = errorBlock
}
@@ -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.BackgroundElement()))
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,7 +256,8 @@ func (m *messagesComponent) header() string {
header = styles.BaseStyle().
Width(width).
PaddingLeft(2).
Background(t.BackgroundElement()).
PaddingRight(2).
// Background(t.BackgroundElement()).
BorderLeft(true).
BorderRight(true).
BorderBackground(t.Background()).
@@ -384,15 +385,6 @@ func (m *messagesComponent) Reload() tea.Cmd {
}
}
func (m *messagesComponent) BindingKeys() []key.Binding {
return []key.Binding{
m.viewport.KeyMap.PageDown,
m.viewport.KeyMap.PageUp,
m.viewport.KeyMap.HalfPageUp,
m.viewport.KeyMap.HalfPageDown,
}
}
func NewMessagesComponent(app *app.App) layout.ModelWithView {
customSpinner := spinner.Spinner{
Frames: []string{" ", "┃", "┃"},
@@ -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.SimpleListItem
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 {
@@ -78,8 +79,9 @@ type CompletionDialogCloseMsg struct{}
type CompletionDialog interface {
layout.ModelWithView
layout.Bindings
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
listView utilComponents.SimpleList[CompletionItemI]
list list.List[CompletionItemI]
}
type completionDialogKeyMap struct {
@@ -116,17 +118,21 @@ 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(),
)
}
func (c *completionDialogComponent) close() tea.Cmd {
c.listView.SetItems([]CompletionItemI{})
c.list.SetItems([]CompletionItemI{})
c.pseudoSearchTextArea.Reset()
c.pseudoSearchTextArea.Blur()
@@ -138,9 +144,7 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if c.pseudoSearchTextArea.Focused() {
if !key.Matches(msg, completionDialogKeys.Complete) {
var cmd tea.Cmd
c.pseudoSearchTextArea, cmd = c.pseudoSearchTextArea.Update(msg)
cmds = append(cmds, cmd)
@@ -157,26 +161,23 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
status.Error(err.Error())
}
c.listView.SetItems(items)
c.list.SetItems(items)
c.query = query
}
u, cmd := c.listView.Update(msg)
c.listView = u.(utilComponents.SimpleList[CompletionItemI])
u, cmd := c.list.Update(msg)
c.list = u.(list.List[CompletionItemI])
cmds = append(cmds, cmd)
}
switch {
case key.Matches(msg, completionDialogKeys.Complete):
item, i := c.listView.GetSelectedItem()
item, i := c.list.GetSelectedItem()
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 {
@@ -191,7 +192,7 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
status.Error(err.Error())
}
c.listView.SetItems(items)
c.list.SetItems(items)
c.pseudoSearchTextArea.SetValue(msg.String())
return c, c.pseudoSearchTextArea.Focus()
}
@@ -208,8 +209,7 @@ func (c *completionDialogComponent) View() string {
baseStyle := styles.BaseStyle()
maxWidth := 40
completions := c.listView.GetItems()
completions := c.list.GetItems()
for _, cmd := range completions {
title := cmd.DisplayValue()
@@ -218,25 +218,37 @@ func (c *completionDialogComponent) View() string {
}
}
c.listView.SetMaxWidth(maxWidth)
c.list.SetMaxWidth(maxWidth)
return baseStyle.Padding(0, 0).
Border(lipgloss.NormalBorder()).
Background(t.BackgroundElement()).
Border(lipgloss.ThickBorder()).
BorderTop(false).
BorderBottom(false).
BorderRight(false).
BorderLeft(false).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
BorderRight(true).
BorderLeft(true).
BorderForeground(t.BackgroundSubtle()).
Width(c.width).
Render(c.listView.View())
Render(c.list.View())
}
func (c *completionDialogComponent) SetWidth(width int) {
c.width = width
}
func (c *completionDialogComponent) BindingKeys() []key.Binding {
return layout.KeyMapToSlice(completionDialogKeys)
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 {
@@ -247,10 +259,10 @@ func NewCompletionDialogComponent(completionProvider CompletionProvider) Complet
status.Error(err.Error())
}
li := utilComponents.NewSimpleList(
li := list.NewListComponent(
items,
7,
"No file matches found",
"No matches",
false,
)
@@ -258,6 +270,6 @@ func NewCompletionDialogComponent(completionProvider CompletionProvider) Complet
query: "",
completionProvider: completionProvider,
pseudoSearchTextArea: ti,
listView: li,
list: li,
}
}
@@ -1,486 +0,0 @@
package dialog
import (
"fmt"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
"log/slog"
"github.com/atotto/clipboard"
"github.com/charmbracelet/bubbles/v2/key"
"github.com/charmbracelet/bubbles/v2/textinput"
"github.com/charmbracelet/bubbles/v2/viewport"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/image"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
const (
maxAttachmentSize = int64(5 * 1024 * 1024) // 5MB
downArrow = "down"
upArrow = "up"
)
type FilePrickerKeyMap struct {
Enter key.Binding
Down key.Binding
Up key.Binding
Forward key.Binding
Backward key.Binding
OpenFilePicker key.Binding
Esc key.Binding
InsertCWD key.Binding
Paste key.Binding
}
var filePickerKeyMap = FilePrickerKeyMap{
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "select file/enter directory"),
),
Down: key.NewBinding(
key.WithKeys("j", downArrow),
key.WithHelp("↓/j", "down"),
),
Up: key.NewBinding(
key.WithKeys("k", upArrow),
key.WithHelp("↑/k", "up"),
),
Forward: key.NewBinding(
key.WithKeys("l"),
key.WithHelp("l", "enter directory"),
),
Backward: key.NewBinding(
key.WithKeys("h", "backspace"),
key.WithHelp("h/backspace", "go back"),
),
OpenFilePicker: key.NewBinding(
key.WithKeys("ctrl+f"),
key.WithHelp("ctrl+f", "open file picker"),
),
Esc: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "close/exit"),
),
InsertCWD: key.NewBinding(
key.WithKeys("i"),
key.WithHelp("i", "manual path input"),
),
Paste: key.NewBinding(
key.WithKeys("ctrl+v"),
key.WithHelp("ctrl+v", "paste file/directory path"),
),
}
type filepickerComponent struct {
basePath string
width int
height int
cursor int
err error
cursorChain stack
viewport viewport.Model
dirs []os.DirEntry
cwdDetails *DirNode
selectedFile string
cwd textinput.Model
ShowFilePicker bool
app *app.App
}
type DirNode struct {
parent *DirNode
child *DirNode
directory string
}
type stack []int
func (s stack) Push(v int) stack {
return append(s, v)
}
func (s stack) Pop() (stack, int) {
l := len(s)
return s[:l-1], s[l-1]
}
type AttachmentAddedMsg struct {
Attachment app.Attachment
}
func (f *filepickerComponent) Init() tea.Cmd {
return nil
}
func (f *filepickerComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
f.width = 60
f.height = 20
f.viewport.SetWidth(80)
f.viewport.SetHeight(22)
f.cursor = 0
f.getCurrentFileBelowCursor()
case tea.KeyMsg:
if f.cwd.Focused() {
f.cwd, cmd = f.cwd.Update(msg)
}
switch {
case key.Matches(msg, filePickerKeyMap.InsertCWD):
f.cwd.Focus()
return f, cmd
case key.Matches(msg, filePickerKeyMap.Esc):
if f.cwd.Focused() {
f.cwd.Blur()
}
case key.Matches(msg, filePickerKeyMap.Down):
if !f.cwd.Focused() || msg.String() == downArrow {
if f.cursor < len(f.dirs)-1 {
f.cursor++
f.getCurrentFileBelowCursor()
}
}
case key.Matches(msg, filePickerKeyMap.Up):
if !f.cwd.Focused() || msg.String() == upArrow {
if f.cursor > 0 {
f.cursor--
f.getCurrentFileBelowCursor()
}
}
case key.Matches(msg, filePickerKeyMap.Enter):
var path string
var isPathDir bool
if f.cwd.Focused() {
path = f.cwd.Value()
fileInfo, err := os.Stat(path)
if err != nil {
status.Error("Invalid path")
return f, cmd
}
isPathDir = fileInfo.IsDir()
} else {
path = filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name())
isPathDir = f.dirs[f.cursor].IsDir()
}
if isPathDir {
newWorkingDir := DirNode{parent: f.cwdDetails, directory: path}
f.cwdDetails.child = &newWorkingDir
f.cwdDetails = f.cwdDetails.child
f.cursorChain = f.cursorChain.Push(f.cursor)
f.dirs = readDir(f.cwdDetails.directory, false)
f.cursor = 0
f.cwd.SetValue(f.cwdDetails.directory)
f.getCurrentFileBelowCursor()
} else {
f.selectedFile = path
return f.addAttachmentToMessage()
}
case key.Matches(msg, filePickerKeyMap.Esc):
if !f.cwd.Focused() {
f.cursorChain = make(stack, 0)
f.cursor = 0
} else {
f.cwd.Blur()
}
case key.Matches(msg, filePickerKeyMap.Forward):
if !f.cwd.Focused() {
if f.dirs[f.cursor].IsDir() {
path := filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name())
newWorkingDir := DirNode{parent: f.cwdDetails, directory: path}
f.cwdDetails.child = &newWorkingDir
f.cwdDetails = f.cwdDetails.child
f.cursorChain = f.cursorChain.Push(f.cursor)
f.dirs = readDir(f.cwdDetails.directory, false)
f.cursor = 0
f.cwd.SetValue(f.cwdDetails.directory)
f.getCurrentFileBelowCursor()
}
}
case key.Matches(msg, filePickerKeyMap.Backward):
if !f.cwd.Focused() {
if len(f.cursorChain) != 0 && f.cwdDetails.parent != nil {
f.cursorChain, f.cursor = f.cursorChain.Pop()
f.cwdDetails = f.cwdDetails.parent
f.cwdDetails.child = nil
f.dirs = readDir(f.cwdDetails.directory, false)
f.cwd.SetValue(f.cwdDetails.directory)
f.getCurrentFileBelowCursor()
}
}
case key.Matches(msg, filePickerKeyMap.Paste):
if f.cwd.Focused() {
val, err := clipboard.ReadAll()
if err != nil {
slog.Error("failed to read clipboard")
return f, cmd
}
f.cwd.SetValue(f.cwd.Value() + val)
}
case key.Matches(msg, filePickerKeyMap.OpenFilePicker):
f.dirs = readDir(f.cwdDetails.directory, false)
f.cursor = 0
f.getCurrentFileBelowCursor()
}
}
return f, cmd
}
func (f *filepickerComponent) addAttachmentToMessage() (tea.Model, tea.Cmd) {
// modeInfo := GetSelectedModel(config.Get())
// if !modeInfo.SupportsAttachments {
// status.Error(fmt.Sprintf("Model %s doesn't support attachments", modeInfo.Name))
// return f, nil
// }
selectedFilePath := f.selectedFile
if !isExtSupported(selectedFilePath) {
status.Error("Unsupported file")
return f, nil
}
isFileLarge, err := image.ValidateFileSize(selectedFilePath, maxAttachmentSize)
if err != nil {
status.Error("unable to read the image")
return f, nil
}
if isFileLarge {
status.Error("file too large, max 5MB")
return f, nil
}
content, err := os.ReadFile(selectedFilePath)
if err != nil {
status.Error("Unable read selected file")
return f, nil
}
mimeBufferSize := min(512, len(content))
mimeType := http.DetectContentType(content[:mimeBufferSize])
fileName := filepath.Base(selectedFilePath)
attachment := app.Attachment{FilePath: selectedFilePath, FileName: fileName, MimeType: mimeType, Content: content}
f.selectedFile = ""
return f, util.CmdHandler(AttachmentAddedMsg{attachment})
}
func (f *filepickerComponent) View() string {
t := theme.CurrentTheme()
const maxVisibleDirs = 20
const maxWidth = 80
adjustedWidth := maxWidth
for _, file := range f.dirs {
if len(file.Name()) > adjustedWidth-4 { // Account for padding
adjustedWidth = len(file.Name()) + 4
}
}
adjustedWidth = max(30, min(adjustedWidth, f.width-15)) + 1
files := make([]string, 0, maxVisibleDirs)
startIdx := 0
if len(f.dirs) > maxVisibleDirs {
halfVisible := maxVisibleDirs / 2
if f.cursor >= halfVisible && f.cursor < len(f.dirs)-halfVisible {
startIdx = f.cursor - halfVisible
} else if f.cursor >= len(f.dirs)-halfVisible {
startIdx = len(f.dirs) - maxVisibleDirs
}
}
endIdx := min(startIdx+maxVisibleDirs, len(f.dirs))
for i := startIdx; i < endIdx; i++ {
file := f.dirs[i]
itemStyle := styles.BaseStyle().Width(adjustedWidth)
if i == f.cursor {
itemStyle = itemStyle.
Background(t.Primary()).
Foreground(t.Background()).
Bold(true)
}
filename := file.Name()
if len(filename) > adjustedWidth-4 {
filename = filename[:adjustedWidth-7] + "..."
}
if file.IsDir() {
filename = filename + "/"
}
files = append(files, itemStyle.Padding(0, 1).Render(filename))
}
// Pad to always show exactly 21 lines
for len(files) < maxVisibleDirs {
files = append(files, styles.BaseStyle().Width(adjustedWidth).Render(""))
}
currentPath := styles.BaseStyle().
Height(1).
Width(adjustedWidth).
Render(f.cwd.View())
viewportstyle := lipgloss.NewStyle().
Width(f.viewport.Width()).
Background(t.Background()).
Border(lipgloss.RoundedBorder()).
BorderForeground(t.TextMuted()).
BorderBackground(t.Background()).
Padding(2).
Render(f.viewport.View())
var insertExitText string
if f.IsCWDFocused() {
insertExitText = "Press esc to exit typing path"
} else {
insertExitText = "Press i to start typing path"
}
content := lipgloss.JoinVertical(
lipgloss.Left,
currentPath,
styles.BaseStyle().Width(adjustedWidth).Render(""),
styles.BaseStyle().Width(adjustedWidth).Render(lipgloss.JoinVertical(lipgloss.Left, files...)),
styles.BaseStyle().Width(adjustedWidth).Render(""),
styles.BaseStyle().Foreground(t.TextMuted()).Width(adjustedWidth).Render(insertExitText),
)
f.cwd.SetValue(f.cwd.Value())
contentStyle := styles.BaseStyle().Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Width(lipgloss.Width(content) + 4)
return lipgloss.JoinHorizontal(lipgloss.Center, contentStyle.Render(content), viewportstyle)
}
type FilepickerComponent interface {
layout.ModelWithView
ToggleFilepicker(showFilepicker bool)
IsCWDFocused() bool
}
func (f *filepickerComponent) ToggleFilepicker(showFilepicker bool) {
f.ShowFilePicker = showFilepicker
}
func (f *filepickerComponent) IsCWDFocused() bool {
return f.cwd.Focused()
}
func NewFilepickerCmp(app *app.App) FilepickerComponent {
homepath, err := os.UserHomeDir()
if err != nil {
slog.Error("error loading user files")
return nil
}
baseDir := DirNode{parent: nil, directory: homepath}
dirs := readDir(homepath, false)
viewport := viewport.New() // viewport.New(0, 0)
currentDirectory := textinput.New()
currentDirectory.CharLimit = 200
currentDirectory.SetWidth(44)
// currentDirectory.Cursor.Blink = true
currentDirectory.SetValue(baseDir.directory)
return &filepickerComponent{cwdDetails: &baseDir, dirs: dirs, cursorChain: make(stack, 0), viewport: viewport, cwd: currentDirectory, app: app}
}
func (f *filepickerComponent) getCurrentFileBelowCursor() {
if len(f.dirs) == 0 || f.cursor < 0 || f.cursor >= len(f.dirs) {
slog.Error(fmt.Sprintf("Invalid cursor position. Dirs length: %d, Cursor: %d", len(f.dirs), f.cursor))
f.viewport.SetContent("Preview unavailable")
return
}
dir := f.dirs[f.cursor]
filename := dir.Name()
if !dir.IsDir() && isExtSupported(filename) {
fullPath := f.cwdDetails.directory + "/" + dir.Name()
go func() {
imageString, err := image.ImagePreview(f.viewport.Width()-4, fullPath)
if err != nil {
slog.Error(err.Error())
f.viewport.SetContent("Preview unavailable")
return
}
f.viewport.SetContent(imageString)
}()
} else {
f.viewport.SetContent("Preview unavailable")
}
}
func readDir(path string, showHidden bool) []os.DirEntry {
slog.Info(fmt.Sprintf("Reading directory: %s", path))
entriesChan := make(chan []os.DirEntry, 1)
errChan := make(chan error, 1)
go func() {
dirEntries, err := os.ReadDir(path)
if err != nil {
status.Error(err.Error())
errChan <- err
return
}
entriesChan <- dirEntries
}()
select {
case dirEntries := <-entriesChan:
sort.Slice(dirEntries, func(i, j int) bool {
if dirEntries[i].IsDir() == dirEntries[j].IsDir() {
return dirEntries[i].Name() < dirEntries[j].Name()
}
return dirEntries[i].IsDir()
})
if showHidden {
return dirEntries
}
var sanitizedDirEntries []os.DirEntry
for _, dirEntry := range dirEntries {
isHidden, _ := IsHidden(dirEntry.Name())
if !isHidden {
if dirEntry.IsDir() || isExtSupported(dirEntry.Name()) {
sanitizedDirEntries = append(sanitizedDirEntries, dirEntry)
}
}
}
return sanitizedDirEntries
case <-errChan:
status.Error(fmt.Sprintf("Error reading directory %s", path))
return []os.DirEntry{}
case <-time.After(5 * time.Second):
status.Error(fmt.Sprintf("Timeout reading directory %s", path))
return []os.DirEntry{}
}
}
func IsHidden(file string) (bool, error) {
return strings.HasPrefix(file, "."), nil
}
func isExtSupported(path string) bool {
ext := strings.ToLower(filepath.Ext(path))
return (ext == ".jpg" || ext == ".jpeg" || ext == ".webp" || ext == ".png")
}
@@ -50,26 +50,6 @@ func (h *helpDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return h, nil
}
// func removeDuplicateBindings(bindings []key.Binding) []key.Binding {
// seen := make(map[string]struct{})
// result := make([]key.Binding, 0, len(bindings))
//
// // Process bindings in reverse order
// for i := len(bindings) - 1; i >= 0; i-- {
// b := bindings[i]
// k := strings.Join(b.Keys(), " ")
// if _, ok := seen[k]; ok {
// // duplicate, skip
// continue
// }
// seen[k] = struct{}{}
// // Add to the beginning of result to maintain original order
// result = append([]key.Binding{b}, result...)
// }
//
// return result
// }
func (h *helpDialog) View() string {
t := theme.CurrentTheme()
keyStyle := lipgloss.NewStyle().
@@ -173,11 +173,6 @@ func (m *InitDialogCmp) SetSize(width, height int) {
m.height = height
}
// Bindings implements layout.Bindings.
func (m InitDialogCmp) Bindings() []key.Binding {
return m.keys.ShortHelp()
}
// CloseInitDialogMsg is a message that is sent when the init dialog is closed.
type CloseInitDialogMsg struct {
Initialize bool
@@ -31,7 +31,6 @@ type PermissionResponseMsg struct {
// PermissionDialogComponent interface for permission dialog component
type PermissionDialogComponent interface {
layout.ModelWithView
layout.Bindings
// SetPermissions(permission permission.PermissionRequest) tea.Cmd
}
@@ -424,10 +423,6 @@ func (p *permissionDialogComponent) View() string {
return p.render()
}
func (p *permissionDialogComponent) BindingKeys() []key.Binding {
return layout.KeyMapToSlice(permissionsKeys)
}
func (p *permissionDialogComponent) SetSize() tea.Cmd {
// if p.permission.ID == "" {
// return nil
@@ -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.SimpleList[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.SimpleList[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.NewSimpleList(
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.SimpleList[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.SimpleList[themeItem])
t.list = listModel.(list.List[themeItem])
return t, cmd
}
@@ -110,7 +110,7 @@ func NewThemeDialog() ThemeDialog {
}
}
list := components.NewSimpleList(
list := list.NewListComponent(
themeItems,
10, // maxVisibleThemes
"No themes available",
+177 -35
View File
@@ -104,6 +104,40 @@ func WithTotalWidth(width int) SideBySideOption {
}
}
// -------------------------------------------------------------------------
// Unified Configuration
// -------------------------------------------------------------------------
// UnifiedConfig configures the rendering of unified diffs
type UnifiedConfig struct {
Width int
}
// UnifiedOption modifies a UnifiedConfig
type UnifiedOption func(*UnifiedConfig)
// NewUnifiedConfig creates a UnifiedConfig with default values
func NewUnifiedConfig(opts ...UnifiedOption) UnifiedConfig {
config := UnifiedConfig{
Width: 80, // Default width for unified view
}
for _, opt := range opts {
opt(&config)
}
return config
}
// WithWidth sets the width for unified view
func WithWidth(width int) UnifiedOption {
return func(u *UnifiedConfig) {
if width > 0 {
u.Width = width
}
}
}
// -------------------------------------------------------------------------
// Diff Parsing
// -------------------------------------------------------------------------
@@ -642,6 +676,101 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
return sb.String()
}
// renderLinePrefix renders the line number and marker prefix for a diff line
func renderLinePrefix(dl DiffLine, lineNum string, marker string, lineNumberStyle lipgloss.Style, t theme.Theme) string {
// Style the marker based on line type
var styledMarker string
switch dl.Kind {
case LineRemoved:
styledMarker = lipgloss.NewStyle().Background(t.DiffRemovedBg()).Foreground(t.DiffRemoved()).Render(marker)
case LineAdded:
styledMarker = lipgloss.NewStyle().Background(t.DiffAddedBg()).Foreground(t.DiffAdded()).Render(marker)
case LineContext:
styledMarker = lipgloss.NewStyle().Background(t.DiffContextBg()).Foreground(t.TextMuted()).Render(marker)
default:
styledMarker = marker
}
return lineNumberStyle.Render(lineNum + " " + styledMarker)
}
// renderLineContent renders the content of a diff line with syntax and intra-line highlighting
func renderLineContent(fileName string, dl DiffLine, bgStyle lipgloss.Style, highlightColor compat.AdaptiveColor, width int, t theme.Theme) string {
// Apply syntax highlighting
content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
// Apply intra-line highlighting if needed
if len(dl.Segments) > 0 && (dl.Kind == LineRemoved || dl.Kind == LineAdded) {
content = applyHighlighting(content, dl.Segments, dl.Kind, highlightColor)
}
// Add a padding space for added/removed lines
if dl.Kind == LineRemoved || dl.Kind == LineAdded {
content = bgStyle.Render(" ") + content
}
// Create the final line and truncate if needed
return bgStyle.MaxHeight(1).Width(width).Render(
ansi.Truncate(
content,
width,
lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.TextMuted()).Render("..."),
),
)
}
// renderUnifiedLine renders a single line in unified diff format
func renderUnifiedLine(fileName string, dl DiffLine, width int, t theme.Theme) string {
removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(t)
// Determine line style and marker based on line type
var marker string
var bgStyle lipgloss.Style
var lineNum string
var highlightColor compat.AdaptiveColor
switch dl.Kind {
case LineRemoved:
marker = "-"
bgStyle = removedLineStyle
lineNumberStyle = lineNumberStyle.Foreground(t.DiffRemoved()).Background(t.DiffRemovedLineNumberBg())
highlightColor = t.DiffHighlightRemoved()
if dl.OldLineNo > 0 {
lineNum = fmt.Sprintf("%6d ", dl.OldLineNo)
} else {
lineNum = " "
}
case LineAdded:
marker = "+"
bgStyle = addedLineStyle
lineNumberStyle = lineNumberStyle.Foreground(t.DiffAdded()).Background(t.DiffAddedLineNumberBg())
highlightColor = t.DiffHighlightAdded()
if dl.NewLineNo > 0 {
lineNum = fmt.Sprintf(" %7d", dl.NewLineNo)
} else {
lineNum = " "
}
case LineContext:
marker = " "
bgStyle = contextLineStyle
if dl.OldLineNo > 0 && dl.NewLineNo > 0 {
lineNum = fmt.Sprintf("%6d %6d", dl.OldLineNo, dl.NewLineNo)
} else {
lineNum = " "
}
}
// Create the line prefix
prefix := renderLinePrefix(dl, lineNum, marker, lineNumberStyle, t)
// Render the content
prefixWidth := ansi.StringWidth(prefix)
contentWidth := width - prefixWidth
content := renderLineContent(fileName, dl, bgStyle, highlightColor, contentWidth, t)
return prefix + content
}
// renderDiffColumnLine is a helper function that handles the common logic for rendering diff columns
func renderDiffColumnLine(
fileName string,
@@ -661,7 +790,6 @@ func renderDiffColumnLine(
var marker string
var bgStyle lipgloss.Style
var lineNum string
var highlightType LineType
var highlightColor compat.AdaptiveColor
if isLeftColumn {
@@ -671,7 +799,6 @@ func renderDiffColumnLine(
marker = "-"
bgStyle = removedLineStyle
lineNumberStyle = lineNumberStyle.Foreground(t.DiffRemoved()).Background(t.DiffRemovedLineNumberBg())
highlightType = LineRemoved
highlightColor = t.DiffHighlightRemoved()
case LineAdded:
marker = "?"
@@ -692,7 +819,6 @@ func renderDiffColumnLine(
marker = "+"
bgStyle = addedLineStyle
lineNumberStyle = lineNumberStyle.Foreground(t.DiffAdded()).Background(t.DiffAddedLineNumberBg())
highlightType = LineAdded
highlightColor = t.DiffHighlightAdded()
case LineRemoved:
marker = "?"
@@ -708,44 +834,24 @@ func renderDiffColumnLine(
}
}
// Style the marker based on line type
var styledMarker string
switch dl.Kind {
case LineRemoved:
styledMarker = removedLineStyle.Foreground(t.DiffRemoved()).Render(marker)
case LineAdded:
styledMarker = addedLineStyle.Foreground(t.DiffAdded()).Render(marker)
case LineContext:
styledMarker = contextLineStyle.Foreground(t.TextMuted()).Render(marker)
default:
styledMarker = marker
}
// Create the line prefix
prefix := lineNumberStyle.Render(lineNum + " " + styledMarker)
prefix := renderLinePrefix(*dl, lineNum, marker, lineNumberStyle, t)
// Apply syntax highlighting
content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
// Determine if we should render content
shouldRenderContent := (dl.Kind == LineRemoved && isLeftColumn) ||
(dl.Kind == LineAdded && !isLeftColumn) ||
dl.Kind == LineContext
// Apply intra-line highlighting if needed
if (dl.Kind == LineRemoved && isLeftColumn || dl.Kind == LineAdded && !isLeftColumn) && len(dl.Segments) > 0 {
content = applyHighlighting(content, dl.Segments, highlightType, highlightColor)
if !shouldRenderContent {
return bgStyle.Width(colWidth).Render("")
}
// Add a padding space for added/removed lines
if (dl.Kind == LineRemoved && isLeftColumn) || (dl.Kind == LineAdded && !isLeftColumn) {
content = bgStyle.Render(" ") + content
}
// Render the content
prefixWidth := ansi.StringWidth(prefix)
contentWidth := colWidth - prefixWidth
content := renderLineContent(fileName, *dl, bgStyle, highlightColor, contentWidth, t)
// Create the final line and truncate if needed
lineText := prefix + content
return bgStyle.MaxHeight(1).Width(colWidth).Render(
ansi.Truncate(
lineText,
colWidth,
lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.TextMuted()).Render("..."),
),
)
return prefix + content
}
// renderLeftColumn formats the left side of a side-by-side diff
@@ -762,6 +868,27 @@ func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
// Public API
// -------------------------------------------------------------------------
// RenderUnifiedHunk formats a hunk for unified display
func RenderUnifiedHunk(fileName string, h Hunk, opts ...UnifiedOption) string {
// Apply options to create the configuration
config := NewUnifiedConfig(opts...)
// Make a copy of the hunk so we don't modify the original
hunkCopy := Hunk{Lines: make([]DiffLine, len(h.Lines))}
copy(hunkCopy.Lines, h.Lines)
// Highlight changes within lines
HighlightIntralineChanges(&hunkCopy)
var sb strings.Builder
for _, line := range hunkCopy.Lines {
sb.WriteString(renderUnifiedLine(fileName, line, config.Width, theme.CurrentTheme()))
sb.WriteString("\n")
}
return sb.String()
}
// RenderSideBySideHunk formats a hunk for side-by-side display
func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) string {
// Apply options to create the configuration
@@ -792,6 +919,21 @@ func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) str
return sb.String()
}
// FormatUnifiedDiff creates a unified formatted view of a diff
func FormatUnifiedDiff(filename string, diffText string, opts ...UnifiedOption) (string, error) {
diffResult, err := ParseUnifiedDiff(diffText)
if err != nil {
return "", err
}
var sb strings.Builder
for _, h := range diffResult.Hunks {
sb.WriteString(RenderUnifiedHunk(filename, h, opts...))
}
return sb.String(), nil
}
// FormatDiff creates a side-by-side formatted view of a diff
func FormatDiff(filename string, diffText string, opts ...SideBySideOption) (string, error) {
// t := theme.CurrentTheme()
@@ -1,29 +1,27 @@
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"
"github.com/sst/opencode/internal/theme"
)
type SimpleListItem interface {
type ListItem interface {
Render(selected bool, width int) string
}
type SimpleList[T SimpleListItem] interface {
type List[T ListItem] interface {
layout.ModelWithView
layout.Bindings
SetMaxWidth(maxWidth int)
GetSelectedItem() (item T, idx int)
SetItems(items []T)
GetItems() []T
SetSelectedIndex(idx int)
IsEmpty() bool
}
type simpleListComponent[T SimpleListItem] struct {
type listComponent[T ListItem] struct {
fallbackMsg string
items []T
selectedIdx int
@@ -34,14 +32,14 @@ type simpleListComponent[T SimpleListItem] struct {
height int
}
type simpleListKeyMap struct {
type listKeyMap struct {
Up key.Binding
Down key.Binding
UpAlpha key.Binding
DownAlpha key.Binding
}
var simpleListKeys = simpleListKeyMap{
var simpleListKeys = listKeyMap{
Up: key.NewBinding(
key.WithKeys("up"),
key.WithHelp("↑", "previous list item"),
@@ -60,11 +58,11 @@ var simpleListKeys = simpleListKeyMap{
),
}
func (c *simpleListComponent[T]) Init() tea.Cmd {
func (c *listComponent[T]) Init() tea.Cmd {
return nil
}
func (c *simpleListComponent[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (c *listComponent[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
@@ -84,11 +82,7 @@ func (c *simpleListComponent[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return c, nil
}
func (c *simpleListComponent[T]) BindingKeys() []key.Binding {
return layout.KeyMapToSlice(simpleListKeys)
}
func (c *simpleListComponent[T]) GetSelectedItem() (T, int) {
func (c *listComponent[T]) GetSelectedItem() (T, int) {
if len(c.items) > 0 {
return c.items[c.selectedIdx], c.selectedIdx
}
@@ -97,40 +91,37 @@ func (c *simpleListComponent[T]) GetSelectedItem() (T, int) {
return zero, -1
}
func (c *simpleListComponent[T]) SetItems(items []T) {
func (c *listComponent[T]) SetItems(items []T) {
c.selectedIdx = 0
c.items = items
}
func (c *simpleListComponent[T]) GetItems() []T {
func (c *listComponent[T]) GetItems() []T {
return c.items
}
func (c *simpleListComponent[T]) SetMaxWidth(width int) {
func (c *listComponent[T]) IsEmpty() bool {
return len(c.items) == 0
}
func (c *listComponent[T]) SetMaxWidth(width int) {
c.maxWidth = width
}
func (c *simpleListComponent[T]) SetSelectedIndex(idx int) {
func (c *listComponent[T]) SetSelectedIndex(idx int) {
if idx >= 0 && idx < len(c.items) {
c.selectedIdx = idx
}
}
func (c *simpleListComponent[T]) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
func (c *listComponent[T]) View() string {
items := c.items
maxWidth := c.maxWidth
maxVisibleItems := min(c.maxVisibleItems, len(items))
startIdx := 0
if len(items) <= 0 {
return baseStyle.
Background(t.Background()).
Padding(0, 1).
Width(maxWidth).
Render(c.fallbackMsg)
return c.fallbackMsg
}
if len(items) > maxVisibleItems {
@@ -155,8 +146,8 @@ func (c *simpleListComponent[T]) View() string {
return lipgloss.JoinVertical(lipgloss.Left, listItems...)
}
func NewSimpleList[T SimpleListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) SimpleList[T] {
return &simpleListComponent[T]{
func NewListComponent[T ListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) List[T] {
return &listComponent[T]{
fallbackMsg: fallbackMsg,
items: items,
maxVisibleItems: maxVisibleItems,
+3 -3
View File
@@ -10,9 +10,9 @@ import (
)
type Config struct {
Theme string `toml:"Theme"`
Provider string `toml:"Provider"`
Model string `toml:"Model"`
Theme string `toml:"theme"`
Provider string `toml:"provider"`
Model string `toml:"model"`
}
// NewConfig creates a new Config instance with default values.
+15 -10
View File
@@ -1,7 +1,6 @@
package layout
import (
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/theme"
@@ -15,16 +14,19 @@ type ModelWithView interface {
type Container interface {
ModelWithView
Sizeable
Bindings
Focus()
Blur()
MaxWidth() int
Alignment() lipgloss.Position
GetPosition() (x, y int)
GetContent() ModelWithView
}
type container struct {
width int
height int
x int
y int
content ModelWithView
@@ -142,7 +144,7 @@ func (c *container) SetSize(width, height int) tea.Cmd {
}
func (c *container) GetSize() (int, int) {
return c.width, c.height
return min(c.width, c.maxWidth), c.height
}
func (c *container) MaxWidth() int {
@@ -153,13 +155,6 @@ func (c *container) Alignment() lipgloss.Position {
return c.align
}
func (c *container) BindingKeys() []key.Binding {
if b, ok := c.content.(Bindings); ok {
return b.BindingKeys()
}
return []key.Binding{}
}
// Focus sets the container as focused
func (c *container) Focus() {
c.focused = true
@@ -178,6 +173,16 @@ func (c *container) Blur() {
}
}
// GetPosition returns the x, y coordinates of the container
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 {
+40 -14
View File
@@ -1,7 +1,6 @@
package layout
import (
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
)
@@ -27,7 +26,6 @@ func FlexPaneSizeFixed(size int) FlexPaneSize {
type FlexLayout interface {
ModelWithView
Sizeable
Bindings
SetPanes(panes []Container) tea.Cmd
SetPaneSizes(sizes []FlexPaneSize) tea.Cmd
SetDirection(direction FlexDirection) tea.Cmd
@@ -161,11 +159,51 @@ func (f *flexLayout) SetSize(width, height int) tea.Cmd {
f.height = height
var cmds []tea.Cmd
currentX, currentY := 0, 0
for i, pane := range f.panes {
if pane != nil {
paneWidth, paneHeight := f.calculatePaneSize(i)
// Calculate actual position based on alignment
actualX, actualY := currentX, currentY
if f.direction == FlexDirectionHorizontal {
// In horizontal layout, vertical alignment affects Y position
// (lipgloss.Center is used for vertical alignment in JoinHorizontal)
actualY = (f.height - paneHeight) / 2
} else {
// In vertical layout, horizontal alignment affects X position
contentWidth := paneWidth
if pane.MaxWidth() > 0 && contentWidth > pane.MaxWidth() {
contentWidth = pane.MaxWidth()
}
switch pane.Alignment() {
case lipgloss.Center:
actualX = (f.width - contentWidth) / 2
case lipgloss.Right:
actualX = f.width - contentWidth
case lipgloss.Left:
actualX = 0
}
}
// Set position if the pane is a *container
if c, ok := pane.(*container); ok {
c.x = actualX
c.y = actualY
}
cmd := pane.SetSize(paneWidth, paneHeight)
cmds = append(cmds, cmd)
// Update position for next pane
if f.direction == FlexDirectionHorizontal {
currentX += paneWidth
} else {
currentY += paneHeight
}
}
}
return tea.Batch(cmds...)
@@ -199,18 +237,6 @@ func (f *flexLayout) SetDirection(direction FlexDirection) tea.Cmd {
return nil
}
func (f *flexLayout) BindingKeys() []key.Binding {
keys := []key.Binding{}
for _, pane := range f.panes {
if pane != nil {
if b, ok := pane.(Bindings); ok {
keys = append(keys, b.BindingKeys()...)
}
}
}
return keys
}
func NewFlexLayout(options ...FlexLayoutOption) FlexLayout {
layout := &flexLayout{
direction: FlexDirectionHorizontal,
-12
View File
@@ -11,7 +11,6 @@ var Current *LayoutInfo
func init() {
Current = &LayoutInfo{
Size: LayoutSizeNormal,
Viewport: Dimensions{Width: 80, Height: 25},
Container: Dimensions{Width: 80, Height: 25},
}
@@ -19,19 +18,12 @@ func init() {
type LayoutSize string
const (
LayoutSizeSmall LayoutSize = "small"
LayoutSizeNormal LayoutSize = "normal"
LayoutSizeLarge LayoutSize = "large"
)
type Dimensions struct {
Width int
Height int
}
type LayoutInfo struct {
Size LayoutSize
Viewport Dimensions
Container Dimensions
}
@@ -53,10 +45,6 @@ type Sizeable interface {
GetSize() (int, int)
}
type Bindings interface {
BindingKeys() []key.Binding
}
func KeyMapToSlice(t any) (bindings []key.Binding) {
typ := reflect.TypeOf(t)
if typ.Kind() != reflect.Struct {
+27 -23
View File
@@ -22,6 +22,7 @@ type chatPage struct {
messages layout.Container
layout layout.FlexLayout
completionDialog dialog.CompletionDialog
completionManager *completions.CompletionManager
showCompletionDialog bool
}
@@ -61,13 +62,13 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmd := p.layout.SetSize(msg.Width, msg.Height)
cmds = append(cmds, cmd)
case chat.SendMsg:
p.showCompletionDialog = false
cmd := p.sendMessage(msg.Text, msg.Attachments)
if cmd != nil {
return p, cmd
}
case dialog.CompletionDialogCloseMsg:
p.showCompletionDialog = false
p.app.SetCompletionDialogOpen(false)
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
@@ -80,7 +81,6 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, keyMap.ShowCompletionDialog):
p.showCompletionDialog = true
p.app.SetCompletionDialogOpen(true)
// Continue sending keys to layout->chat
case key.Matches(msg, keyMap.Cancel):
if p.app.Session.Id != "" {
@@ -93,14 +93,22 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return p, util.CmdHandler(chat.ToggleToolMessagesMsg{})
}
}
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" {
if keyMsg.String() == "enter" { // && !p.completionDialog.IsEmpty() {
return p, tea.Batch(cmds...)
}
}
@@ -130,17 +138,16 @@ func (p *chatPage) GetSize() (int, int) {
func (p *chatPage) View() string {
layoutView := p.layout.View()
// TODO: Fix this with our new layout
if p.showCompletionDialog {
_, layoutHeight := p.layout.GetSize()
editorWidth, editorHeight := p.editor.GetSize()
editorWidth, _ := p.editor.GetSize()
editorX, editorY := p.editor.GetPosition()
p.completionDialog.SetWidth(editorWidth)
overlay := p.completionDialog.View()
layoutView = layout.PlaceOverlay(
0,
layoutHeight-editorHeight-lipgloss.Height(overlay),
editorX,
editorY-lipgloss.Height(overlay)+2,
overlay,
layoutView,
)
@@ -149,16 +156,11 @@ func (p *chatPage) View() string {
return layoutView
}
func (p *chatPage) BindingKeys() []key.Binding {
bindings := layout.KeyMapToSlice(keyMap)
bindings = append(bindings, p.messages.BindingKeys()...)
bindings = append(bindings, p.editor.BindingKeys()...)
return bindings
}
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),
)
@@ -167,17 +169,19 @@ 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),
layout.WithPaneSizes(
layout.FlexPaneSizeGrow,
layout.FlexPaneSizeFixed(5),
layout.FlexPaneSizeFixed(6),
),
),
}
+72 -94
View File
@@ -2,6 +2,7 @@ package tui
import (
"context"
"log/slog"
"github.com/charmbracelet/bubbles/v2/cursor"
"github.com/charmbracelet/bubbles/v2/key"
@@ -10,6 +11,7 @@ import (
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/core"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/components/modal"
@@ -22,42 +24,6 @@ import (
"github.com/sst/opencode/pkg/client"
)
type keyMap struct {
Help key.Binding
NewSession key.Binding
SwitchSession key.Binding
SwitchModel key.Binding
SwitchTheme key.Binding
Quit key.Binding
}
var keys = keyMap{
Help: key.NewBinding(
key.WithKeys("f1", "super+/", "super+h"),
key.WithHelp("/help", "show help"),
),
NewSession: key.NewBinding(
key.WithKeys("f2", "super+n"),
key.WithHelp("/new", "new session"),
),
SwitchSession: key.NewBinding(
key.WithKeys("f3", "super+s"),
key.WithHelp("/sessions", "switch session"),
),
SwitchModel: key.NewBinding(
key.WithKeys("f4", "super+m"),
key.WithHelp("/model", "switch model"),
),
SwitchTheme: key.NewBinding(
key.WithKeys("f5", "super+t"),
key.WithHelp("/theme", "switch theme"),
),
Quit: key.NewBinding(
key.WithKeys("f10", "ctrl+c", "super+q"),
key.WithHelp("/quit", "quit"),
),
}
type appModel struct {
width, height int
currentPage page.PageID
@@ -113,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
}
}
isModalTrigger = key.Matches(msg, keys.NewSession) ||
key.Matches(msg, keys.SwitchSession) ||
key.Matches(msg, keys.SwitchModel) ||
key.Matches(msg, keys.SwitchTheme) ||
key.Matches(msg, keys.Help) ||
key.Matches(msg, keys.Quit)
// don't send commands to the modal
for _, cmdDef := range a.app.Commands {
if key.Matches(msg, cmdDef.KeyBinding) {
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
@@ -147,6 +132,38 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
switch msg := msg.(type) {
case commands.ExecuteCommandMsg:
switch msg.Name {
case "quit":
quitDialog := dialog.NewQuitDialog()
a.modal = quitDialog
case "new":
a.app.Session = &client.SessionInfo{}
a.app.Messages = []client.MessageInfo{}
cmds = append(cmds, util.CmdHandler(state.SessionClearedMsg{}))
case "sessions":
sessionDialog := dialog.NewSessionDialog(a.app)
a.modal = sessionDialog
case "model":
modelDialog := dialog.NewModelDialog(a.app)
a.modal = modelDialog
case "theme":
themeDialog := dialog.NewThemeDialog()
a.modal = themeDialog
case "help":
var helpBindings []key.Binding
for _, cmd := range a.app.Commands {
// Create a new binding for help display
helpBindings = append(helpBindings, key.NewBinding(
key.WithKeys(cmd.KeyBinding.Keys()...),
key.WithHelp("/"+cmd.Name, cmd.Description),
))
}
helpDialog := dialog.NewHelpDialog(helpBindings...)
a.modal = helpDialog
}
slog.Info("Execute command", "cmds", cmds)
return a, tea.Batch(cmds...)
case tea.BackgroundColorMsg:
styles.Terminal = &styles.TerminalInfo{
@@ -181,18 +198,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
msg.Height -= 2 // Make space for the status bar
a.width, a.height = msg.Width, msg.Height
size := layout.LayoutSizeNormal
if a.width < 40 {
size = layout.LayoutSizeSmall
} else if a.width < 80 {
size = layout.LayoutSizeNormal
} else {
size = layout.LayoutSizeLarge
}
// TODO: move away from global state
layout.Current = &layout.LayoutInfo{
Size: size,
Viewport: layout.Dimensions{
Width: a.width,
Height: a.height,
@@ -278,6 +285,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyMsg:
switch msg.String() {
// give the editor a chance to clear input
case "ctrl+c":
updated, cmd := a.pages[a.currentPage].Update(msg)
a.pages[a.currentPage] = updated.(layout.ModelWithView)
@@ -286,45 +294,15 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
switch {
case key.Matches(msg, keys.Help):
helpDialog := dialog.NewHelpDialog(
keys.Help,
keys.NewSession,
keys.SwitchSession,
keys.SwitchModel,
keys.SwitchTheme,
keys.Quit,
)
a.modal = helpDialog
return a, nil
case key.Matches(msg, keys.NewSession):
a.app.Session = &client.SessionInfo{}
a.app.Messages = []client.MessageInfo{}
return a, tea.Batch(
util.CmdHandler(state.SessionClearedMsg{}),
)
case key.Matches(msg, keys.SwitchModel):
modelDialog := dialog.NewModelDialog(a.app)
a.modal = modelDialog
return a, nil
case key.Matches(msg, keys.SwitchSession):
sessionDialog := dialog.NewSessionDialog(a.app)
a.modal = sessionDialog
return a, nil
case key.Matches(msg, keys.SwitchTheme):
themeDialog := dialog.NewThemeDialog()
a.modal = themeDialog
return a, nil
case key.Matches(msg, keys.Quit):
quitDialog := dialog.NewQuitDialog()
a.modal = quitDialog
return a, nil
// First, check for modal triggers from the command registry
if a.modal == nil {
for _, cmdDef := range a.app.Commands {
if key.Matches(msg, cmdDef.KeyBinding) {
// If a key matches, send an ExecuteCommandMsg to self.
// This unifies keybinding and slash command handling.
return a, util.CmdHandler(commands.ExecuteCommandMsg{Name: cmdDef.Name})
}
}
}
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 276 KiB