Compare commits

..
40 Commits
Author SHA1 Message Date
Dax Raad ffc231bd8b docs: contributing
publish / publish (push) Has been cancelled
2025-06-14 12:45:26 -04:00
Dax Raad 3cf4ef56fb sync 2025-06-14 12:32:41 -04:00
Dax Raad c738e26438 docs: mcp 2025-06-14 12:25:26 -04:00
Dax Raad 9c6aa82ac1 docs: config schema 2025-06-14 12:22:07 -04:00
Dax Raad ef74d97491 ci: update publish script 2025-06-14 12:13:59 -04:00
Dax Raad af892e5432 docs: readme 2025-06-14 12:13:46 -04:00
Dax Raad d7aca6230d naming fixes
publish / publish (push) Has been cancelled
2025-06-14 01:54:28 -04:00
Dax RaadandOpenCode 0f9c2c5c27 Add flag system and auto-share functionality
publish / publish (push) Has been cancelled
- Add Flag module for environment variable configuration
- Implement OPENCODE_AUTO_SHARE flag to automatically share new sessions
- Update session creation to conditionally auto-share based on flag

🤖 Generated with [OpenCode](https://opencode.ai)

Co-Authored-By: OpenCode <noreply@opencode.ai>
2025-06-14 01:51:04 -04:00
Dax RaadandOpenCode 6a261dedb4 Improve logging and simplify fzf implementation
- Refactor fzf search to use Bun's $ syntax for cleaner command execution
- Add request/response duration logging to server middleware
- Set default service name for logging to improve log clarity

🤖 Generated with [OpenCode](https://opencode.ai)

Co-Authored-By: OpenCode <noreply@opencode.ai>
2025-06-14 01:51:04 -04:00
Alireza Bahrami ec928d88b5 fix(install): check if the path export command already exists (#28) 2025-06-13 23:28:33 -04:00
Dax RaadandOpenCode 59a5f120c0 Clean up workflows and enhance file discovery tools to include dot files
publish / publish (push) Has been cancelled
🤖 Generated with [OpenCode](https://opencode.ai)

Co-Authored-By: OpenCode <noreply@opencode.ai>
2025-06-13 23:24:46 -04:00
Dax Raad ce07f80b19 sync 2025-06-13 17:42:56 -04:00
Dax Raad 168fd9b2e3 screenshot 2025-06-13 17:42:14 -04:00
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
Dax Raad 37febc6873 do not strip aur package
publish / publish (push) Has been cancelled
2025-06-13 08:27:17 -04:00
adamdottv 4169f0c412 wip: refactoring tui
publish / publish (push) Has been cancelled
2025-06-13 07:01:26 -05:00
adamdottv b7f06bbc1f wip: refactoring tui 2025-06-13 06:56:12 -05:00
adamdottv 1b8cfe9e99 wip: refactoring tui 2025-06-13 06:49:59 -05:00
adamdottv 97837d2d23 wip: refactoring tui 2025-06-13 06:23:12 -05:00
61 changed files with 1703 additions and 3094 deletions
-37
View File
@@ -1,37 +0,0 @@
name: build
on:
workflow_dispatch:
push:
branches:
- dev
concurrency: ${{ github.workflow }}-${{ github.ref }}
permissions:
contents: write
packages: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- run: git fetch --force --tags
- uses: actions/setup-go@v5
with:
go-version: ">=1.23.2"
cache: true
cache-dependency-path: go.sum
- run: go mod download
- uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: latest
args: build --snapshot --clean
+1 -1
View File
@@ -4,7 +4,7 @@ on:
workflow_dispatch: workflow_dispatch:
push: push:
branches: branches:
- dontlook - dev
tags: tags:
- "*" - "*"
+57 -623
View File
@@ -1,658 +1,92 @@
opencode [![OpenCode Terminal UI](screenshot.png)](https://github.com/sst/opencode)
![OpenCode Terminal UI](screenshot.png) AI coding agent, built for the terminal.
> **⚠️ 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 ⚠️ **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 📚
A powerful terminal-based AI assistant for developers, providing intelligent coding assistance directly in your terminal. ### Installation
## 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
## Installation
### Using the Install Script
```bash ```bash
# Install the latest version # YOLO
curl -fsSL https://opencode.ai/install | bash curl -fsSL https://opencode.ai/install | bash
# Install a specific version # Package managers
curl -fsSL https://opencode.ai/install | VERSION=0.1.0 bash npm i -g opencode-ai@latest # or bun/pnpm/yarn
brew install sst/tap/opencode # macOS
paru -S opencode-bin # Arch Linux
``` ```
### Using Homebrew (macOS and Linux) > **Note:** Remove previous versions < 0.1.x first if installed
### Providers
The recommended approach is to sign up for claude pro or max and do `opencode auth login` and select Anthropic. It is the most cost effective way to use this tool.
Additionally opencode is powered by the provider list at [models.dev](https://models.dev) so you can use `opencode auth login` to configure api keys for any provider you'd like to use. This is stored in `~/.local/share/opencode/auth.json`
```bash ```bash
brew install sst/tap/opencode $ opencode auth login
┌ Add credential
◆ Select provider
│ ● Anthropic (recommended)
│ ○ OpenAI
│ ○ Google
│ ○ Amazon Bedrock
│ ○ Azure
│ ○ DeepSeek
│ ○ Groq
│ ...
``` ```
### Using AUR (Arch Linux) The models.dev dataset is also used to detect common environment variables like `OPENAI_API_KEY` to autoload that provider.
```bash If there are additional providers you want to use you can submit a PR to the [models.dev repo](https://github.com/sst/models.dev). If configuring just for yourself check out the Config section below
# Using yay
yay -S opencode-bin
# Using paru ### Project Config
paru -S opencode-bin
```
### Using Go Project configuration is optional. You can place an `opencode.json` file in the root of your repo and it will be loaded.
```bash ```json title="opencode.json"
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": { "$schema": "http://opencode.ai/config.json"
"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 #### MCP
OpenCode supports a variety of AI models from different providers: ```json title="opencode.json"
### 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": { "$schema": "http://opencode.ai/config.json",
"primary": { "mcp": {
"model": "bedrock.claude-3.7-sonnet", "localmcp": {
"maxTokens": 5000, "type": "local",
"reasoningEffort": "" "command": ["bun", "x", "my-mcp-command"],
}, "environment": {
"task": { "MY_ENV_VAR": "my_env_var_value"
"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": { "remotemcp": {
"disabled": false, "type": "remote",
"command": "typescript-language-server", "url": "https://my-mcp-server.com"
"args": ["--stdio"]
} }
} }
} }
``` ```
### LSP Integration with AI ### Contributing
The AI assistant can access LSP features through the `diagnostics` tool, allowing it to: To run opencode locally you need
- Check for errors in your code - bun
- Suggest fixes based on diagnostics - golang 1.24.x
While the LSP client implementation supports the full LSP protocol (including completions, hover, definition, etc.), currently only diagnostics are exposed to the AI assistant. To run
## 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
``` ```
$ bun install
## Acknowledgments $ cd packages/opencode
$ bun run src/index.ts
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.
+1
View File
@@ -50,6 +50,7 @@
"@types/turndown": "5.0.5", "@types/turndown": "5.0.5",
"@types/yargs": "17.0.33", "@types/yargs": "17.0.33",
"typescript": "catalog:", "typescript": "catalog:",
"zod-to-json-schema": "3.24.5",
}, },
}, },
"packages/web": { "packages/web": {
+17 -8
View File
@@ -12,23 +12,28 @@ requested_version=${VERSION:-}
os=$(uname -s | tr '[:upper:]' '[:lower:]') os=$(uname -s | tr '[:upper:]' '[:lower:]')
if [[ "$os" == "darwin" ]]; then if [[ "$os" == "darwin" ]]; then
os="mac" os="darwin"
fi fi
arch=$(uname -m) arch=$(uname -m)
if [[ "$arch" == "aarch64" ]]; then if [[ "$arch" == "aarch64" ]]; then
arch="arm64" arch="arm64"
elif [[ "$arch" == "x86_64" ]]; then
arch="x64"
fi fi
filename="$APP-$os-$arch.tar.gz" filename="$APP-$os-$arch.zip"
case "$filename" in case "$filename" in
*"-linux-"*) *"-linux-"*)
[[ "$arch" == "x86_64" || "$arch" == "arm64" || "$arch" == "i386" ]] || exit 1 [[ "$arch" == "x64" || "$arch" == "arm64" ]] || exit 1
;; ;;
*"-mac-"*) *"-darwin-"*)
[[ "$arch" == "x86_64" || "$arch" == "arm64" ]] || exit 1 [[ "$arch" == "x64" || "$arch" == "arm64" ]] || exit 1
;;
*"-windows-"*)
[[ "$arch" == "x64" ]] || exit 1
;; ;;
*) *)
echo "${RED}Unsupported OS/Arch: $os/$arch${NC}" echo "${RED}Unsupported OS/Arch: $os/$arch${NC}"
@@ -88,8 +93,9 @@ check_version() {
download_and_install() { download_and_install() {
print_message info "Downloading ${ORANGE}opencode ${GREEN}version: ${YELLOW}$specific_version ${GREEN}..." print_message info "Downloading ${ORANGE}opencode ${GREEN}version: ${YELLOW}$specific_version ${GREEN}..."
mkdir -p opencodetmp && cd opencodetmp mkdir -p opencodetmp && cd opencodetmp
curl -# -L $url | tar xz curl -# -L -o "$filename" "$url"
mv opencode $INSTALL_DIR unzip -q "$filename"
mv opencode "$INSTALL_DIR"
cd .. && rm -rf opencodetmp cd .. && rm -rf opencodetmp
} }
@@ -101,7 +107,9 @@ add_to_path() {
local config_file=$1 local config_file=$1
local command=$2 local command=$2
if [[ -w $config_file ]]; then if grep -Fxq "$command" "$config_file"; then
print_message info "Command already exists in $config_file, skipping write."
elif [[ -w $config_file ]]; then
echo -e "\n# opencode" >> "$config_file" echo -e "\n# opencode" >> "$config_file"
echo "$command" >> "$config_file" echo "$command" >> "$config_file"
print_message info "Successfully added ${ORANGE}opencode ${GREEN}to \$PATH in $config_file" print_message info "Successfully added ${ORANGE}opencode ${GREEN}to \$PATH in $config_file"
@@ -167,6 +175,7 @@ if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH" add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
;; ;;
*) *)
export PATH=$INSTALL_DIR:$PATH
print_message warning "Manually add the directory to $config_file (or similar):" print_message warning "Manually add the directory to $config_file (or similar):"
print_message info " export PATH=$INSTALL_DIR:\$PATH" print_message info " export PATH=$INSTALL_DIR:\$PATH"
;; ;;
+152
View File
@@ -0,0 +1,152 @@
{
"type": "object",
"properties": {
"$schema": {
"type": "string"
},
"provider": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"env": {
"type": "array",
"items": {
"type": "string"
}
},
"id": {
"type": "string"
},
"models": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"attachment": {
"type": "boolean"
},
"reasoning": {
"type": "boolean"
},
"temperature": {
"type": "boolean"
},
"cost": {
"type": "object",
"properties": {
"input": {
"type": "number"
},
"output": {
"type": "number"
},
"inputCached": {
"type": "number"
},
"outputCached": {
"type": "number"
}
},
"required": [
"input",
"output",
"inputCached",
"outputCached"
],
"additionalProperties": false
},
"limit": {
"type": "object",
"properties": {
"context": {
"type": "number"
},
"output": {
"type": "number"
}
},
"required": [
"context",
"output"
],
"additionalProperties": false
},
"id": {
"type": "string"
}
},
"additionalProperties": false
}
},
"options": {
"type": "object",
"additionalProperties": {}
}
},
"required": [
"models"
],
"additionalProperties": false
}
},
"mcp": {
"type": "object",
"additionalProperties": {
"anyOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "local"
},
"command": {
"type": "array",
"items": {
"type": "string"
}
},
"environment": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"required": [
"type",
"command"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "remote"
},
"url": {
"type": "string"
}
},
"required": [
"type",
"url"
],
"additionalProperties": false
}
]
}
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
+4 -2
View File
@@ -5,7 +5,8 @@
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit",
"dev": "bun run ./src/index.ts"
}, },
"exports": { "exports": {
"./*": [ "./*": [
@@ -18,7 +19,8 @@
"@types/bun": "latest", "@types/bun": "latest",
"@types/turndown": "5.0.5", "@types/turndown": "5.0.5",
"@types/yargs": "17.0.33", "@types/yargs": "17.0.33",
"typescript": "catalog:" "typescript": "catalog:",
"zod-to-json-schema": "3.24.5"
}, },
"dependencies": { "dependencies": {
"@clack/prompts": "0.11.0", "@clack/prompts": "0.11.0",
@@ -1,23 +0,0 @@
# Maintainer: dax
# Maintainer: adam
pkgname='opencode-bin'
pkgver={{VERSION}}
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"
}
+105 -24
View File
@@ -110,7 +110,8 @@ if (!snapshot) {
return ( return (
!lower.includes("chore:") && !lower.includes("chore:") &&
!lower.includes("ci:") && !lower.includes("ci:") &&
!lower.includes("docs:") !lower.includes("docs:") &&
!lower.includes("doc:")
) )
}) })
.join("\n") .join("\n")
@@ -118,30 +119,52 @@ if (!snapshot) {
if (!dry) if (!dry)
await $`gh release create v${version} --title "v${version}" --notes ${notes} ./dist/*.zip` 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 // AUR package
const pkgbuildTemplate = await Bun.file("./script/PKGBUILD.template").text() const pkgbuild = [
const pkgbuild = pkgbuildTemplate "# Maintainer: dax",
.replace("{{VERSION}}", version.split("-")[0]) "# Maintainer: adam",
.replace( "",
"{{ARM64_URL}}", "pkgname='opencode-bin'",
`https://github.com/sst/opencode/releases/download/v${version}/opencode-linux-arm64.zip`, `pkgver=${version.split("-")[0]}`,
) "options=('!debug' '!strip')",
.replace( "pkgrel=1",
"{{ARM64_SHA}}", "pkgdesc='The AI coding agent built for the terminal.'",
await $`sha256sum ./dist/opencode-linux-arm64.zip | cut -d' ' -f1` "url='https://github.com/sst/opencode'",
.text() "arch=('aarch64' 'x86_64')",
.then((x) => x.trim()), "license=('MIT')",
) "provides=('opencode')",
.replace( "conflicts=('opencode')",
"{{X64_URL}}", "depends=('fzf' 'ripgrep')",
`https://github.com/sst/opencode/releases/download/v${version}/opencode-linux-x64.zip`, "",
) `source_aarch64=("\${pkgname}_\${pkgver}_aarch64.zip::https://github.com/sst/opencode/releases/download/v${version}/opencode-linux-arm64.zip")`,
.replace( `sha256sums_aarch64=('${arm64Sha}')`,
"{{X64_SHA}}", "",
await $`sha256sum ./dist/opencode-linux-x64.zip | cut -d' ' -f1` `source_x86_64=("\${pkgname}_\${pkgver}_x86_64.zip::https://github.com/sst/opencode/releases/download/v${version}/opencode-linux-x64.zip")`,
.text() `sha256sums_x86_64=('${x64Sha}')`,
.then((x) => x.trim()), "",
) "package() {",
' install -Dm755 ./opencode "${pkgdir}/usr/bin/opencode"',
"}",
"",
].join("\n")
await $`rm -rf ./dist/aur-opencode-bin` await $`rm -rf ./dist/aur-opencode-bin`
@@ -151,4 +174,62 @@ if (!snapshot) {
await $`cd ./dist/aur-opencode-bin && git add PKGBUILD .SRCINFO` await $`cd ./dist/aur-opencode-bin && git add PKGBUILD .SRCINFO`
await $`cd ./dist/aur-opencode-bin && git commit -m "Update to v${version}"` await $`cd ./dist/aur-opencode-bin && git commit -m "Update to v${version}"`
if (!dry) await $`cd ./dist/aur-opencode-bin && git push` 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`
} }
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bun
import "zod-openapi/extend"
import { Config } from "../src/config/config"
import { zodToJsonSchema } from "zod-to-json-schema"
const result = zodToJsonSchema(Config.Info)
await Bun.write("config.schema.json", JSON.stringify(result, null, 2))
@@ -2,6 +2,7 @@ import { Server } from "../../server/server"
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import type { CommandModule } from "yargs" import type { CommandModule } from "yargs"
import { Config } from "../../config/config"
export const GenerateCommand = { export const GenerateCommand = {
command: "generate", command: "generate",
+1 -5
View File
@@ -50,6 +50,7 @@ export namespace Config {
export const Info = z export const Info = z
.object({ .object({
$schema: z.string().optional(),
provider: z provider: z
.record( .record(
ModelsDev.Provider.partial().extend({ ModelsDev.Provider.partial().extend({
@@ -58,11 +59,6 @@ export namespace Config {
}), }),
) )
.optional(), .optional(),
tool: z
.object({
provider: z.record(z.string(), z.string().array()).optional(),
})
.optional(),
mcp: z.record(z.string(), Mcp).optional(), mcp: z.record(z.string(), Mcp).optional(),
}) })
.strict() .strict()
+11 -11
View File
@@ -1,4 +1,3 @@
import { App } from "../app/app"
import path from "path" import path from "path"
import { Global } from "../global" import { Global } from "../global"
import fs from "fs/promises" import fs from "fs/promises"
@@ -6,6 +5,7 @@ import { z } from "zod"
import { NamedError } from "../util/error" import { NamedError } from "../util/error"
import { lazy } from "../util/lazy" import { lazy } from "../util/lazy"
import { Log } from "../util/log" import { Log } from "../util/log"
import { $ } from "bun"
export namespace Fzf { export namespace Fzf {
const log = Log.create({ service: "fzf" }) const log = Log.create({ service: "fzf" })
@@ -117,18 +117,18 @@ export namespace Fzf {
} }
export async function search(cwd: string, query: string) { export async function search(cwd: string, query: string) {
const process = Bun.spawn({ const results = await $`${await filepath()} --filter ${query}`
cwd, .quiet()
stdin: "inherit", .throws(false)
stdout: "pipe", .cwd(cwd)
stderr: "pipe", .text()
cmd: [await filepath(), "--filter", query], const split = results
})
await process.exited
const stdout = await Bun.readableStreamToText(process.stdout)
return stdout
.trim() .trim()
.split("\n") .split("\n")
.filter((line) => line.length > 0) .filter((line) => line.length > 0)
log.info("results", {
count: split.length,
})
return split
} }
} }
+8
View File
@@ -0,0 +1,8 @@
export namespace Flag {
export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE")
function truthy(key: string) {
const value = process.env[key]?.toLowerCase()
return value === "true" || value === "1"
}
}
+1 -1
View File
@@ -72,7 +72,7 @@ const cli = yargs(hideBin(process.argv))
cmd = [binary] cmd = [binary]
} }
const proc = Bun.spawn({ const proc = Bun.spawn({
cmd, cmd: [...cmd, ...process.argv.slice(2)],
cwd, cwd,
stdout: "inherit", stdout: "inherit",
stderr: "inherit", stderr: "inherit",
+32 -23
View File
@@ -7,31 +7,40 @@ export namespace ModelsDev {
const log = Log.create({ service: "models.dev" }) const log = Log.create({ service: "models.dev" })
const filepath = path.join(Global.Path.cache, "models.json") const filepath = path.join(Global.Path.cache, "models.json")
export const Model = z.object({ export const Model = z
name: z.string(), .object({
attachment: z.boolean(), name: z.string(),
reasoning: z.boolean(), attachment: z.boolean(),
temperature: z.boolean(), reasoning: z.boolean(),
cost: z.object({ temperature: z.boolean(),
input: z.number(), cost: z.object({
output: z.number(), input: z.number(),
inputCached: z.number(), output: z.number(),
outputCached: z.number(), inputCached: z.number(),
}), outputCached: z.number(),
limit: z.object({ }),
context: z.number(), limit: z.object({
output: z.number(), context: z.number(),
}), output: z.number(),
id: z.string(), }),
}) id: z.string(),
})
.openapi({
ref: "Model.Info",
})
export type Model = z.infer<typeof Model> export type Model = z.infer<typeof Model>
export const Provider = z.object({ export const Provider = z
name: z.string(), .object({
env: z.array(z.string()), name: z.string(),
id: z.string(), env: z.array(z.string()),
models: z.record(Model), id: z.string(),
}) models: z.record(Model),
})
.openapi({
ref: "Provider.Info",
})
export type Provider = z.infer<typeof Provider> export type Provider = z.infer<typeof Provider>
export async function get() { export async function get() {
@@ -289,11 +289,13 @@ export namespace Provider {
google: TOOLS, google: TOOLS,
} }
export async function tools(providerID: string) { export async function tools(providerID: string) {
/*
const cfg = await Config.get() const cfg = await Config.get()
if (cfg.tool?.provider?.[providerID]) if (cfg.tool?.provider?.[providerID])
return cfg.tool.provider[providerID].map( return cfg.tool.provider[providerID].map(
(id) => TOOLS.find((t) => t.id === id)!, (id) => TOOLS.find((t) => t.id === id)!,
) )
*/
return TOOL_MAPPING[providerID] ?? TOOLS return TOOL_MAPPING[providerID] ?? TOOLS
} }
+7 -3
View File
@@ -56,12 +56,16 @@ export namespace Server {
}, },
) )
}) })
.use((c, next) => { .use(async (c, next) => {
log.info("request", { log.info("request", {
method: c.req.method, method: c.req.method,
path: c.req.path, path: c.req.path,
}) })
return next() const start = Date.now()
await next()
log.info("response", {
duration: Date.now() - start,
})
}) })
.get( .get(
"/openapi", "/openapi",
@@ -422,7 +426,7 @@ export namespace Server {
) )
return c.json({ return c.json({
providers: Object.values(providers), providers: Object.values(providers),
defaults: mapValues( default: mapValues(
providers, providers,
(item) => Provider.sort(Object.values(item.models))[0].id, (item) => Provider.sort(Object.values(item.models))[0].id,
), ),
+2 -1
View File
@@ -27,6 +27,7 @@ import { MCP } from "../mcp"
import { NamedError } from "../util/error" import { NamedError } from "../util/error"
import type { Tool } from "../tool/tool" import type { Tool } from "../tool/tool"
import { SystemPrompt } from "./system" import { SystemPrompt } from "./system"
import { Flag } from "../flag/flag"
export namespace Session { export namespace Session {
const log = Log.create({ service: "session" }) const log = Log.create({ service: "session" })
@@ -92,7 +93,7 @@ export namespace Session {
log.info("created", result) log.info("created", result)
state().sessions.set(result.id, result) state().sessions.set(result.id, result)
await Storage.writeJSON("session/info/" + result.id, result) await Storage.writeJSON("session/info/" + result.id, result)
if (!result.parentID) if (!result.parentID && Flag.OPENCODE_AUTO_SHARE)
share(result.id).then((share) => { share(result.id).then((share) => {
update(result.id, (draft) => { update(result.id, (draft) => {
draft.share = share draft.share = share
@@ -1,14 +1,14 @@
You are OpenCode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user. You are opencode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
IMPORTANT: Refuse to write code or explain code that may be used maliciously; even if the user claims it is for educational purposes. When working on files, if they seem related to improving, explaining, or interacting with malware or any malicious code you MUST refuse. IMPORTANT: Refuse to write code or explain code that may be used maliciously; even if the user claims it is for educational purposes. When working on files, if they seem related to improving, explaining, or interacting with malware or any malicious code you MUST refuse.
IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure. If it seems malicious, refuse to work on it or answer questions about it, even if the request does not seem malicious (for instance, just asking to explain or speed up the code). IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure. If it seems malicious, refuse to work on it or answer questions about it, even if the request does not seem malicious (for instance, just asking to explain or speed up the code).
IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files. IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.
If the user asks for help or wants to give feedback inform them of the following: If the user asks for help or wants to give feedback inform them of the following:
- /help: Get help with using OpenCode - /help: Get help with using opencode
- To give feedback, users should report the issue at https://github.com/sst/opencode/issues - To give feedback, users should report the issue at https://github.com/sst/opencode/issues
When the user directly asks about OpenCode (eg 'can OpenCode do...', 'does OpenCode have...') or asks in second person (eg 'are you able...', 'can you do...'), first use the WebFetch tool to gather information to answer the question from OpenCode docs at https://opencode.ai When the user directly asks about opencode (eg 'can opencode do...', 'does opencode have...') or asks in second person (eg 'are you able...', 'can you do...'), first use the WebFetch tool to gather information to answer the question from opencode docs at https://opencode.ai
# Tone and style # Tone and style
You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system). You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
+6 -6
View File
@@ -22,7 +22,7 @@ Usage notes:
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
- If the output exceeds 30000 characters, output will be truncated before being returned to you. - If the output exceeds 30000 characters, output will be truncated before being returned to you.
- VERY IMPORTANT: You MUST avoid using search commands like `find` and `grep`. Instead use Grep, Glob, or Task to search. You MUST avoid read tools like `cat`, `head`, `tail`, and `ls`, and use Read and LS to read files. - VERY IMPORTANT: You MUST avoid using search commands like `find` and `grep`. Instead use Grep, Glob, or Task to search. You MUST avoid read tools like `cat`, `head`, `tail`, and `ls`, and use Read and LS to read files.
- If you _still_ need to run `grep`, STOP. ALWAYS USE ripgrep at `rg` (or /usr/bin/rg) first, which all OpenCode users have pre-installed. - If you _still_ need to run `grep`, STOP. ALWAYS USE ripgrep at `rg` (or /usr/bin/rg) first, which all opencode users have pre-installed.
- When issuing multiple commands, use the ';' or '&&' operator to separate them. DO NOT use newlines (newlines are ok in quoted strings). - When issuing multiple commands, use the ';' or '&&' operator to separate them. DO NOT use newlines (newlines are ok in quoted strings).
- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of `cd`. You may use `cd` if the User explicitly requests it. - Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of `cd`. You may use `cd` if the User explicitly requests it.
<good-example> <good-example>
@@ -60,9 +60,9 @@ When the user asks you to create a new git commit, follow these steps carefully:
3. You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. ALWAYS run the following commands in parallel: 3. You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. ALWAYS run the following commands in parallel:
- Add relevant untracked files to the staging area. - Add relevant untracked files to the staging area.
- Create the commit with a message ending with: - Create the commit with a message ending with:
🤖 Generated with [OpenCode](https://opencode.ai) 🤖 Generated with [opencode](https://opencode.ai)
Co-Authored-By: OpenCode <noreply@opencode.ai> Co-Authored-By: opencode <noreply@opencode.ai>
- Run git status to make sure the commit succeeded. - Run git status to make sure the commit succeeded.
4. If the commit fails due to pre-commit hook changes, retry the commit ONCE to include these automated changes. If it fails again, it usually means a pre-commit hook is preventing the commit. If the commit succeeds but you notice that files were modified by the pre-commit hook, you MUST amend your commit to include them. 4. If the commit fails due to pre-commit hook changes, retry the commit ONCE to include these automated changes. If it fails again, it usually means a pre-commit hook is preventing the commit. If the commit succeeds but you notice that files were modified by the pre-commit hook, you MUST amend your commit to include them.
@@ -81,9 +81,9 @@ Important notes:
git commit -m "$(cat <<'EOF' git commit -m "$(cat <<'EOF'
Commit message here. Commit message here.
🤖 Generated with [OpenCode](https://opencode.ai) 🤖 Generated with [opencode](https://opencode.ai)
Co-Authored-By: OpenCode <noreply@opencode.ai> Co-Authored-By: opencode <noreply@opencode.ai>
EOF EOF
)" )"
</example> </example>
@@ -128,7 +128,7 @@ gh pr create --title "the pr title" --body "$(cat <<'EOF'
## Test plan ## Test plan
[Checklist of TODOs for testing the pull request...] [Checklist of TODOs for testing the pull request...]
🤖 Generated with [OpenCode](https://opencode.ai) 🤖 Generated with [opencode](https://opencode.ai)
EOF EOF
)" )"
</example> </example>
+1 -1
View File
@@ -27,7 +27,7 @@ export const GlobTool = Tool.define({
const glob = new Bun.Glob(params.pattern) const glob = new Bun.Glob(params.pattern)
const files = [] const files = []
let truncated = false let truncated = false
for await (const file of glob.scan({ cwd: search })) { for await (const file of glob.scan({ cwd: search, dot: true })) {
if (files.length >= limit) { if (files.length >= limit) {
truncated = true truncated = true
break break
+1 -1
View File
@@ -40,7 +40,7 @@ export const ListTool = Tool.define({
const glob = new Bun.Glob("**/*") const glob = new Bun.Glob("**/*")
const files = [] const files = []
for await (const file of glob.scan({ cwd: searchPath })) { for await (const file of glob.scan({ cwd: searchPath, dot: true })) {
if (file.startsWith(".") || IGNORE_PATTERNS.some((p) => file.includes(p))) if (file.startsWith(".") || IGNORE_PATTERNS.some((p) => file.includes(p)))
continue continue
if (params.ignore?.some((pattern) => new Bun.Glob(pattern).match(file))) if (params.ignore?.some((pattern) => new Bun.Glob(pattern).match(file)))
+1 -1
View File
@@ -2,7 +2,7 @@ import path from "path"
import fs from "fs/promises" import fs from "fs/promises"
import { Global } from "../global" import { Global } from "../global"
export namespace Log { export namespace Log {
export const Default = create() export const Default = create({ service: "default" })
export interface Options { export interface Options {
print: boolean print: boolean
-3
View File
@@ -123,9 +123,6 @@ func main() {
// Cancel subscriptions first // Cancel subscriptions first
cancelSubs() cancelSubs()
// Then shutdown the app
app_.Shutdown()
// Then cancel TUI message handler // Then cancel TUI message handler
tuiCancel() tuiCancel()
+36 -47
View File
@@ -9,8 +9,8 @@ import (
"log/slog" "log/slog"
tea "github.com/charmbracelet/bubbletea/v2" tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/config" "github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/fileutil"
"github.com/sst/opencode/internal/state" "github.com/sst/opencode/internal/state"
"github.com/sst/opencode/internal/status" "github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/theme"
@@ -23,14 +23,11 @@ type App struct {
Config *config.Config Config *config.Config
Client *client.ClientWithResponses Client *client.ClientWithResponses
Provider *client.ProviderInfo Provider *client.ProviderInfo
Model *client.ProviderModel Model *client.ModelInfo
Session *client.SessionInfo Session *client.SessionInfo
Messages []client.MessageInfo Messages []client.MessageInfo
Status status.Service Status status.Service
Commands commands.Registry
// UI state
filepickerOpen bool
completionDialogOpen bool
} }
type AppInfo struct { type AppInfo struct {
@@ -61,20 +58,25 @@ func New(ctx context.Context, version string, httpClient *client.ClientWithRespo
} }
providers := []client.ProviderInfo{} providers := []client.ProviderInfo{}
var defaultProvider *client.ProviderInfo var defaultProvider *client.ProviderInfo
var defaultModel *client.ProviderModel var defaultModel *client.ModelInfo
for i, provider := range providersResponse.JSON200.Providers { var anthropic *client.ProviderInfo
if i == 0 || provider.Id == "anthropic" { for _, provider := range providersResponse.JSON200.Providers {
defaultProvider = &providersResponse.JSON200.Providers[i] if provider.Id == "anthropic" {
if match, ok := providersResponse.JSON200.Default[provider.Id]; ok { anthropic = &provider
model := defaultProvider.Models[match] }
defaultModel = &model }
} else {
for _, model := range provider.Models { // default to anthropic if available
defaultModel = &model if anthropic != nil {
break defaultProvider = anthropic
} defaultModel = getDefaultModel(providersResponse, *anthropic)
} }
for _, provider := range providersResponse.JSON200.Providers {
if defaultProvider == nil || defaultModel == nil {
defaultProvider = &provider
defaultModel = getDefaultModel(providersResponse, provider)
} }
providers = append(providers, provider) providers = append(providers, provider)
} }
@@ -82,7 +84,7 @@ func New(ctx context.Context, version string, httpClient *client.ClientWithRespo
return nil, fmt.Errorf("no providers found") 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) appConfig, err := config.LoadConfig(appConfigPath)
if err != nil { if err != nil {
slog.Info("No TUI config found, using default values", "error", err) slog.Info("No TUI config found, using default values", "error", err)
@@ -91,7 +93,7 @@ func New(ctx context.Context, version string, httpClient *client.ClientWithRespo
} }
var currentProvider *client.ProviderInfo var currentProvider *client.ProviderInfo
var currentModel *client.ProviderModel var currentModel *client.ModelInfo
for _, provider := range providers { for _, provider := range providers {
if provider.Id == appConfig.Provider { if provider.Id == appConfig.Provider {
currentProvider = &provider currentProvider = &provider
@@ -113,14 +115,26 @@ func New(ctx context.Context, version string, httpClient *client.ClientWithRespo
Session: &client.SessionInfo{}, Session: &client.SessionInfo{},
Messages: []client.MessageInfo{}, Messages: []client.MessageInfo{},
Status: status.GetService(), Status: status.GetService(),
Commands: commands.NewCommandRegistry(),
} }
theme.SetTheme(appConfig.Theme) theme.SetTheme(appConfig.Theme)
fileutil.Init()
return app, nil return app, nil
} }
func getDefaultModel(response *client.PostProviderListResponse, provider client.ProviderInfo) *client.ModelInfo {
if match, ok := response.JSON200.Default[provider.Id]; ok {
model := provider.Models[match]
return &model
} else {
for _, model := range provider.Models {
return &model
}
}
return nil
}
type Attachment struct { type Attachment struct {
FilePath string FilePath string
FileName string FileName string
@@ -309,28 +323,3 @@ func (a *App) ListProviders(ctx context.Context) ([]client.ProviderInfo, error)
providers := *resp.JSON200 providers := *resp.JSON200
return providers.Providers, nil 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,18 +1,15 @@
package completions package completions
import ( import (
"bytes" "context"
"fmt"
"os/exec"
"path/filepath"
"github.com/lithammer/fuzzysearch/fuzzy" "github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/fileutil"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/components/dialog" "github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/pkg/client"
) )
type filesAndFoldersContextGroup struct { type filesAndFoldersContextGroup struct {
app *app.App
prefix string prefix string
} }
@@ -27,143 +24,18 @@ func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI {
}) })
} }
func processNullTerminatedOutput(outputBytes []byte) []string {
if len(outputBytes) > 0 && outputBytes[len(outputBytes)-1] == 0 {
outputBytes = outputBytes[:len(outputBytes)-1]
}
if len(outputBytes) == 0 {
return []string{}
}
split := bytes.Split(outputBytes, []byte{0})
matches := make([]string, 0, len(split))
for _, p := range split {
if len(p) == 0 {
continue
}
path := string(p)
path = filepath.Join(".", path)
if !fileutil.SkipHidden(path) {
matches = append(matches, path)
}
}
return matches
}
func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) { func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) {
cmdRg := fileutil.GetRgCmd("") // No glob pattern for this use case response, err := cg.app.Client.PostFileSearchWithResponse(context.Background(), client.PostFileSearchJSONRequestBody{
cmdFzf := fileutil.GetFzfCmd(query) Query: query,
})
var matches []string if err != nil {
// Case 1: Both rg and fzf available return []string{}, err
if cmdRg != nil && cmdFzf != nil { }
rgPipe, err := cmdRg.StdoutPipe() if response.JSON200 == nil {
if err != nil { return []string{}, nil
return nil, fmt.Errorf("failed to get rg stdout pipe: %w", err)
}
defer rgPipe.Close()
cmdFzf.Stdin = rgPipe
var fzfOut bytes.Buffer
var fzfErr bytes.Buffer
cmdFzf.Stdout = &fzfOut
cmdFzf.Stderr = &fzfErr
if err := cmdFzf.Start(); err != nil {
return nil, fmt.Errorf("failed to start fzf: %w", err)
}
errRg := cmdRg.Run()
errFzf := cmdFzf.Wait()
if errRg != nil {
status.Warn(fmt.Sprintf("rg command failed during pipe: %v", errRg))
}
if errFzf != nil {
if exitErr, ok := errFzf.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
return []string{}, nil // No matches from fzf
}
return nil, fmt.Errorf("fzf command failed: %w\nStderr: %s", errFzf, fzfErr.String())
}
matches = processNullTerminatedOutput(fzfOut.Bytes())
// Case 2: Only rg available
} else if cmdRg != nil {
status.Debug("Using Ripgrep with fuzzy match fallback for file completions")
var rgOut bytes.Buffer
var rgErr bytes.Buffer
cmdRg.Stdout = &rgOut
cmdRg.Stderr = &rgErr
if err := cmdRg.Run(); err != nil {
return nil, fmt.Errorf("rg command failed: %w\nStderr: %s", err, rgErr.String())
}
allFiles := processNullTerminatedOutput(rgOut.Bytes())
matches = fuzzy.Find(query, allFiles)
// Case 3: Only fzf available
} else if cmdFzf != nil {
status.Debug("Using FZF with doublestar fallback for file completions")
files, _, err := fileutil.GlobWithDoublestar("**/*", ".", 0)
if err != nil {
return nil, fmt.Errorf("failed to list files for fzf: %w", err)
}
allFiles := make([]string, 0, len(files))
for _, file := range files {
if !fileutil.SkipHidden(file) {
allFiles = append(allFiles, file)
}
}
var fzfIn bytes.Buffer
for _, file := range allFiles {
fzfIn.WriteString(file)
fzfIn.WriteByte(0)
}
cmdFzf.Stdin = &fzfIn
var fzfOut bytes.Buffer
var fzfErr bytes.Buffer
cmdFzf.Stdout = &fzfOut
cmdFzf.Stderr = &fzfErr
if err := cmdFzf.Run(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
return []string{}, nil
}
return nil, fmt.Errorf("fzf command failed: %w\nStderr: %s", err, fzfErr.String())
}
matches = processNullTerminatedOutput(fzfOut.Bytes())
// Case 4: Fallback to doublestar with fuzzy match
} else {
status.Debug("Using doublestar with fuzzy match for file completions")
allFiles, _, err := fileutil.GlobWithDoublestar("**/*", ".", 0)
if err != nil {
return nil, fmt.Errorf("failed to glob files: %w", err)
}
filteredFiles := make([]string, 0, len(allFiles))
for _, file := range allFiles {
if !fileutil.SkipHidden(file) {
filteredFiles = append(filteredFiles, file)
}
}
matches = fuzzy.Find(query, filteredFiles)
} }
return matches, nil return *response.JSON200, nil
} }
func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) { func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
@@ -184,8 +56,9 @@ func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.C
return items, nil return items, nil
} }
func NewFileAndFolderContextGroup() dialog.CompletionProvider { func NewFileAndFolderContextGroup(app *app.App) dialog.CompletionProvider {
return &filesAndFoldersContextGroup{ return &filesAndFoldersContextGroup{
app: app,
prefix: "file", 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"]
}
+38 -27
View File
@@ -13,6 +13,7 @@ import (
tea "github.com/charmbracelet/bubbletea/v2" tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/dialog" "github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/image" "github.com/sst/opencode/internal/image"
"github.com/sst/opencode/internal/layout" "github.com/sst/opencode/internal/layout"
@@ -43,11 +44,6 @@ type EditorKeyMaps struct {
HistoryDown key.Binding HistoryDown key.Binding
} }
type bluredEditorKeyMaps struct {
Send key.Binding
Focus key.Binding
OpenEditor key.Binding
}
type DeleteAttachmentKeyMaps struct { type DeleteAttachmentKeyMaps struct {
AttachmentDeleteMode key.Binding AttachmentDeleteMode key.Binding
Escape key.Binding Escape key.Binding
@@ -107,16 +103,18 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case dialog.ThemeChangedMsg: case dialog.ThemeChangedMsg:
m.textarea = createTextArea(&m.textarea) m.textarea = createTextArea(&m.textarea)
case dialog.CompletionSelectedMsg: case dialog.CompletionSelectedMsg:
existingValue := m.textarea.Value() if msg.IsCommand {
modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1) // Execute the command directly
m.textarea.SetValue(modifiedValue) commandName := strings.TrimPrefix(msg.CompletionValue, "/")
return m, nil m.textarea.Reset()
case dialog.AttachmentAddedMsg: return m, util.CmdHandler(commands.ExecuteCommandMsg{Name: commandName})
if len(m.attachments) >= maxAttachments { } else {
status.Error(fmt.Sprintf("cannot add more than %d images", maxAttachments)) // For files, replace the text in the editor
return m, cmd 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: case tea.KeyMsg:
switch msg.String() { switch msg.String() {
case "ctrl+c": 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 // Handle history navigation with up/down arrow keys
// Only handle history navigation if the filepicker is not open and completion dialog is not open // 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 // Get the current line number
currentLine := m.textarea.Line() 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 // Get the current line number and total lines
currentLine := m.textarea.Line() currentLine := m.textarea.Line()
value := m.textarea.Value() value := m.textarea.Value()
@@ -271,9 +273,11 @@ func (m *editorComponent) View() string {
) )
textarea = styles.BaseStyle(). textarea = styles.BaseStyle().
Width(m.width). Width(m.width).
PaddingTop(1).
PaddingBottom(1).
Background(t.BackgroundElement()). Background(t.BackgroundElement()).
Border(lipgloss.ThickBorder(), false, true). Border(lipgloss.ThickBorder(), false, true).
BorderForeground(t.BorderActive()). BorderForeground(t.BackgroundSubtle()).
BorderBackground(t.Background()). BorderBackground(t.Background()).
Render(textarea) Render(textarea)
@@ -284,7 +288,7 @@ func (m *editorComponent) View() string {
model := "" model := ""
if m.app.Model != nil { if m.app.Model != nil {
model = base(*m.app.Model.Name) + muted(" • /model") model = base(m.app.Model.Name) + muted(" • /model")
} }
space := m.width - 2 - lipgloss.Width(model) - lipgloss.Width(hint) space := m.width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
@@ -308,7 +312,7 @@ func (m *editorComponent) SetSize(width, height int) tea.Cmd {
m.width = width m.width = width
m.height = height m.height = height
m.textarea.SetWidth(width - 5) // account for the prompt and padding right 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 return nil
} }
@@ -316,13 +320,6 @@ func (m *editorComponent) GetSize() (int, int) {
return m.width, m.height 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 { func (m *editorComponent) openEditor(value string) tea.Cmd {
editor := os.Getenv("EDITOR") editor := os.Getenv("EDITOR")
if editor == "" { if editor == "" {
@@ -365,7 +362,7 @@ func (m *editorComponent) openEditor(value string) tea.Cmd {
} }
func (m *editorComponent) send() tea.Cmd { func (m *editorComponent) send() tea.Cmd {
value := m.textarea.Value() value := strings.TrimSpace(m.textarea.Value())
m.textarea.Reset() m.textarea.Reset()
attachments := m.attachments attachments := m.attachments
@@ -382,6 +379,16 @@ func (m *editorComponent) send() tea.Cmd {
if value == "" { if value == "" {
return nil 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( return tea.Batch(
util.CmdHandler(SendMsg{ util.CmdHandler(SendMsg{
Text: value, Text: value,
@@ -449,6 +456,10 @@ func createTextArea(existing *textarea.Model) textarea.Model {
return ta return ta
} }
func (m *editorComponent) GetValue() string {
return m.textarea.Value()
}
func NewEditorComponent(app *app.App) layout.ModelWithView { func NewEditorComponent(app *app.App) layout.ModelWithView {
s := spinner.New(spinner.WithSpinner(spinner.Ellipsis), spinner.WithStyle(styles.Muted().Width(3))) s := spinner.New(spinner.WithSpinner(spinner.Ellipsis), spinner.WithStyle(styles.Muted().Width(3)))
ta := createTextArea(nil) ta := createTextArea(nil)
+159 -84
View File
@@ -55,6 +55,10 @@ type blockRenderer struct {
fullWidth bool fullWidth bool
paddingTop int paddingTop int
paddingBottom int paddingBottom int
paddingLeft int
paddingRight int
marginTop int
marginBottom int
} }
type renderingOption func(*blockRenderer) 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 { func WithPaddingTop(padding int) renderingOption {
return func(c *blockRenderer) { return func(c *blockRenderer) {
c.paddingTop = padding c.paddingTop = padding
@@ -92,17 +120,23 @@ func WithPaddingBottom(padding int) renderingOption {
func renderContentBlock(content string, options ...renderingOption) string { func renderContentBlock(content string, options ...renderingOption) string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
renderer := &blockRenderer{ renderer := &blockRenderer{
fullWidth: false, fullWidth: false,
paddingTop: 1,
paddingBottom: 1,
paddingLeft: 2,
paddingRight: 2,
} }
for _, option := range options { for _, option := range options {
option(renderer) option(renderer)
} }
style := styles.BaseStyle(). style := styles.BaseStyle().
PaddingTop(1). MarginTop(renderer.marginTop).
PaddingBottom(1). MarginBottom(renderer.marginBottom).
PaddingLeft(2). PaddingTop(renderer.paddingTop).
PaddingRight(2). PaddingBottom(renderer.paddingBottom).
PaddingLeft(renderer.paddingLeft).
PaddingRight(renderer.paddingRight).
Background(t.BackgroundSubtle()). Background(t.BackgroundSubtle()).
Foreground(t.TextMuted()). Foreground(t.TextMuted()).
BorderStyle(lipgloss.ThickBorder()) BorderStyle(lipgloss.ThickBorder())
@@ -142,12 +176,6 @@ func renderContentBlock(content string, options ...renderingOption) string {
style = style.Width(layout.Current.Container.Width) style = style.Width(layout.Current.Container.Width)
} }
content = style.Render(content) 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( content = lipgloss.PlaceHorizontal(
layout.Current.Container.Width, layout.Current.Container.Width,
align, align,
@@ -165,13 +193,12 @@ func renderText(message client.MessageInfo, text string, author string) string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
width := layout.Current.Container.Width width := layout.Current.Container.Width
padding := 0 padding := 0
switch layout.Current.Size { if layout.Current.Viewport.Width < 80 {
case layout.LayoutSizeSmall:
padding = 5 padding = 5
case layout.LayoutSizeNormal: } else if layout.Current.Viewport.Width < 120 {
padding = 10
case layout.LayoutSizeLarge:
padding = 15 padding = 15
} else {
padding = 20
} }
timestamp := time.UnixMilli(int64(message.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM") 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 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 markdownWidth := min(textWidth, width-padding-4) // -4 for the border and padding
content := toMarkdown(text, markdownWidth, t.BackgroundSubtle()) content := toMarkdown(text, markdownWidth, t.BackgroundSubtle())
content = lipgloss.JoinVertical(align, content, info) content = lipgloss.JoinVertical(align, content, info)
@@ -203,7 +230,7 @@ func renderText(message client.MessageInfo, text string, author string) string {
case client.Assistant: case client.Assistant:
return renderContentBlock(content, return renderContentBlock(content,
WithAlign(lipgloss.Left), WithAlign(lipgloss.Left),
WithBorderColor(t.Primary()), WithBorderColor(t.Accent()),
) )
} }
return "" return ""
@@ -212,7 +239,7 @@ func renderText(message client.MessageInfo, text string, author string) string {
func renderToolInvocation( func renderToolInvocation(
toolCall client.MessageToolInvocationToolCall, toolCall client.MessageToolInvocationToolCall,
result *string, result *string,
metadata map[string]any, metadata client.MessageInfo_Metadata_Tool_AdditionalProperties,
showResult bool, showResult bool,
) string { ) string {
ignoredTools := []string{"opencode_todoread"} ignoredTools := []string{"opencode_todoread"}
@@ -220,16 +247,28 @@ func renderToolInvocation(
return "" return ""
} }
padding := 1 outerWidth := layout.Current.Container.Width
outerWidth := layout.Current.Container.Width - 1 // subtract 1 for the border innerWidth := outerWidth - 6
innerWidth := outerWidth - padding - 4 // -4 for the border and padding paddingTop := 0
paddingBottom := 0
if showResult {
paddingTop = 1
if result == nil || *result == "" {
paddingBottom = 1
}
}
t := theme.CurrentTheme() t := theme.CurrentTheme()
style := styles.Muted(). style := styles.Muted().
Width(outerWidth). Width(outerWidth).
PaddingLeft(padding). Background(t.BackgroundSubtle()).
PaddingTop(paddingTop).
PaddingBottom(paddingBottom).
PaddingLeft(2).
PaddingRight(2).
BorderLeft(true). BorderLeft(true).
BorderForeground(t.BorderSubtle()). BorderRight(true).
BorderForeground(t.BackgroundSubtle()).
BorderStyle(lipgloss.ThickBorder()) BorderStyle(lipgloss.ThickBorder())
if toolCall.State == "partial-call" { if toolCall.State == "partial-call" {
@@ -260,88 +299,117 @@ func renderToolInvocation(
body := "" body := ""
error := "" error := ""
finished := result != nil && *result != "" finished := result != nil && *result != ""
if finished {
body = *result
}
if metadata["error"] != nil && metadata["message"] != nil { if e, ok := metadata.Get("error"); ok && e.(bool) == true {
body = "" if m, ok := metadata.Get("message"); ok {
error = styles.BaseStyle(). style = style.BorderLeftForeground(t.Error())
Foreground(t.Error()). error = styles.BaseStyle().
Render(metadata["message"].(string)) Background(t.BackgroundSubtle()).
error = renderContentBlock(error, WithBorderColor(t.Error()), WithFullWidth(), WithPaddingTop(1), WithPaddingBottom(1)) Foreground(t.Error()).
Render(m.(string))
error = renderContentBlock(
error,
WithFullWidth(),
WithBorderColor(t.Error()),
WithMarginBottom(1),
)
}
} }
elapsed := "" elapsed := ""
if metadata["time"] != nil { start := metadata.Time.Start
timeMap := metadata["time"].(map[string]any) end := metadata.Time.End
start := timeMap["start"].(float64) durationMs := end - start
end := timeMap["end"].(float64) duration := time.Duration(durationMs * float32(time.Millisecond))
durationMs := end - start roundedDuration := time.Duration(duration.Round(time.Millisecond))
duration := time.Duration(durationMs * float64(time.Millisecond)) if durationMs > 1000 {
roundedDuration := time.Duration(duration.Round(time.Millisecond)) roundedDuration = time.Duration(duration.Round(time.Second))
if durationMs > 1000 {
roundedDuration = time.Duration(duration.Round(time.Second))
}
elapsed = styles.Muted().Render(roundedDuration.String())
} }
elapsed = styles.Muted().Render(roundedDuration.String())
title := "" title := ""
switch toolCall.ToolName { switch toolCall.ToolName {
case "opencode_read": case "opencode_read":
toolArgs = renderArgs(&toolArgsMap, "filePath") toolArgs = renderArgs(&toolArgsMap, "filePath")
title = fmt.Sprintf("Read: %s %s", toolArgs, elapsed) title = fmt.Sprintf("Read: %s %s", toolArgs, elapsed)
body = "" if preview, ok := metadata.Get("preview"); ok && toolArgsMap["filePath"] != nil {
if metadata["preview"] != nil && toolArgsMap["filePath"] != nil {
filename := toolArgsMap["filePath"].(string) filename := toolArgsMap["filePath"].(string)
body = metadata["preview"].(string) body = preview.(string)
body = renderFile(filename, body, WithTruncate(6)) body = renderFile(filename, body, WithTruncate(6))
} }
case "opencode_edit": case "opencode_edit":
filename := toolArgsMap["filePath"].(string) if filename, ok := toolArgsMap["filePath"].(string); ok {
title = fmt.Sprintf("Edit: %s %s", relative(filename), elapsed) title = fmt.Sprintf("Edit: %s %s", relative(filename), elapsed)
if metadata["diff"] != nil { if d, ok := metadata.Get("diff"); ok {
patch := metadata["diff"].(string) patch := d.(string)
diffWidth := min(layout.Current.Viewport.Width, 120) var formattedDiff string
formattedDiff, _ := diff.FormatDiff(filename, patch, diff.WithTotalWidth(diffWidth)) if layout.Current.Viewport.Width < 80 {
body = strings.TrimSpace(formattedDiff) formattedDiff, _ = diff.FormatUnifiedDiff(
body = lipgloss.Place( filename,
layout.Current.Viewport.Width, patch,
lipgloss.Height(body)+2, diff.WithWidth(layout.Current.Container.Width-2),
lipgloss.Center, )
lipgloss.Center, } else {
body, diffWidth := min(layout.Current.Viewport.Width-2, 120)
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())), 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": case "opencode_write":
filename := toolArgsMap["filePath"].(string) if filename, ok := toolArgsMap["filePath"].(string); ok {
title = fmt.Sprintf("Write: %s %s", relative(filename), elapsed) title = fmt.Sprintf("Write: %s %s", relative(filename), elapsed)
content := toolArgsMap["content"].(string) if content, ok := toolArgsMap["content"].(string); ok {
body = renderFile(filename, content) body = renderFile(filename, content)
}
}
case "opencode_bash": case "opencode_bash":
description := toolArgsMap["description"].(string) if description, ok := toolArgsMap["description"].(string); ok {
title = fmt.Sprintf("Shell: %s %s", description, elapsed) title = fmt.Sprintf("Shell: %s %s", description, elapsed)
if metadata["stdout"] != nil { }
if stdout, ok := metadata.Get("stdout"); ok {
command := toolArgsMap["command"].(string) command := toolArgsMap["command"].(string)
stdout := metadata["stdout"].(string) stdout := stdout.(string)
body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout) body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
body = toMarkdown(body, innerWidth, t.BackgroundSubtle()) body = toMarkdown(body, innerWidth, t.BackgroundSubtle())
body = renderContentBlock(body, WithFullWidth(), WithPaddingTop(1), WithPaddingBottom(1)) body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
} }
case "opencode_webfetch": case "opencode_webfetch":
toolArgs = renderArgs(&toolArgsMap, "url")
title = fmt.Sprintf("Fetching: %s %s", toolArgs, elapsed) title = fmt.Sprintf("Fetching: %s %s", toolArgs, elapsed)
format := toolArgsMap["format"].(string) if format, ok := toolArgsMap["format"].(string); ok {
body = truncateHeight(body, 10) body = *result
if format == "html" || format == "markdown" { body = truncateHeight(body, 10)
body = toMarkdown(body, innerWidth, t.BackgroundSubtle()) 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": case "opencode_todowrite":
title = fmt.Sprintf("Planning... %s", elapsed) title = fmt.Sprintf("Planning %s", elapsed)
if finished && metadata["todos"] != nil {
body = "" if to, ok := metadata.Get("todos"); ok && finished {
todos := metadata["todos"].([]any) todos := to.([]any)
for _, todo := range todos { for _, todo := range todos {
t := todo.(map[string]any) t := todo.(map[string]any)
content := t["content"].(string) content := t["content"].(string)
@@ -349,19 +417,26 @@ func renderToolInvocation(
case "completed": case "completed":
body += fmt.Sprintf("- [x] %s\n", content) body += fmt.Sprintf("- [x] %s\n", content)
// case "in-progress": // case "in-progress":
// body += fmt.Sprintf("- [ ] _%s_\n", content) // body += fmt.Sprintf("- [ ] %s\n", content)
default: default:
body += fmt.Sprintf("- [ ] %s\n", content) body += fmt.Sprintf("- [ ] %s\n", content)
} }
} }
body = toMarkdown(body, innerWidth, t.BackgroundSubtle()) body = toMarkdown(body, innerWidth, t.BackgroundSubtle())
body = renderContentBlock(body, WithFullWidth(), WithPaddingTop(1), WithPaddingBottom(1)) body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
} }
default: default:
toolName := renderToolName(toolCall.ToolName) toolName := renderToolName(toolCall.ToolName)
title = fmt.Sprintf("%s: %s %s", toolName, toolArgs, elapsed) title = fmt.Sprintf("%s: %s %s", toolName, toolArgs, elapsed)
body = *result
body = truncateHeight(body, 10) 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) 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 = fmt.Sprintf("```%s\n%s\n```", extension(renderer.filename), content)
content = toMarkdown(content, width, t.BackgroundSubtle()) 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 { func renderToolAction(name string) string {
@@ -118,7 +118,6 @@ type blockType int
const ( const (
none blockType = iota none blockType = iota
systemTextBlock
userTextBlock userTextBlock
assistantTextBlock assistantTextBlock
toolInvocationBlock toolInvocationBlock
@@ -134,10 +133,6 @@ func (m *messagesComponent) renderView() {
blocks := make([]string, 0) blocks := make([]string, 0)
previousBlockType := none previousBlockType := none
for _, message := range m.app.Messages { for _, message := range m.app.Messages {
if message.Role == client.System {
continue // ignoring system messages for now
}
var content string var content string
var cached bool var cached bool
@@ -174,15 +169,13 @@ func (m *messagesComponent) renderView() {
previousBlockType = userTextBlock previousBlockType = userTextBlock
} else if message.Role == client.Assistant { } else if message.Role == client.Assistant {
previousBlockType = assistantTextBlock previousBlockType = assistantTextBlock
} else if message.Role == client.System {
previousBlockType = systemTextBlock
} }
case client.MessagePartToolInvocation: case client.MessagePartToolInvocation:
toolInvocationPart := part.(client.MessagePartToolInvocation) toolInvocationPart := part.(client.MessagePartToolInvocation)
toolCall, _ := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolCall() toolCall, _ := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolCall()
metadata := map[string]any{} metadata := client.MessageInfo_Metadata_Tool_AdditionalProperties{}
if _, ok := message.Metadata.Tool[toolCall.ToolCallId]; ok { if _, ok := message.Metadata.Tool[toolCall.ToolCallId]; ok {
metadata = message.Metadata.Tool[toolCall.ToolCallId].(map[string]any) metadata = message.Metadata.Tool[toolCall.ToolCallId]
} }
var result *string var result *string
resultPart, resultError := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolResult() resultPart, resultError := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolResult()
@@ -215,14 +208,16 @@ func (m *messagesComponent) renderView() {
} }
error := "" error := ""
errorValue, _ := message.Metadata.Error.ValueByDiscriminator() if message.Metadata.Error != nil {
switch errorValue.(type) { errorValue, _ := message.Metadata.Error.ValueByDiscriminator()
case client.UnknownError: switch errorValue.(type) {
clientError := errorValue.(client.UnknownError) case client.UnknownError:
error = clientError.Data.Message clientError := errorValue.(client.UnknownError)
error = renderContentBlock(error, WithBorderColor(t.Error()), WithFullWidth(), WithPaddingTop(1), WithPaddingBottom(1)) error = clientError.Data.Message
blocks = append(blocks, error) error = renderContentBlock(error, WithBorderColor(t.Error()), WithFullWidth(), WithMarginTop(1), WithMarginBottom(1))
previousBlockType = errorBlock blocks = append(blocks, error)
previousBlockType = errorBlock
}
} }
} }
@@ -250,7 +245,7 @@ func (m *messagesComponent) header() string {
base := styles.BaseStyle().Render base := styles.BaseStyle().Render
muted := styles.Muted().Render muted := styles.Muted().Render
headerLines := []string{} headerLines := []string{}
headerLines = append(headerLines, toMarkdown("# "+m.app.Session.Title, width, t.Background())) headerLines = append(headerLines, toMarkdown("# "+m.app.Session.Title, width-6, t.Background()))
if m.app.Session.Share != nil && m.app.Session.Share.Url != "" { if m.app.Session.Share != nil && m.app.Session.Share.Url != "" {
headerLines = append(headerLines, muted(m.app.Session.Share.Url)) headerLines = append(headerLines, muted(m.app.Session.Share.Url))
} else { } else {
@@ -260,20 +255,26 @@ func (m *messagesComponent) header() string {
header = styles.BaseStyle(). header = styles.BaseStyle().
Width(width). Width(width).
PaddingTop(1). PaddingLeft(2).
BorderBottom(true). PaddingRight(2).
BorderForeground(t.BorderSubtle()). // Background(t.BackgroundElement()).
BorderStyle(lipgloss.NormalBorder()). BorderLeft(true).
Background(t.Background()). BorderRight(true).
BorderBackground(t.Background()).
BorderForeground(t.BackgroundSubtle()).
BorderStyle(lipgloss.ThickBorder()).
Render(header) Render(header)
return header return "\n" + header + "\n"
} }
func (m *messagesComponent) View() string { func (m *messagesComponent) View() string {
if len(m.app.Messages) == 0 || m.rendering { if len(m.app.Messages) == 0 {
return m.home() return m.home()
} }
if m.rendering {
return m.viewport.View()
}
return lipgloss.JoinVertical( return lipgloss.JoinVertical(
lipgloss.Left, lipgloss.Left,
lipgloss.PlaceHorizontal(m.width, lipgloss.Center, m.header()), lipgloss.PlaceHorizontal(m.width, lipgloss.Center, m.header()),
@@ -306,8 +307,8 @@ func (m *messagesComponent) home() string {
styles.Muted().Render(open), styles.Muted().Render(open),
styles.BaseStyle().Render(code), styles.BaseStyle().Render(code),
) )
cwd := app.Info.Path.Cwd // cwd := app.Info.Path.Cwd
config := app.Info.Path.Config // config := app.Info.Path.Config
commands := [][]string{ commands := [][]string{
{"/help", "show help"}, {"/help", "show help"},
@@ -334,9 +335,9 @@ func (m *messagesComponent) home() string {
lines = append(lines, "") lines = append(lines, "")
lines = append(lines, logoAndVersion) lines = append(lines, logoAndVersion)
lines = append(lines, "") lines = append(lines, "")
lines = append(lines, base("cwd ")+muted(cwd)) // lines = append(lines, base("cwd ")+muted(cwd))
lines = append(lines, base("config ")+muted(config)) // lines = append(lines, base("config ")+muted(config))
lines = append(lines, "") // lines = append(lines, "")
lines = append(lines, commandLines...) lines = append(lines, commandLines...)
lines = append(lines, "") lines = append(lines, "")
if m.rendering { if m.rendering {
@@ -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 { func NewMessagesComponent(app *app.App) layout.ModelWithView {
customSpinner := spinner.Spinner{ customSpinner := spinner.Spinner{
Frames: []string{" ", "┃", "┃"}, Frames: []string{" ", "┃", "┃"},
@@ -1,264 +0,0 @@
package dialog
import (
"fmt"
"github.com/charmbracelet/bubbles/v2/key"
"github.com/charmbracelet/bubbles/v2/textinput"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
type argumentsDialogKeyMap struct {
Enter key.Binding
Escape key.Binding
}
// ShortHelp implements key.Map.
func (k argumentsDialogKeyMap) ShortHelp() []key.Binding {
return []key.Binding{
key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "confirm"),
),
key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "cancel"),
),
}
}
// FullHelp implements key.Map.
func (k argumentsDialogKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{k.ShortHelp()}
}
// ShowMultiArgumentsDialogMsg is a message that is sent to show the multi-arguments dialog.
type ShowMultiArgumentsDialogMsg struct {
CommandID string
Content string
ArgNames []string
}
// CloseMultiArgumentsDialogMsg is a message that is sent when the multi-arguments dialog is closed.
type CloseMultiArgumentsDialogMsg struct {
Submit bool
CommandID string
Content string
Args map[string]string
}
// MultiArgumentsDialogCmp is a component that asks the user for multiple command arguments.
type MultiArgumentsDialogCmp struct {
width, height int
inputs []textinput.Model
focusIndex int
keys argumentsDialogKeyMap
commandID string
content string
argNames []string
}
// NewMultiArgumentsDialogCmp creates a new MultiArgumentsDialogCmp.
func NewMultiArgumentsDialogCmp(commandID, content string, argNames []string) MultiArgumentsDialogCmp {
t := theme.CurrentTheme()
inputs := make([]textinput.Model, len(argNames))
for i, name := range argNames {
ti := textinput.New()
ti.Placeholder = fmt.Sprintf("Enter value for %s...", name)
ti.SetWidth(40)
ti.Prompt = ""
ti.Styles.Blurred.Placeholder = ti.Styles.Blurred.Placeholder.Background(t.Background())
ti.Styles.Blurred.Text = ti.Styles.Blurred.Text.Background(t.Background())
ti.Styles.Blurred.Prompt = ti.Styles.Blurred.Prompt.Foreground(t.Primary())
ti.Styles.Focused.Placeholder = ti.Styles.Focused.Placeholder.Background(t.Background())
ti.Styles.Focused.Text = ti.Styles.Focused.Text.Background(t.Background())
ti.Styles.Focused.Prompt = ti.Styles.Focused.Prompt.Foreground(t.Primary())
// ti.PromptStyle = ti.PromptStyle.Background(t.Background())
// ti.TextStyle = ti.TextStyle.Background(t.Background())
// Only focus the first input initially
if i == 0 {
ti.Focus()
// ti.PromptStyle = ti.PromptStyle.Foreground(t.Primary())
// ti.TextStyle = ti.TextStyle.Foreground(t.Primary())
} else {
ti.Blur()
}
inputs[i] = ti
}
return MultiArgumentsDialogCmp{
inputs: inputs,
keys: argumentsDialogKeyMap{},
commandID: commandID,
content: content,
argNames: argNames,
focusIndex: 0,
}
}
// Init implements tea.Model.
func (m MultiArgumentsDialogCmp) Init() tea.Cmd {
// Make sure only the first input is focused
for i := range m.inputs {
if i == 0 {
m.inputs[i].Focus()
} else {
m.inputs[i].Blur()
}
}
return textinput.Blink
}
// Update implements tea.Model.
func (m MultiArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
// t := theme.CurrentTheme()
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
return m, util.CmdHandler(CloseMultiArgumentsDialogMsg{
Submit: false,
CommandID: m.commandID,
Content: m.content,
Args: nil,
})
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
// If we're on the last input, submit the form
if m.focusIndex == len(m.inputs)-1 {
args := make(map[string]string)
for i, name := range m.argNames {
args[name] = m.inputs[i].Value()
}
return m, util.CmdHandler(CloseMultiArgumentsDialogMsg{
Submit: true,
CommandID: m.commandID,
Content: m.content,
Args: args,
})
}
// Otherwise, move to the next input
m.inputs[m.focusIndex].Blur()
m.focusIndex++
m.inputs[m.focusIndex].Focus()
// m.inputs[m.focusIndex].PromptStyle = m.inputs[m.focusIndex].PromptStyle.Foreground(t.Primary())
// m.inputs[m.focusIndex].TextStyle = m.inputs[m.focusIndex].TextStyle.Foreground(t.Primary())
case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))):
// Move to the next input
m.inputs[m.focusIndex].Blur()
m.focusIndex = (m.focusIndex + 1) % len(m.inputs)
m.inputs[m.focusIndex].Focus()
// m.inputs[m.focusIndex].PromptStyle = m.inputs[m.focusIndex].PromptStyle.Foreground(t.Primary())
// m.inputs[m.focusIndex].TextStyle = m.inputs[m.focusIndex].TextStyle.Foreground(t.Primary())
case key.Matches(msg, key.NewBinding(key.WithKeys("shift+tab"))):
// Move to the previous input
m.inputs[m.focusIndex].Blur()
m.focusIndex = (m.focusIndex - 1 + len(m.inputs)) % len(m.inputs)
m.inputs[m.focusIndex].Focus()
// m.inputs[m.focusIndex].PromptStyle = m.inputs[m.focusIndex].PromptStyle.Foreground(t.Primary())
// m.inputs[m.focusIndex].TextStyle = m.inputs[m.focusIndex].TextStyle.Foreground(t.Primary())
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
}
// Update the focused input
var cmd tea.Cmd
m.inputs[m.focusIndex], cmd = m.inputs[m.focusIndex].Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
// View implements tea.Model.
func (m MultiArgumentsDialogCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
// Calculate width needed for content
maxWidth := 60 // Width for explanation text
title := lipgloss.NewStyle().
Foreground(t.Primary()).
Bold(true).
Width(maxWidth).
Padding(0, 1).
Background(t.Background()).
Render("Command Arguments")
explanation := lipgloss.NewStyle().
Foreground(t.Text()).
Width(maxWidth).
Padding(0, 1).
Background(t.Background()).
Render("This command requires multiple arguments. Please enter values for each:")
// Create input fields for each argument
inputFields := make([]string, len(m.inputs))
for i, input := range m.inputs {
// Highlight the label of the focused input
labelStyle := lipgloss.NewStyle().
Width(maxWidth).
Padding(1, 1, 0, 1).
Background(t.Background())
if i == m.focusIndex {
labelStyle = labelStyle.Foreground(t.Primary()).Bold(true)
} else {
labelStyle = labelStyle.Foreground(t.TextMuted())
}
label := labelStyle.Render(m.argNames[i] + ":")
field := lipgloss.NewStyle().
Foreground(t.Text()).
Width(maxWidth).
Padding(0, 1).
Background(t.Background()).
Render(input.View())
inputFields[i] = lipgloss.JoinVertical(lipgloss.Left, label, field)
}
maxWidth = min(maxWidth, m.width-10)
// Join all elements vertically
elements := []string{title, explanation}
elements = append(elements, inputFields...)
content := lipgloss.JoinVertical(
lipgloss.Left,
elements...,
)
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Background(t.Background()).
Width(lipgloss.Width(content) + 4).
Render(content)
}
// SetSize sets the size of the component.
func (m *MultiArgumentsDialogCmp) SetSize(width, height int) {
m.width = width
m.height = height
}
// Bindings implements layout.Bindings.
func (m MultiArgumentsDialogCmp) Bindings() []key.Binding {
return m.keys.ShortHelp()
}
@@ -1,180 +0,0 @@
package dialog
import (
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
utilComponents "github.com/sst/opencode/internal/components/util"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
// Command represents a command that can be executed
type Command struct {
ID string
Title string
Description string
Handler func(cmd Command) tea.Cmd
}
func (ci Command) Render(selected bool, width int) string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
descStyle := baseStyle.Width(width).Foreground(t.TextMuted())
itemStyle := baseStyle.Width(width).
Foreground(t.Text()).
Background(t.Background())
if selected {
itemStyle = itemStyle.
Background(t.Primary()).
Foreground(t.Background()).
Bold(true)
descStyle = descStyle.
Background(t.Primary()).
Foreground(t.Background())
}
title := itemStyle.Padding(0, 1).Render(ci.Title)
if ci.Description != "" {
description := descStyle.Padding(0, 1).Render(ci.Description)
return lipgloss.JoinVertical(lipgloss.Left, title, description)
}
return title
}
// CommandSelectedMsg is sent when a command is selected
type CommandSelectedMsg struct {
Command Command
}
// CloseCommandDialogMsg is sent when the command dialog is closed
type CloseCommandDialogMsg struct{}
// CommandDialog interface for the command selection dialog
type CommandDialog interface {
layout.ModelWithView
layout.Bindings
SetCommands(commands []Command)
}
type commandDialogComponent struct {
listView utilComponents.SimpleList[Command]
width int
height int
}
type commandKeyMap struct {
Enter key.Binding
Escape key.Binding
}
var commandKeys = commandKeyMap{
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "select command"),
),
Escape: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "close"),
),
}
func (c *commandDialogComponent) Init() tea.Cmd {
return c.listView.Init()
}
func (c *commandDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, commandKeys.Enter):
selectedItem, idx := c.listView.GetSelectedItem()
if idx != -1 {
return c, util.CmdHandler(CommandSelectedMsg{
Command: selectedItem,
})
}
case key.Matches(msg, commandKeys.Escape):
return c, util.CmdHandler(CloseCommandDialogMsg{})
}
case tea.WindowSizeMsg:
c.width = msg.Width
c.height = msg.Height
}
u, cmd := c.listView.Update(msg)
c.listView = u.(utilComponents.SimpleList[Command])
cmds = append(cmds, cmd)
return c, tea.Batch(cmds...)
}
func (c *commandDialogComponent) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
maxWidth := 40
commands := c.listView.GetItems()
for _, cmd := range commands {
if len(cmd.Title) > maxWidth-4 {
maxWidth = len(cmd.Title) + 4
}
if cmd.Description != "" {
if len(cmd.Description) > maxWidth-4 {
maxWidth = len(cmd.Description) + 4
}
}
}
c.listView.SetMaxWidth(maxWidth)
title := baseStyle.
Foreground(t.Primary()).
Bold(true).
Width(maxWidth).
Padding(0, 1).
Render("Commands")
content := lipgloss.JoinVertical(
lipgloss.Left,
title,
baseStyle.Width(maxWidth).Render(""),
baseStyle.Width(maxWidth).Render(c.listView.View()),
baseStyle.Width(maxWidth).Render(""),
)
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Width(lipgloss.Width(content) + 4).
Render(content)
}
func (c *commandDialogComponent) BindingKeys() []key.Binding {
return layout.KeyMapToSlice(commandKeys)
}
func (c *commandDialogComponent) SetCommands(commands []Command) {
c.listView.SetItems(commands)
}
// NewCommandDialogCmp creates a new command selection dialog
func NewCommandDialogCmp() CommandDialog {
listView := utilComponents.NewSimpleList[Command](
[]Command{},
10,
"No commands available",
true,
)
return &commandDialogComponent{
listView: listView,
}
}
@@ -5,7 +5,7 @@ import (
"github.com/charmbracelet/bubbles/v2/textarea" "github.com/charmbracelet/bubbles/v2/textarea"
tea "github.com/charmbracelet/bubbletea/v2" tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/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/layout"
"github.com/sst/opencode/internal/status" "github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/styles"
@@ -20,7 +20,7 @@ type CompletionItem struct {
} }
type CompletionItemI interface { type CompletionItemI interface {
utilComponents.SimpleListItem list.ListItem
GetValue() string GetValue() string
DisplayValue() string DisplayValue() string
} }
@@ -30,18 +30,18 @@ func (ci *CompletionItem) Render(selected bool, width int) string {
baseStyle := styles.BaseStyle() baseStyle := styles.BaseStyle()
itemStyle := baseStyle. itemStyle := baseStyle.
Background(t.BackgroundElement()).
Width(width). Width(width).
Padding(0, 1) Padding(0, 1)
if selected { if selected {
itemStyle = itemStyle. itemStyle = itemStyle.
Background(t.Background()).
Foreground(t.Primary()). Foreground(t.Primary()).
Bold(true) Bold(true)
} }
title := itemStyle.Render( title := itemStyle.Render(
ci.GetValue(), ci.DisplayValue(),
) )
return title return title
@@ -68,6 +68,7 @@ type CompletionProvider interface {
type CompletionSelectedMsg struct { type CompletionSelectedMsg struct {
SearchString string SearchString string
CompletionValue string CompletionValue string
IsCommand bool
} }
type CompletionDialogCompleteItemMsg struct { type CompletionDialogCompleteItemMsg struct {
@@ -78,8 +79,9 @@ type CompletionDialogCloseMsg struct{}
type CompletionDialog interface { type CompletionDialog interface {
layout.ModelWithView layout.ModelWithView
layout.Bindings
SetWidth(width int) SetWidth(width int)
IsEmpty() bool
SetProvider(provider CompletionProvider)
} }
type completionDialogComponent struct { type completionDialogComponent struct {
@@ -88,7 +90,7 @@ type completionDialogComponent struct {
width int width int
height int height int
pseudoSearchTextArea textarea.Model pseudoSearchTextArea textarea.Model
listView utilComponents.SimpleList[CompletionItemI] list list.List[CompletionItemI]
} }
type completionDialogKeyMap struct { type completionDialogKeyMap struct {
@@ -116,17 +118,21 @@ func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd {
return nil return nil
} }
// Check if this is a command completion
isCommand := c.completionProvider.GetId() == "commands"
return tea.Batch( return tea.Batch(
util.CmdHandler(CompletionSelectedMsg{ util.CmdHandler(CompletionSelectedMsg{
SearchString: value, SearchString: value,
CompletionValue: item.GetValue(), CompletionValue: item.GetValue(),
IsCommand: isCommand,
}), }),
c.close(), c.close(),
) )
} }
func (c *completionDialogComponent) close() tea.Cmd { func (c *completionDialogComponent) close() tea.Cmd {
c.listView.SetItems([]CompletionItemI{}) c.list.SetItems([]CompletionItemI{})
c.pseudoSearchTextArea.Reset() c.pseudoSearchTextArea.Reset()
c.pseudoSearchTextArea.Blur() c.pseudoSearchTextArea.Blur()
@@ -138,9 +144,7 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.KeyMsg: case tea.KeyMsg:
if c.pseudoSearchTextArea.Focused() { if c.pseudoSearchTextArea.Focused() {
if !key.Matches(msg, completionDialogKeys.Complete) { if !key.Matches(msg, completionDialogKeys.Complete) {
var cmd tea.Cmd var cmd tea.Cmd
c.pseudoSearchTextArea, cmd = c.pseudoSearchTextArea.Update(msg) c.pseudoSearchTextArea, cmd = c.pseudoSearchTextArea.Update(msg)
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
@@ -157,26 +161,23 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
status.Error(err.Error()) status.Error(err.Error())
} }
c.listView.SetItems(items) c.list.SetItems(items)
c.query = query c.query = query
} }
u, cmd := c.listView.Update(msg) u, cmd := c.list.Update(msg)
c.listView = u.(utilComponents.SimpleList[CompletionItemI]) c.list = u.(list.List[CompletionItemI])
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
} }
switch { switch {
case key.Matches(msg, completionDialogKeys.Complete): case key.Matches(msg, completionDialogKeys.Complete):
item, i := c.listView.GetSelectedItem() item, i := c.list.GetSelectedItem()
if i == -1 { if i == -1 {
return c, nil return c, nil
} }
return c, c.complete(item)
cmd := c.complete(item)
return c, cmd
case key.Matches(msg, completionDialogKeys.Cancel): case key.Matches(msg, completionDialogKeys.Cancel):
// Only close on backspace when there are no characters left // Only close on backspace when there are no characters left
if msg.String() != "backspace" || len(c.pseudoSearchTextArea.Value()) <= 0 { 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()) status.Error(err.Error())
} }
c.listView.SetItems(items) c.list.SetItems(items)
c.pseudoSearchTextArea.SetValue(msg.String()) c.pseudoSearchTextArea.SetValue(msg.String())
return c, c.pseudoSearchTextArea.Focus() return c, c.pseudoSearchTextArea.Focus()
} }
@@ -208,8 +209,7 @@ func (c *completionDialogComponent) View() string {
baseStyle := styles.BaseStyle() baseStyle := styles.BaseStyle()
maxWidth := 40 maxWidth := 40
completions := c.list.GetItems()
completions := c.listView.GetItems()
for _, cmd := range completions { for _, cmd := range completions {
title := cmd.DisplayValue() 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). return baseStyle.Padding(0, 0).
Border(lipgloss.NormalBorder()). Background(t.BackgroundElement()).
Border(lipgloss.ThickBorder()).
BorderTop(false).
BorderBottom(false). BorderBottom(false).
BorderRight(false). BorderRight(true).
BorderLeft(false). BorderLeft(true).
BorderBackground(t.Background()). BorderForeground(t.BackgroundSubtle()).
BorderForeground(t.TextMuted()).
Width(c.width). Width(c.width).
Render(c.listView.View()) Render(c.list.View())
} }
func (c *completionDialogComponent) SetWidth(width int) { func (c *completionDialogComponent) SetWidth(width int) {
c.width = width c.width = width
} }
func (c *completionDialogComponent) BindingKeys() []key.Binding { func (c *completionDialogComponent) IsEmpty() bool {
return layout.KeyMapToSlice(completionDialogKeys) 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 { func NewCompletionDialogComponent(completionProvider CompletionProvider) CompletionDialog {
@@ -247,10 +259,10 @@ func NewCompletionDialogComponent(completionProvider CompletionProvider) Complet
status.Error(err.Error()) status.Error(err.Error())
} }
li := utilComponents.NewSimpleList( li := list.NewListComponent(
items, items,
7, 7,
"No file matches found", "No matches",
false, false,
) )
@@ -258,6 +270,6 @@ func NewCompletionDialogComponent(completionProvider CompletionProvider) Complet
query: "", query: "",
completionProvider: completionProvider, completionProvider: completionProvider,
pseudoSearchTextArea: ti, pseudoSearchTextArea: ti,
listView: li, list: li,
} }
} }
@@ -1,155 +0,0 @@
package dialog
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/util"
)
// Command prefix constants
const (
UserCommandPrefix = "user:"
ProjectCommandPrefix = "project:"
)
// namedArgPattern is a regex pattern to find named arguments in the format $NAME
var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
// LoadCustomCommands loads custom commands from both XDG_CONFIG_HOME and project data directory
func LoadCustomCommands() ([]Command, error) {
var commands []Command
homeCommandsDir := filepath.Join(app.Info.Path.Config, "commands")
homeCommands, err := loadCommandsFromDir(homeCommandsDir, UserCommandPrefix)
if err != nil {
// Log error but continue - we'll still try to load other commands
fmt.Printf("Warning: failed to load home commands: %v\n", err)
} else {
commands = append(commands, homeCommands...)
}
projectCommandsDir := filepath.Join(app.Info.Path.Root, ".opencode", "commands")
projectCommands, err := loadCommandsFromDir(projectCommandsDir, ProjectCommandPrefix)
if err != nil {
// Log error but return what we have so far
fmt.Printf("Warning: failed to load project commands: %v\n", err)
} else {
commands = append(commands, projectCommands...)
}
return commands, nil
}
// loadCommandsFromDir loads commands from a specific directory with the given prefix
func loadCommandsFromDir(commandsDir string, prefix string) ([]Command, error) {
// Check if the commands directory exists
if _, err := os.Stat(commandsDir); os.IsNotExist(err) {
// Create the commands directory if it doesn't exist
if err := os.MkdirAll(commandsDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create commands directory %s: %w", commandsDir, err)
}
// Return empty list since we just created the directory
return []Command{}, nil
}
var commands []Command
// Walk through the commands directory and load all .md files
err := filepath.Walk(commandsDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Skip directories
if info.IsDir() {
return nil
}
// Only process markdown files
if !strings.HasSuffix(strings.ToLower(info.Name()), ".md") {
return nil
}
// Read the file content
content, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read command file %s: %w", path, err)
}
// Get the command ID from the file name without the .md extension
commandID := strings.TrimSuffix(info.Name(), filepath.Ext(info.Name()))
// Get relative path from commands directory
relPath, err := filepath.Rel(commandsDir, path)
if err != nil {
return fmt.Errorf("failed to get relative path for %s: %w", path, err)
}
// Create the command ID from the relative path
// Replace directory separators with colons
commandIDPath := strings.ReplaceAll(filepath.Dir(relPath), string(filepath.Separator), ":")
if commandIDPath != "." {
commandID = commandIDPath + ":" + commandID
}
// Create a command
command := Command{
ID: prefix + commandID,
Title: prefix + commandID,
Description: fmt.Sprintf("Custom command from %s", relPath),
Handler: func(cmd Command) tea.Cmd {
commandContent := string(content)
// Check for named arguments
matches := namedArgPattern.FindAllStringSubmatch(commandContent, -1)
if len(matches) > 0 {
// Extract unique argument names
argNames := make([]string, 0)
argMap := make(map[string]bool)
for _, match := range matches {
argName := match[1] // Group 1 is the name without $
if !argMap[argName] {
argMap[argName] = true
argNames = append(argNames, argName)
}
}
// Show multi-arguments dialog for all named arguments
return util.CmdHandler(ShowMultiArgumentsDialogMsg{
CommandID: cmd.ID,
Content: commandContent,
ArgNames: argNames,
})
}
// No arguments needed, run command directly
return util.CmdHandler(CommandRunCustomMsg{
Content: commandContent,
Args: nil, // No arguments
})
},
}
commands = append(commands, command)
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to load custom commands from %s: %w", commandsDir, err)
}
return commands, nil
}
// CommandRunCustomMsg is sent when a custom command is executed
type CommandRunCustomMsg struct {
Content string
Args map[string]string // Map of argument names to values
}
@@ -1,106 +0,0 @@
package dialog
import (
"testing"
"regexp"
)
func TestNamedArgPattern(t *testing.T) {
testCases := []struct {
input string
expected []string
}{
{
input: "This is a test with $ARGUMENTS placeholder",
expected: []string{"ARGUMENTS"},
},
{
input: "This is a test with $FOO and $BAR placeholders",
expected: []string{"FOO", "BAR"},
},
{
input: "This is a test with $FOO_BAR and $BAZ123 placeholders",
expected: []string{"FOO_BAR", "BAZ123"},
},
{
input: "This is a test with no placeholders",
expected: []string{},
},
{
input: "This is a test with $FOO appearing twice: $FOO",
expected: []string{"FOO"},
},
{
input: "This is a test with $1INVALID placeholder",
expected: []string{},
},
}
for _, tc := range testCases {
matches := namedArgPattern.FindAllStringSubmatch(tc.input, -1)
// Extract unique argument names
argNames := make([]string, 0)
argMap := make(map[string]bool)
for _, match := range matches {
argName := match[1] // Group 1 is the name without $
if !argMap[argName] {
argMap[argName] = true
argNames = append(argNames, argName)
}
}
// Check if we got the expected number of arguments
if len(argNames) != len(tc.expected) {
t.Errorf("Expected %d arguments, got %d for input: %s", len(tc.expected), len(argNames), tc.input)
continue
}
// Check if we got the expected argument names
for _, expectedArg := range tc.expected {
found := false
for _, actualArg := range argNames {
if actualArg == expectedArg {
found = true
break
}
}
if !found {
t.Errorf("Expected argument %s not found in %v for input: %s", expectedArg, argNames, tc.input)
}
}
}
}
func TestRegexPattern(t *testing.T) {
pattern := regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
validMatches := []string{
"$FOO",
"$BAR",
"$FOO_BAR",
"$BAZ123",
"$ARGUMENTS",
}
invalidMatches := []string{
"$foo",
"$1BAR",
"$_FOO",
"FOO",
"$",
}
for _, valid := range validMatches {
if !pattern.MatchString(valid) {
t.Errorf("Expected %s to match, but it didn't", valid)
}
}
for _, invalid := range invalidMatches {
if pattern.MatchString(invalid) {
t.Errorf("Expected %s not to match, but it did", invalid)
}
}
}
@@ -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 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 { func (h *helpDialog) View() string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
keyStyle := lipgloss.NewStyle(). keyStyle := lipgloss.NewStyle().
@@ -173,11 +173,6 @@ func (m *InitDialogCmp) SetSize(width, height int) {
m.height = height 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. // CloseInitDialogMsg is a message that is sent when the init dialog is closed.
type CloseInitDialogMsg struct { type CloseInitDialogMsg struct {
Initialize bool Initialize bool
@@ -112,8 +112,14 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
case key.Matches(msg, modelKeys.Enter): case key.Matches(msg, modelKeys.Enter):
models := m.models() models := m.models()
cmd := util.CmdHandler(state.ModelSelectedMsg{Provider: m.provider, Model: models[m.selectedIdx]}) return m, tea.Sequence(
return m, tea.Batch(cmd, util.CmdHandler(modal.CloseModalMsg{})) util.CmdHandler(modal.CloseModalMsg{}),
util.CmdHandler(
state.ModelSelectedMsg{
Provider: m.provider,
Model: models[m.selectedIdx],
}),
)
case key.Matches(msg, modelKeys.Escape): case key.Matches(msg, modelKeys.Escape):
return m, util.CmdHandler(modal.CloseModalMsg{}) return m, util.CmdHandler(modal.CloseModalMsg{})
} }
@@ -125,9 +131,9 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
func (m *modelDialog) models() []client.ProviderModel { func (m *modelDialog) models() []client.ModelInfo {
models := slices.SortedFunc(maps.Values(m.provider.Models), func(a, b client.ProviderModel) int { models := slices.SortedFunc(maps.Values(m.provider.Models), func(a, b client.ModelInfo) int {
return strings.Compare(*a.Name, *b.Name) return strings.Compare(a.Name, b.Name)
}) })
return models return models
} }
@@ -205,7 +211,7 @@ func (m *modelDialog) View() string {
Foreground(t.BackgroundElement()). Foreground(t.BackgroundElement()).
Bold(true) Bold(true)
} }
modelItems = append(modelItems, itemStyle.Render(*models[i].Name)) modelItems = append(modelItems, itemStyle.Render(models[i].Name))
} }
scrollIndicator := m.getScrollIndicators(maxDialogWidth) scrollIndicator := m.getScrollIndicators(maxDialogWidth)
@@ -31,7 +31,6 @@ type PermissionResponseMsg struct {
// PermissionDialogComponent interface for permission dialog component // PermissionDialogComponent interface for permission dialog component
type PermissionDialogComponent interface { type PermissionDialogComponent interface {
layout.ModelWithView layout.ModelWithView
layout.Bindings
// SetPermissions(permission permission.PermissionRequest) tea.Cmd // SetPermissions(permission permission.PermissionRequest) tea.Cmd
} }
@@ -424,10 +423,6 @@ func (p *permissionDialogComponent) View() string {
return p.render() return p.render()
} }
func (p *permissionDialogComponent) BindingKeys() []key.Binding {
return layout.KeyMapToSlice(permissionsKeys)
}
func (p *permissionDialogComponent) SetSize() tea.Cmd { func (p *permissionDialogComponent) SetSize() tea.Cmd {
// if p.permission.ID == "" { // if p.permission.ID == "" {
// return nil // return nil
@@ -73,15 +73,11 @@ func (q *quitDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if !q.selectedNo { if !q.selectedNo {
return q, tea.Quit return q, tea.Quit
} }
return q, tea.Batch( return q, util.CmdHandler(modal.CloseModalMsg{})
util.CmdHandler(modal.CloseModalMsg{}),
)
case key.Matches(msg, helpKeys.Yes): case key.Matches(msg, helpKeys.Yes):
return q, tea.Quit return q, tea.Quit
case key.Matches(msg, helpKeys.No): case key.Matches(msg, helpKeys.No):
return q, tea.Batch( return q, util.CmdHandler(modal.CloseModalMsg{})
util.CmdHandler(modal.CloseModalMsg{}),
)
} }
} }
return q, nil return q, nil
@@ -5,8 +5,8 @@ import (
tea "github.com/charmbracelet/bubbletea/v2" tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/components/modal" "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/layout"
"github.com/sst/opencode/internal/state" "github.com/sst/opencode/internal/state"
"github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/styles"
@@ -48,7 +48,7 @@ type sessionDialog struct {
height int height int
modal *modal.Modal modal *modal.Modal
selectedSessionID string selectedSessionID string
list components.SimpleList[sessionItem] list list.List[sessionItem]
} }
func (s *sessionDialog) Init() tea.Cmd { func (s *sessionDialog) Init() tea.Cmd {
@@ -67,9 +67,9 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if item, idx := s.list.GetSelectedItem(); idx >= 0 { if item, idx := s.list.GetSelectedItem(); idx >= 0 {
selectedSession := item.session selectedSession := item.session
s.selectedSessionID = selectedSession.Id s.selectedSessionID = selectedSession.Id
return s, tea.Batch( return s, tea.Sequence(
util.CmdHandler(state.SessionSelectedMsg(&selectedSession)),
util.CmdHandler(modal.CloseModalMsg{}), util.CmdHandler(modal.CloseModalMsg{}),
util.CmdHandler(state.SessionSelectedMsg(&selectedSession)),
) )
} }
} }
@@ -77,7 +77,7 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd var cmd tea.Cmd
listModel, cmd := s.list.Update(msg) listModel, cmd := s.list.Update(msg)
s.list = listModel.(components.SimpleList[sessionItem]) s.list = listModel.(list.List[sessionItem])
return s, cmd return s, cmd
} }
@@ -98,7 +98,7 @@ func NewSessionDialog(app *app.App) SessionDialog {
sessionItems = append(sessionItems, sessionItem{session: sess}) sessionItems = append(sessionItems, sessionItem{session: sess})
} }
list := components.NewSimpleList( list := list.NewListComponent(
sessionItems, sessionItems,
10, // maxVisibleSessions 10, // maxVisibleSessions
"No sessions available", "No sessions available",
@@ -2,8 +2,8 @@ package dialog
import ( import (
tea "github.com/charmbracelet/bubbletea/v2" tea "github.com/charmbracelet/bubbletea/v2"
list "github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/components/modal" "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/layout"
"github.com/sst/opencode/internal/status" "github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/styles"
@@ -49,7 +49,7 @@ type themeDialog struct {
height int height int
modal *modal.Modal modal *modal.Modal
list components.SimpleList[themeItem] list list.List[themeItem]
} }
func (t *themeDialog) Init() tea.Cmd { func (t *themeDialog) Init() tea.Cmd {
@@ -74,9 +74,9 @@ func (t *themeDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
status.Error(err.Error()) status.Error(err.Error())
return t, nil return t, nil
} }
return t, tea.Batch( return t, tea.Sequence(
util.CmdHandler(ThemeChangedMsg{ThemeName: selectedTheme}),
util.CmdHandler(modal.CloseModalMsg{}), util.CmdHandler(modal.CloseModalMsg{}),
util.CmdHandler(ThemeChangedMsg{ThemeName: selectedTheme}),
) )
} }
} }
@@ -84,7 +84,7 @@ func (t *themeDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd var cmd tea.Cmd
listModel, cmd := t.list.Update(msg) listModel, cmd := t.list.Update(msg)
t.list = listModel.(components.SimpleList[themeItem]) t.list = listModel.(list.List[themeItem])
return t, cmd return t, cmd
} }
@@ -110,7 +110,7 @@ func NewThemeDialog() ThemeDialog {
} }
} }
list := components.NewSimpleList( list := list.NewListComponent(
themeItems, themeItems,
10, // maxVisibleThemes 10, // maxVisibleThemes
"No themes available", "No themes available",
@@ -125,4 +125,3 @@ func NewThemeDialog() ThemeDialog {
modal: modal.New(modal.WithTitle("Select Theme"), modal.WithMaxWidth(40)), modal: modal.New(modal.WithTitle("Select Theme"), modal.WithMaxWidth(40)),
} }
} }
@@ -1,178 +0,0 @@
package dialog
import (
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
utilComponents "github.com/sst/opencode/internal/components/util"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
const (
maxToolsDialogWidth = 60
maxVisibleTools = 15
)
// ToolsDialog interface for the tools list dialog
type ToolsDialog interface {
layout.ModelWithView
layout.Bindings
SetTools(tools []string)
}
// ShowToolsDialogMsg is sent to show the tools dialog
type ShowToolsDialogMsg struct {
Show bool
}
// CloseToolsDialogMsg is sent when the tools dialog is closed
type CloseToolsDialogMsg struct{}
type toolItem struct {
name string
}
func (t toolItem) Render(selected bool, width int) string {
th := theme.CurrentTheme()
baseStyle := styles.BaseStyle().
Width(width).
Background(th.Background())
if selected {
baseStyle = baseStyle.
Background(th.Primary()).
Foreground(th.Background()).
Bold(true)
} else {
baseStyle = baseStyle.
Foreground(th.Text())
}
return baseStyle.Render(t.name)
}
type toolsDialogComponent struct {
tools []toolItem
width int
height int
list utilComponents.SimpleList[toolItem]
}
type toolsKeyMap struct {
Up key.Binding
Down key.Binding
Escape key.Binding
J key.Binding
K key.Binding
}
var toolsKeys = toolsKeyMap{
Up: key.NewBinding(
key.WithKeys("up"),
key.WithHelp("↑", "previous tool"),
),
Down: key.NewBinding(
key.WithKeys("down"),
key.WithHelp("↓", "next tool"),
),
Escape: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "close"),
),
J: key.NewBinding(
key.WithKeys("j"),
key.WithHelp("j", "next tool"),
),
K: key.NewBinding(
key.WithKeys("k"),
key.WithHelp("k", "previous tool"),
),
}
func (m *toolsDialogComponent) Init() tea.Cmd {
return nil
}
func (m *toolsDialogComponent) SetTools(tools []string) {
var toolItems []toolItem
for _, name := range tools {
toolItems = append(toolItems, toolItem{name: name})
}
m.tools = toolItems
m.list.SetItems(toolItems)
}
func (m *toolsDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, toolsKeys.Escape):
return m, func() tea.Msg { return CloseToolsDialogMsg{} }
// Pass other key messages to the list component
default:
var cmd tea.Cmd
listModel, cmd := m.list.Update(msg)
m.list = listModel.(utilComponents.SimpleList[toolItem])
return m, cmd
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
}
// For non-key messages
var cmd tea.Cmd
listModel, cmd := m.list.Update(msg)
m.list = listModel.(utilComponents.SimpleList[toolItem])
return m, cmd
}
func (m *toolsDialogComponent) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle().Background(t.Background())
title := baseStyle.
Foreground(t.Primary()).
Bold(true).
Width(maxToolsDialogWidth).
Padding(0, 0, 1).
Render("Available Tools")
// Calculate dialog width based on content
dialogWidth := min(maxToolsDialogWidth, m.width/2)
m.list.SetMaxWidth(dialogWidth)
content := lipgloss.JoinVertical(
lipgloss.Left,
title,
m.list.View(),
)
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Background(t.Background()).
Width(lipgloss.Width(content) + 4).
Render(content)
}
func (m *toolsDialogComponent) BindingKeys() []key.Binding {
return layout.KeyMapToSlice(toolsKeys)
}
func NewToolsDialogCmp() ToolsDialog {
list := utilComponents.NewSimpleList[toolItem](
[]toolItem{},
maxVisibleTools,
"No tools available",
true,
)
return &toolsDialogComponent{
list: list,
}
}
+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 // Diff Parsing
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -642,6 +676,101 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
return sb.String() 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 // renderDiffColumnLine is a helper function that handles the common logic for rendering diff columns
func renderDiffColumnLine( func renderDiffColumnLine(
fileName string, fileName string,
@@ -661,7 +790,6 @@ func renderDiffColumnLine(
var marker string var marker string
var bgStyle lipgloss.Style var bgStyle lipgloss.Style
var lineNum string var lineNum string
var highlightType LineType
var highlightColor compat.AdaptiveColor var highlightColor compat.AdaptiveColor
if isLeftColumn { if isLeftColumn {
@@ -671,7 +799,6 @@ func renderDiffColumnLine(
marker = "-" marker = "-"
bgStyle = removedLineStyle bgStyle = removedLineStyle
lineNumberStyle = lineNumberStyle.Foreground(t.DiffRemoved()).Background(t.DiffRemovedLineNumberBg()) lineNumberStyle = lineNumberStyle.Foreground(t.DiffRemoved()).Background(t.DiffRemovedLineNumberBg())
highlightType = LineRemoved
highlightColor = t.DiffHighlightRemoved() highlightColor = t.DiffHighlightRemoved()
case LineAdded: case LineAdded:
marker = "?" marker = "?"
@@ -692,7 +819,6 @@ func renderDiffColumnLine(
marker = "+" marker = "+"
bgStyle = addedLineStyle bgStyle = addedLineStyle
lineNumberStyle = lineNumberStyle.Foreground(t.DiffAdded()).Background(t.DiffAddedLineNumberBg()) lineNumberStyle = lineNumberStyle.Foreground(t.DiffAdded()).Background(t.DiffAddedLineNumberBg())
highlightType = LineAdded
highlightColor = t.DiffHighlightAdded() highlightColor = t.DiffHighlightAdded()
case LineRemoved: case LineRemoved:
marker = "?" 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 // Create the line prefix
prefix := lineNumberStyle.Render(lineNum + " " + styledMarker) prefix := renderLinePrefix(*dl, lineNum, marker, lineNumberStyle, t)
// Apply syntax highlighting // Determine if we should render content
content := highlightLine(fileName, dl.Content, bgStyle.GetBackground()) shouldRenderContent := (dl.Kind == LineRemoved && isLeftColumn) ||
(dl.Kind == LineAdded && !isLeftColumn) ||
dl.Kind == LineContext
// Apply intra-line highlighting if needed if !shouldRenderContent {
if (dl.Kind == LineRemoved && isLeftColumn || dl.Kind == LineAdded && !isLeftColumn) && len(dl.Segments) > 0 { return bgStyle.Width(colWidth).Render("")
content = applyHighlighting(content, dl.Segments, highlightType, highlightColor)
} }
// Add a padding space for added/removed lines // Render the content
if (dl.Kind == LineRemoved && isLeftColumn) || (dl.Kind == LineAdded && !isLeftColumn) { prefixWidth := ansi.StringWidth(prefix)
content = bgStyle.Render(" ") + content contentWidth := colWidth - prefixWidth
} content := renderLineContent(fileName, *dl, bgStyle, highlightColor, contentWidth, t)
// Create the final line and truncate if needed return prefix + content
lineText := prefix + content
return bgStyle.MaxHeight(1).Width(colWidth).Render(
ansi.Truncate(
lineText,
colWidth,
lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.TextMuted()).Render("..."),
),
)
} }
// renderLeftColumn formats the left side of a side-by-side diff // 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 // 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 // RenderSideBySideHunk formats a hunk for side-by-side display
func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) string { func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) string {
// Apply options to create the configuration // Apply options to create the configuration
@@ -792,6 +919,21 @@ func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) str
return sb.String() 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 // FormatDiff creates a side-by-side formatted view of a diff
func FormatDiff(filename string, diffText string, opts ...SideBySideOption) (string, error) { func FormatDiff(filename string, diffText string, opts ...SideBySideOption) (string, error) {
// t := theme.CurrentTheme() // t := theme.CurrentTheme()
@@ -1,29 +1,27 @@
package utilComponents package list
import ( import (
"github.com/charmbracelet/bubbles/v2/key" "github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2" tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/layout" "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 Render(selected bool, width int) string
} }
type SimpleList[T SimpleListItem] interface { type List[T ListItem] interface {
layout.ModelWithView layout.ModelWithView
layout.Bindings
SetMaxWidth(maxWidth int) SetMaxWidth(maxWidth int)
GetSelectedItem() (item T, idx int) GetSelectedItem() (item T, idx int)
SetItems(items []T) SetItems(items []T)
GetItems() []T GetItems() []T
SetSelectedIndex(idx int) SetSelectedIndex(idx int)
IsEmpty() bool
} }
type simpleListComponent[T SimpleListItem] struct { type listComponent[T ListItem] struct {
fallbackMsg string fallbackMsg string
items []T items []T
selectedIdx int selectedIdx int
@@ -34,14 +32,14 @@ type simpleListComponent[T SimpleListItem] struct {
height int height int
} }
type simpleListKeyMap struct { type listKeyMap struct {
Up key.Binding Up key.Binding
Down key.Binding Down key.Binding
UpAlpha key.Binding UpAlpha key.Binding
DownAlpha key.Binding DownAlpha key.Binding
} }
var simpleListKeys = simpleListKeyMap{ var simpleListKeys = listKeyMap{
Up: key.NewBinding( Up: key.NewBinding(
key.WithKeys("up"), key.WithKeys("up"),
key.WithHelp("↑", "previous list item"), 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 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) { switch msg := msg.(type) {
case tea.KeyMsg: case tea.KeyMsg:
switch { switch {
@@ -84,11 +82,7 @@ func (c *simpleListComponent[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return c, nil return c, nil
} }
func (c *simpleListComponent[T]) BindingKeys() []key.Binding { func (c *listComponent[T]) GetSelectedItem() (T, int) {
return layout.KeyMapToSlice(simpleListKeys)
}
func (c *simpleListComponent[T]) GetSelectedItem() (T, int) {
if len(c.items) > 0 { if len(c.items) > 0 {
return c.items[c.selectedIdx], c.selectedIdx return c.items[c.selectedIdx], c.selectedIdx
} }
@@ -97,40 +91,37 @@ func (c *simpleListComponent[T]) GetSelectedItem() (T, int) {
return zero, -1 return zero, -1
} }
func (c *simpleListComponent[T]) SetItems(items []T) { func (c *listComponent[T]) SetItems(items []T) {
c.selectedIdx = 0 c.selectedIdx = 0
c.items = items c.items = items
} }
func (c *simpleListComponent[T]) GetItems() []T { func (c *listComponent[T]) GetItems() []T {
return c.items 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 c.maxWidth = width
} }
func (c *simpleListComponent[T]) SetSelectedIndex(idx int) { func (c *listComponent[T]) SetSelectedIndex(idx int) {
if idx >= 0 && idx < len(c.items) { if idx >= 0 && idx < len(c.items) {
c.selectedIdx = idx c.selectedIdx = idx
} }
} }
func (c *simpleListComponent[T]) View() string { func (c *listComponent[T]) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
items := c.items items := c.items
maxWidth := c.maxWidth maxWidth := c.maxWidth
maxVisibleItems := min(c.maxVisibleItems, len(items)) maxVisibleItems := min(c.maxVisibleItems, len(items))
startIdx := 0 startIdx := 0
if len(items) <= 0 { if len(items) <= 0 {
return baseStyle. return c.fallbackMsg
Background(t.Background()).
Padding(0, 1).
Width(maxWidth).
Render(c.fallbackMsg)
} }
if len(items) > maxVisibleItems { if len(items) > maxVisibleItems {
@@ -155,8 +146,8 @@ func (c *simpleListComponent[T]) View() string {
return lipgloss.JoinVertical(lipgloss.Left, listItems...) return lipgloss.JoinVertical(lipgloss.Left, listItems...)
} }
func NewSimpleList[T SimpleListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) SimpleList[T] { func NewListComponent[T ListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) List[T] {
return &simpleListComponent[T]{ return &listComponent[T]{
fallbackMsg: fallbackMsg, fallbackMsg: fallbackMsg,
items: items, items: items,
maxVisibleItems: maxVisibleItems, maxVisibleItems: maxVisibleItems,
+3 -3
View File
@@ -10,9 +10,9 @@ import (
) )
type Config struct { type Config struct {
Theme string `toml:"Theme"` Theme string `toml:"theme"`
Provider string `toml:"Provider"` Provider string `toml:"provider"`
Model string `toml:"Model"` Model string `toml:"model"`
} }
// NewConfig creates a new Config instance with default values. // NewConfig creates a new Config instance with default values.
-163
View File
@@ -1,163 +0,0 @@
package fileutil
import (
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"github.com/bmatcuk/doublestar/v4"
"github.com/sst/opencode/internal/status"
)
var (
rgPath string
fzfPath string
)
func Init() {
var err error
rgPath, err = exec.LookPath("rg")
if err != nil {
status.Warn("Ripgrep (rg) not found in $PATH. Some features might be limited or slower.")
rgPath = ""
}
fzfPath, err = exec.LookPath("fzf")
if err != nil {
status.Warn("FZF not found in $PATH. Some features might be limited or slower.")
fzfPath = ""
}
}
func GetRgCmd(globPattern string) *exec.Cmd {
if rgPath == "" {
return nil
}
rgArgs := []string{
"--files",
"-L",
"--null",
}
if globPattern != "" {
if !filepath.IsAbs(globPattern) && !strings.HasPrefix(globPattern, "/") {
globPattern = "/" + globPattern
}
rgArgs = append(rgArgs, "--glob", globPattern)
}
cmd := exec.Command(rgPath, rgArgs...)
cmd.Dir = "."
return cmd
}
func GetFzfCmd(query string) *exec.Cmd {
if fzfPath == "" {
return nil
}
fzfArgs := []string{
"--filter",
query,
"--read0",
"--print0",
}
cmd := exec.Command(fzfPath, fzfArgs...)
cmd.Dir = "."
return cmd
}
type FileInfo struct {
Path string
ModTime time.Time
}
func SkipHidden(path string) bool {
// Check for hidden files (starting with a dot)
base := filepath.Base(path)
if base != "." && strings.HasPrefix(base, ".") {
return true
}
commonIgnoredDirs := map[string]bool{
".opencode": true,
"node_modules": true,
"vendor": true,
"dist": true,
"build": true,
"target": true,
".git": true,
".idea": true,
".vscode": true,
"__pycache__": true,
"bin": true,
"obj": true,
"out": true,
"coverage": true,
"tmp": true,
"temp": true,
"logs": true,
"generated": true,
"bower_components": true,
"jspm_packages": true,
}
parts := strings.Split(path, string(os.PathSeparator))
for _, part := range parts {
if commonIgnoredDirs[part] {
return true
}
}
return false
}
func GlobWithDoublestar(pattern, searchPath string, limit int) ([]string, bool, error) {
fsys := os.DirFS(searchPath)
relPattern := strings.TrimPrefix(pattern, "/")
var matches []FileInfo
err := doublestar.GlobWalk(fsys, relPattern, func(path string, d fs.DirEntry) error {
if d.IsDir() {
return nil
}
if SkipHidden(path) {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
absPath := path
if !strings.HasPrefix(absPath, searchPath) && searchPath != "." {
absPath = filepath.Join(searchPath, absPath)
} else if !strings.HasPrefix(absPath, "/") && searchPath == "." {
absPath = filepath.Join(searchPath, absPath) // Ensure relative paths are joined correctly
}
matches = append(matches, FileInfo{Path: absPath, ModTime: info.ModTime()})
if limit > 0 && len(matches) >= limit*2 {
return fs.SkipAll
}
return nil
})
if err != nil {
return nil, false, fmt.Errorf("glob walk error: %w", err)
}
sort.Slice(matches, func(i, j int) bool {
return matches[i].ModTime.After(matches[j].ModTime)
})
truncated := false
if limit > 0 && len(matches) > limit {
matches = matches[:limit]
truncated = true
}
results := make([]string, len(matches))
for i, m := range matches {
results[i] = m.Path
}
return results, truncated, nil
}
+15 -10
View File
@@ -1,7 +1,6 @@
package layout package layout
import ( import (
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2" tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/theme"
@@ -15,16 +14,19 @@ type ModelWithView interface {
type Container interface { type Container interface {
ModelWithView ModelWithView
Sizeable Sizeable
Bindings
Focus() Focus()
Blur() Blur()
MaxWidth() int MaxWidth() int
Alignment() lipgloss.Position Alignment() lipgloss.Position
GetPosition() (x, y int)
GetContent() ModelWithView
} }
type container struct { type container struct {
width int width int
height int height int
x int
y int
content ModelWithView content ModelWithView
@@ -142,7 +144,7 @@ func (c *container) SetSize(width, height int) tea.Cmd {
} }
func (c *container) GetSize() (int, int) { func (c *container) GetSize() (int, int) {
return c.width, c.height return min(c.width, c.maxWidth), c.height
} }
func (c *container) MaxWidth() int { func (c *container) MaxWidth() int {
@@ -153,13 +155,6 @@ func (c *container) Alignment() lipgloss.Position {
return c.align 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 // Focus sets the container as focused
func (c *container) Focus() { func (c *container) Focus() {
c.focused = true 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) type ContainerOption func(*container)
func NewContainer(content ModelWithView, options ...ContainerOption) Container { func NewContainer(content ModelWithView, options ...ContainerOption) Container {
+40 -14
View File
@@ -1,7 +1,6 @@
package layout package layout
import ( import (
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2" tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/lipgloss/v2"
) )
@@ -27,7 +26,6 @@ func FlexPaneSizeFixed(size int) FlexPaneSize {
type FlexLayout interface { type FlexLayout interface {
ModelWithView ModelWithView
Sizeable Sizeable
Bindings
SetPanes(panes []Container) tea.Cmd SetPanes(panes []Container) tea.Cmd
SetPaneSizes(sizes []FlexPaneSize) tea.Cmd SetPaneSizes(sizes []FlexPaneSize) tea.Cmd
SetDirection(direction FlexDirection) tea.Cmd SetDirection(direction FlexDirection) tea.Cmd
@@ -161,11 +159,51 @@ func (f *flexLayout) SetSize(width, height int) tea.Cmd {
f.height = height f.height = height
var cmds []tea.Cmd var cmds []tea.Cmd
currentX, currentY := 0, 0
for i, pane := range f.panes { for i, pane := range f.panes {
if pane != nil { if pane != nil {
paneWidth, paneHeight := f.calculatePaneSize(i) 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) cmd := pane.SetSize(paneWidth, paneHeight)
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
// Update position for next pane
if f.direction == FlexDirectionHorizontal {
currentX += paneWidth
} else {
currentY += paneHeight
}
} }
} }
return tea.Batch(cmds...) return tea.Batch(cmds...)
@@ -199,18 +237,6 @@ func (f *flexLayout) SetDirection(direction FlexDirection) tea.Cmd {
return nil 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 { func NewFlexLayout(options ...FlexLayoutOption) FlexLayout {
layout := &flexLayout{ layout := &flexLayout{
direction: FlexDirectionHorizontal, direction: FlexDirectionHorizontal,
-12
View File
@@ -11,7 +11,6 @@ var Current *LayoutInfo
func init() { func init() {
Current = &LayoutInfo{ Current = &LayoutInfo{
Size: LayoutSizeNormal,
Viewport: Dimensions{Width: 80, Height: 25}, Viewport: Dimensions{Width: 80, Height: 25},
Container: Dimensions{Width: 80, Height: 25}, Container: Dimensions{Width: 80, Height: 25},
} }
@@ -19,19 +18,12 @@ func init() {
type LayoutSize string type LayoutSize string
const (
LayoutSizeSmall LayoutSize = "small"
LayoutSizeNormal LayoutSize = "normal"
LayoutSizeLarge LayoutSize = "large"
)
type Dimensions struct { type Dimensions struct {
Width int Width int
Height int Height int
} }
type LayoutInfo struct { type LayoutInfo struct {
Size LayoutSize
Viewport Dimensions Viewport Dimensions
Container Dimensions Container Dimensions
} }
@@ -53,10 +45,6 @@ type Sizeable interface {
GetSize() (int, int) GetSize() (int, int)
} }
type Bindings interface {
BindingKeys() []key.Binding
}
func KeyMapToSlice(t any) (bindings []key.Binding) { func KeyMapToSlice(t any) (bindings []key.Binding) {
typ := reflect.TypeOf(t) typ := reflect.TypeOf(t)
if typ.Kind() != reflect.Struct { if typ.Kind() != reflect.Struct {
+27 -48
View File
@@ -2,7 +2,6 @@ package page
import ( import (
"context" "context"
"strings"
"github.com/charmbracelet/bubbles/v2/key" "github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2" tea "github.com/charmbracelet/bubbletea/v2"
@@ -12,7 +11,6 @@ import (
"github.com/sst/opencode/internal/components/chat" "github.com/sst/opencode/internal/components/chat"
"github.com/sst/opencode/internal/components/dialog" "github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/layout" "github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/util" "github.com/sst/opencode/internal/util"
) )
@@ -24,6 +22,7 @@ type chatPage struct {
messages layout.Container messages layout.Container
layout layout.FlexLayout layout layout.FlexLayout
completionDialog dialog.CompletionDialog completionDialog dialog.CompletionDialog
completionManager *completions.CompletionManager
showCompletionDialog bool showCompletionDialog bool
} }
@@ -63,36 +62,13 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmd := p.layout.SetSize(msg.Width, msg.Height) cmd := p.layout.SetSize(msg.Width, msg.Height)
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
case chat.SendMsg: case chat.SendMsg:
p.showCompletionDialog = false
cmd := p.sendMessage(msg.Text, msg.Attachments) cmd := p.sendMessage(msg.Text, msg.Attachments)
if cmd != nil { if cmd != nil {
return p, cmd return p, cmd
} }
case dialog.CommandRunCustomMsg:
// Check if the agent is busy before executing custom commands
if p.app.IsBusy() {
status.Warn("Agent is busy, please wait before executing a command...")
return p, nil
}
// Process the command content with arguments if any
content := msg.Content
if msg.Args != nil {
// Replace all named arguments with their values
for name, value := range msg.Args {
placeholder := "$" + name
content = strings.ReplaceAll(content, placeholder, value)
}
}
// Handle custom command execution
cmd := p.sendMessage(content, nil)
if cmd != nil {
return p, cmd
}
case dialog.CompletionDialogCloseMsg: case dialog.CompletionDialogCloseMsg:
p.showCompletionDialog = false p.showCompletionDialog = false
p.app.SetCompletionDialogOpen(false)
case tea.KeyMsg: case tea.KeyMsg:
switch msg.String() { switch msg.String() {
case "ctrl+c": case "ctrl+c":
@@ -105,7 +81,6 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch { switch {
case key.Matches(msg, keyMap.ShowCompletionDialog): case key.Matches(msg, keyMap.ShowCompletionDialog):
p.showCompletionDialog = true p.showCompletionDialog = true
p.app.SetCompletionDialogOpen(true)
// Continue sending keys to layout->chat // Continue sending keys to layout->chat
case key.Matches(msg, keyMap.Cancel): case key.Matches(msg, keyMap.Cancel):
if p.app.Session.Id != "" { if p.app.Session.Id != "" {
@@ -118,14 +93,22 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return p, util.CmdHandler(chat.ToggleToolMessagesMsg{}) return p, util.CmdHandler(chat.ToggleToolMessagesMsg{})
} }
} }
if p.showCompletionDialog { 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) context, contextCmd := p.completionDialog.Update(msg)
p.completionDialog = context.(dialog.CompletionDialog) p.completionDialog = context.(dialog.CompletionDialog)
cmds = append(cmds, contextCmd) 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, ok := msg.(tea.KeyMsg); ok {
if keyMsg.String() == "enter" { if keyMsg.String() == "enter" { // && !p.completionDialog.IsEmpty() {
return p, tea.Batch(cmds...) return p, tea.Batch(cmds...)
} }
} }
@@ -155,17 +138,16 @@ func (p *chatPage) GetSize() (int, int) {
func (p *chatPage) View() string { func (p *chatPage) View() string {
layoutView := p.layout.View() layoutView := p.layout.View()
// TODO: Fix this with our new layout
if p.showCompletionDialog { if p.showCompletionDialog {
_, layoutHeight := p.layout.GetSize() editorWidth, _ := p.editor.GetSize()
editorWidth, editorHeight := p.editor.GetSize() editorX, editorY := p.editor.GetPosition()
p.completionDialog.SetWidth(editorWidth) p.completionDialog.SetWidth(editorWidth)
overlay := p.completionDialog.View() overlay := p.completionDialog.View()
layoutView = layout.PlaceOverlay( layoutView = layout.PlaceOverlay(
0, editorX,
layoutHeight-editorHeight-lipgloss.Height(overlay), editorY-lipgloss.Height(overlay)+2,
overlay, overlay,
layoutView, layoutView,
) )
@@ -174,16 +156,11 @@ func (p *chatPage) View() string {
return layoutView 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 { func NewChatPage(app *app.App) layout.ModelWithView {
cg := completions.NewFileAndFolderContextGroup() completionManager := completions.NewCompletionManager(app)
completionDialog := dialog.NewCompletionDialogComponent(cg) initialProvider := completionManager.GetProvider("")
completionDialog := dialog.NewCompletionDialogComponent(initialProvider)
messagesContainer := layout.NewContainer( messagesContainer := layout.NewContainer(
chat.NewMessagesComponent(app), chat.NewMessagesComponent(app),
) )
@@ -192,17 +169,19 @@ func NewChatPage(app *app.App) layout.ModelWithView {
layout.WithMaxWidth(layout.Current.Container.Width), layout.WithMaxWidth(layout.Current.Container.Width),
layout.WithAlignCenter(), layout.WithAlignCenter(),
) )
return &chatPage{ return &chatPage{
app: app, app: app,
editor: editorContainer, editor: editorContainer,
messages: messagesContainer, messages: messagesContainer,
completionDialog: completionDialog, completionDialog: completionDialog,
completionManager: completionManager,
layout: layout.NewFlexLayout( layout: layout.NewFlexLayout(
layout.WithPanes(messagesContainer, editorContainer), layout.WithPanes(messagesContainer, editorContainer),
layout.WithDirection(layout.FlexDirectionVertical), layout.WithDirection(layout.FlexDirectionVertical),
layout.WithPaneSizes( layout.WithPaneSizes(
layout.FlexPaneSizeGrow, layout.FlexPaneSizeGrow,
layout.FlexPaneSizeFixed(5), layout.FlexPaneSizeFixed(6),
), ),
), ),
} }
+1 -3
View File
@@ -7,7 +7,7 @@ import (
type SessionSelectedMsg = *client.SessionInfo type SessionSelectedMsg = *client.SessionInfo
type ModelSelectedMsg struct { type ModelSelectedMsg struct {
Provider client.ProviderInfo Provider client.ProviderInfo
Model client.ProviderModel Model client.ModelInfo
} }
type SessionClearedMsg struct{} type SessionClearedMsg struct{}
@@ -17,5 +17,3 @@ type CompactSessionMsg struct{}
type StateUpdatedMsg struct { type StateUpdatedMsg struct {
State map[string]any State map[string]any
} }
// TODO: store in CONFIG/tui.yaml
+3 -16
View File
@@ -6,6 +6,8 @@ import (
"slices" "slices"
"strings" "strings"
"sync" "sync"
"github.com/alecthomas/chroma/v2/styles"
// "github.com/alecthomas/chroma/v2/styles" // "github.com/alecthomas/chroma/v2/styles"
) )
@@ -45,22 +47,7 @@ func RegisterTheme(name string, theme Theme) {
func SetTheme(name string) error { func SetTheme(name string) error {
globalManager.mu.Lock() globalManager.mu.Lock()
defer globalManager.mu.Unlock() defer globalManager.mu.Unlock()
// delete(styles.Registry, "charm") delete(styles.Registry, "charm")
// Handle custom theme
// if name == "custom" {
// cfg := config.Get()
// if cfg == nil || cfg.TUI.CustomTheme == nil || len(cfg.TUI.CustomTheme) == 0 {
// return fmt.Errorf("custom theme selected but no custom theme colors defined in config")
// }
//
// customTheme, err := LoadCustomTheme(cfg.TUI.CustomTheme)
// if err != nil {
// return fmt.Errorf("failed to load custom theme: %w", err)
// }
//
// // Register the custom theme
// globalManager.themes["custom"] = customTheme
if _, exists := globalManager.themes[name]; !exists { if _, exists := globalManager.themes[name]; !exists {
return fmt.Errorf("theme '%s' not found", name) return fmt.Errorf("theme '%s' not found", name)
+75 -143
View File
@@ -11,55 +11,19 @@ import (
"github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/app" "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/core"
"github.com/sst/opencode/internal/components/dialog" "github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/components/modal" "github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/layout" "github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/page" "github.com/sst/opencode/internal/page"
"github.com/sst/opencode/internal/state" "github.com/sst/opencode/internal/state"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util" "github.com/sst/opencode/internal/util"
"github.com/sst/opencode/pkg/client" "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 { type appModel struct {
width, height int width, height int
currentPage page.PageID currentPage page.PageID
@@ -69,7 +33,6 @@ type appModel struct {
status core.StatusComponent status core.StatusComponent
app *app.App app *app.App
modal layout.Modal modal layout.Modal
commands []dialog.Command
} }
func (a appModel) Init() tea.Cmd { func (a appModel) Init() tea.Cmd {
@@ -116,33 +79,52 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd var cmd tea.Cmd
if a.modal != nil { if a.modal != nil {
isModalTrigger := false bypassModal := false
if _, ok := msg.(modal.CloseModalMsg); ok { if _, ok := msg.(modal.CloseModalMsg); ok {
a.modal = nil a.modal = nil
return a, nil return a, nil
} }
if msg, ok := msg.(tea.KeyMsg); ok { if msg, ok := msg.(tea.KeyMsg); ok {
switch msg.String() { switch msg.String() {
case "esc": case "esc":
a.modal = nil a.modal = nil
return a, nil return a, nil
case "ctrl+c": 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() quitDialog := dialog.NewQuitDialog()
a.modal = quitDialog a.modal = quitDialog
return a, nil return a, nil
} }
} }
isModalTrigger = key.Matches(msg, keys.NewSession) || // don't send commands to the modal
key.Matches(msg, keys.SwitchSession) || for _, cmdDef := range a.app.Commands {
key.Matches(msg, keys.SwitchModel) || if key.Matches(msg, cmdDef.KeyBinding) {
key.Matches(msg, keys.SwitchTheme) || bypassModal = true
key.Matches(msg, keys.Help) || break
key.Matches(msg, keys.Quit) }
}
} }
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) updatedModal, cmd := a.modal.Update(msg)
a.modal = updatedModal.(layout.Modal) a.modal = updatedModal.(layout.Modal)
return a, cmd return a, cmd
@@ -150,6 +132,38 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
switch msg := msg.(type) { 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: case tea.BackgroundColorMsg:
styles.Terminal = &styles.TerminalInfo{ styles.Terminal = &styles.TerminalInfo{
@@ -184,18 +198,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
msg.Height -= 2 // Make space for the status bar msg.Height -= 2 // Make space for the status bar
a.width, a.height = msg.Width, msg.Height 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 // TODO: move away from global state
layout.Current = &layout.LayoutInfo{ layout.Current = &layout.LayoutInfo{
Size: size,
Viewport: layout.Dimensions{ Viewport: layout.Dimensions{
Width: a.width, Width: a.width,
Height: a.height, Height: a.height,
@@ -271,12 +275,17 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
} }
s, cmd := a.status.Update(msg)
cmds = append(cmds, cmd)
a.status = s.(core.StatusComponent)
t := theme.CurrentTheme() t := theme.CurrentTheme()
cmds = append(cmds, tea.SetBackgroundColor(t.Background())) cmds = append(cmds, tea.SetBackgroundColor(t.Background()))
return a, tea.Batch(cmds...) return a, tea.Batch(cmds...)
case tea.KeyMsg: case tea.KeyMsg:
switch msg.String() { switch msg.String() {
// give the editor a chance to clear input
case "ctrl+c": case "ctrl+c":
updated, cmd := a.pages[a.currentPage].Update(msg) updated, cmd := a.pages[a.currentPage].Update(msg)
a.pages[a.currentPage] = updated.(layout.ModelWithView) a.pages[a.currentPage] = updated.(layout.ModelWithView)
@@ -285,51 +294,16 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
} }
switch { // First, check for modal triggers from the command registry
case key.Matches(msg, keys.Help): if a.modal == nil {
helpDialog := dialog.NewHelpDialog( for _, cmdDef := range a.app.Commands {
keys.Help, if key.Matches(msg, cmdDef.KeyBinding) {
keys.NewSession, // If a key matches, send an ExecuteCommandMsg to self.
keys.SwitchSession, // This unifies keybinding and slash command handling.
keys.SwitchModel, return a, util.CmdHandler(commands.ExecuteCommandMsg{Name: cmdDef.Name})
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
} }
default:
// f, filepickerCmd := a.filepicker.Update(msg)
// a.filepicker = f.(dialog.FilepickerComponent)
// cmds = append(cmds, filepickerCmd)
} }
// update status bar // update status bar
@@ -344,11 +318,6 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, tea.Batch(cmds...) return a, tea.Batch(cmds...)
} }
// RegisterCommand adds a command to the command dialog
func (a *appModel) RegisterCommand(cmd dialog.Command) {
a.commands = append(a.commands, cmd)
}
func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd { func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
var cmds []tea.Cmd var cmds []tea.Cmd
if _, ok := a.loadedPages[pageID]; !ok { if _, ok := a.loadedPages[pageID]; !ok {
@@ -387,47 +356,10 @@ func NewModel(app *app.App) tea.Model {
loadedPages: make(map[page.PageID]bool), loadedPages: make(map[page.PageID]bool),
status: core.NewStatusCmp(app), status: core.NewStatusCmp(app),
app: app, app: app,
commands: []dialog.Command{},
pages: map[page.PageID]layout.ModelWithView{ pages: map[page.PageID]layout.ModelWithView{
page.ChatPage: page.NewChatPage(app), page.ChatPage: page.NewChatPage(app),
}, },
} }
model.RegisterCommand(dialog.Command{
ID: "init",
Title: "Initialize Project",
Description: "Create/Update the AGENTS.md memory file",
Handler: func(cmd dialog.Command) tea.Cmd {
return app.InitializeProject(context.Background())
},
})
model.RegisterCommand(dialog.Command{
ID: "compact_conversation",
Title: "Compact Conversation",
Description: "Summarize the current session to save tokens",
Handler: func(cmd dialog.Command) tea.Cmd {
// Get the current session from the appModel
if model.currentPage != page.ChatPage {
status.Warn("Please navigate to a chat session first.")
return nil
}
// Return a message that will be handled by the chat page
status.Info("Compacting conversation...")
return util.CmdHandler(state.CompactSessionMsg{})
},
})
// Load custom commands
customCommands, err := dialog.LoadCustomCommands()
if err != nil {
slog.Warn("Failed to load custom commands", "error", err)
} else {
for _, cmd := range customCommands {
model.RegisterCommand(cmd)
}
}
return model return model
} }
+130 -21
View File
@@ -439,6 +439,45 @@
"parameters": [], "parameters": [],
"description": "List all providers" "description": "List all providers"
} }
},
"/file_search": {
"post": {
"responses": {
"200": {
"description": "Search for files",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
}
},
"operationId": "postFile_search",
"parameters": [],
"description": "Search for files",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"query": {
"type": "string"
}
},
"required": [
"query"
]
}
}
}
}
}
} }
}, },
"components": { "components": {
@@ -618,7 +657,6 @@
"role": { "role": {
"type": "string", "type": "string",
"enum": [ "enum": [
"system",
"user", "user",
"assistant" "assistant"
] ]
@@ -668,17 +706,65 @@
}, },
"tool": { "tool": {
"type": "object", "type": "object",
"additionalProperties": {} "additionalProperties": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"time": {
"type": "object",
"properties": {
"start": {
"type": "number"
},
"end": {
"type": "number"
}
},
"required": [
"start",
"end"
]
}
},
"required": [
"title",
"time"
],
"additionalProperties": {}
}
}, },
"assistant": { "assistant": {
"type": "object", "type": "object",
"properties": { "properties": {
"system": {
"type": "array",
"items": {
"type": "string"
}
},
"modelID": { "modelID": {
"type": "string" "type": "string"
}, },
"providerID": { "providerID": {
"type": "string" "type": "string"
}, },
"path": {
"type": "object",
"properties": {
"cwd": {
"type": "string"
},
"root": {
"type": "string"
}
},
"required": [
"cwd",
"root"
]
},
"cost": { "cost": {
"type": "number" "type": "number"
}, },
@@ -706,8 +792,10 @@
} }
}, },
"required": [ "required": [
"system",
"modelID", "modelID",
"providerID", "providerID",
"path",
"cost", "cost",
"tokens" "tokens"
] ]
@@ -715,7 +803,6 @@
}, },
"required": [ "required": [
"time", "time",
"error",
"sessionID", "sessionID",
"tool" "tool"
] ]
@@ -1036,10 +1123,18 @@
"properties": { "properties": {
"part": { "part": {
"$ref": "#/components/schemas/Message.Part" "$ref": "#/components/schemas/Message.Part"
},
"sessionID": {
"type": "string"
},
"messageID": {
"type": "string"
} }
}, },
"required": [ "required": [
"part" "part",
"sessionID",
"messageID"
] ]
} }
}, },
@@ -1079,6 +1174,10 @@
"type": "string", "type": "string",
"pattern": "^ses" "pattern": "^ses"
}, },
"parentID": {
"type": "string",
"pattern": "^ses"
},
"share": { "share": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1146,10 +1245,7 @@
} }
} }
} }
}, }
"required": [
"error"
]
} }
}, },
"required": [ "required": [
@@ -1220,31 +1316,35 @@
"Provider.Info": { "Provider.Info": {
"type": "object", "type": "object",
"properties": { "properties": {
"id": { "name": {
"type": "string" "type": "string"
}, },
"name": { "env": {
"type": "array",
"items": {
"type": "string"
}
},
"id": {
"type": "string" "type": "string"
}, },
"models": { "models": {
"type": "object", "type": "object",
"additionalProperties": { "additionalProperties": {
"$ref": "#/components/schemas/Provider.Model" "$ref": "#/components/schemas/Model.Info"
} }
} }
}, },
"required": [ "required": [
"id",
"name", "name",
"env",
"id",
"models" "models"
] ]
}, },
"Provider.Model": { "Model.Info": {
"type": "object", "type": "object",
"properties": { "properties": {
"id": {
"type": "string"
},
"name": { "name": {
"type": "string" "type": "string"
}, },
@@ -1254,16 +1354,19 @@
"reasoning": { "reasoning": {
"type": "boolean" "type": "boolean"
}, },
"temperature": {
"type": "boolean"
},
"cost": { "cost": {
"type": "object", "type": "object",
"properties": { "properties": {
"input": { "input": {
"type": "number" "type": "number"
}, },
"inputCached": { "output": {
"type": "number" "type": "number"
}, },
"output": { "inputCached": {
"type": "number" "type": "number"
}, },
"outputCached": { "outputCached": {
@@ -1272,8 +1375,8 @@
}, },
"required": [ "required": [
"input", "input",
"inputCached",
"output", "output",
"inputCached",
"outputCached" "outputCached"
] ]
}, },
@@ -1291,13 +1394,19 @@
"context", "context",
"output" "output"
] ]
},
"id": {
"type": "string"
} }
}, },
"required": [ "required": [
"id", "name",
"attachment", "attachment",
"reasoning",
"temperature",
"cost", "cost",
"limit" "limit",
"id"
] ]
} }
} }
+268 -23
View File
@@ -20,7 +20,6 @@ import (
// Defines values for MessageInfoRole. // Defines values for MessageInfoRole.
const ( const (
Assistant MessageInfoRole = "assistant" Assistant MessageInfoRole = "assistant"
System MessageInfoRole = "system"
User MessageInfoRole = "user" User MessageInfoRole = "user"
) )
@@ -61,7 +60,9 @@ type EventLspClientDiagnostics struct {
// EventMessagePartUpdated defines model for Event.message.part.updated. // EventMessagePartUpdated defines model for Event.message.part.updated.
type EventMessagePartUpdated struct { type EventMessagePartUpdated struct {
Properties struct { Properties struct {
Part MessagePart `json:"part"` MessageID string `json:"messageID"`
Part MessagePart `json:"part"`
SessionID string `json:"sessionID"`
} `json:"properties"` } `json:"properties"`
Type string `json:"type"` Type string `json:"type"`
} }
@@ -83,7 +84,7 @@ type EventPermissionUpdated struct {
// EventSessionError defines model for Event.session.error. // EventSessionError defines model for Event.session.error.
type EventSessionError struct { type EventSessionError struct {
Properties struct { Properties struct {
Error EventSessionError_Properties_Error `json:"error"` Error *EventSessionError_Properties_Error `json:"error,omitempty"`
} `json:"properties"` } `json:"properties"`
Type string `json:"type"` Type string `json:"type"`
} }
@@ -115,23 +116,28 @@ type MessageInfo struct {
Id string `json:"id"` Id string `json:"id"`
Metadata struct { Metadata struct {
Assistant *struct { Assistant *struct {
Cost float32 `json:"cost"` Cost float32 `json:"cost"`
ModelID string `json:"modelID"` ModelID string `json:"modelID"`
ProviderID string `json:"providerID"` Path struct {
Summary *bool `json:"summary,omitempty"` Cwd string `json:"cwd"`
Root string `json:"root"`
} `json:"path"`
ProviderID string `json:"providerID"`
Summary *bool `json:"summary,omitempty"`
System []string `json:"system"`
Tokens struct { Tokens struct {
Input float32 `json:"input"` Input float32 `json:"input"`
Output float32 `json:"output"` Output float32 `json:"output"`
Reasoning float32 `json:"reasoning"` Reasoning float32 `json:"reasoning"`
} `json:"tokens"` } `json:"tokens"`
} `json:"assistant,omitempty"` } `json:"assistant,omitempty"`
Error MessageInfo_Metadata_Error `json:"error"` Error *MessageInfo_Metadata_Error `json:"error,omitempty"`
SessionID string `json:"sessionID"` SessionID string `json:"sessionID"`
Time struct { Time struct {
Completed *float32 `json:"completed,omitempty"` Completed *float32 `json:"completed,omitempty"`
Created float32 `json:"created"` Created float32 `json:"created"`
} `json:"time"` } `json:"time"`
Tool map[string]interface{} `json:"tool"` Tool map[string]MessageInfo_Metadata_Tool_AdditionalProperties `json:"tool"`
} `json:"metadata"` } `json:"metadata"`
Parts []MessagePart `json:"parts"` Parts []MessagePart `json:"parts"`
Role MessageInfoRole `json:"role"` Role MessageInfoRole `json:"role"`
@@ -142,6 +148,16 @@ type MessageInfo_Metadata_Error struct {
union json.RawMessage union json.RawMessage
} }
// MessageInfo_Metadata_Tool_AdditionalProperties defines model for MessageInfo.Metadata.Tool.AdditionalProperties.
type MessageInfo_Metadata_Tool_AdditionalProperties struct {
Time struct {
End float32 `json:"end"`
Start float32 `json:"start"`
} `json:"time"`
Title string `json:"title"`
AdditionalProperties map[string]interface{} `json:"-"`
}
// MessageInfoRole defines model for MessageInfo.Role. // MessageInfoRole defines model for MessageInfo.Role.
type MessageInfoRole string type MessageInfoRole string
@@ -224,15 +240,8 @@ type MessageToolInvocationToolResult struct {
ToolName string `json:"toolName"` ToolName string `json:"toolName"`
} }
// ProviderInfo defines model for Provider.Info. // ModelInfo defines model for Model.Info.
type ProviderInfo struct { type ModelInfo struct {
Id string `json:"id"`
Models map[string]ProviderModel `json:"models"`
Name string `json:"name"`
}
// ProviderModel defines model for Provider.Model.
type ProviderModel struct {
Attachment bool `json:"attachment"` Attachment bool `json:"attachment"`
Cost struct { Cost struct {
Input float32 `json:"input"` Input float32 `json:"input"`
@@ -245,8 +254,17 @@ type ProviderModel struct {
Context float32 `json:"context"` Context float32 `json:"context"`
Output float32 `json:"output"` Output float32 `json:"output"`
} `json:"limit"` } `json:"limit"`
Name *string `json:"name,omitempty"` Name string `json:"name"`
Reasoning *bool `json:"reasoning,omitempty"` Reasoning bool `json:"reasoning"`
Temperature bool `json:"temperature"`
}
// ProviderInfo defines model for Provider.Info.
type ProviderInfo struct {
Env []string `json:"env"`
Id string `json:"id"`
Models map[string]ModelInfo `json:"models"`
Name string `json:"name"`
} }
// ProviderAuthError defines model for ProviderAuthError. // ProviderAuthError defines model for ProviderAuthError.
@@ -279,8 +297,9 @@ type PermissionInfo struct {
// SessionInfo defines model for session.info. // SessionInfo defines model for session.info.
type SessionInfo struct { type SessionInfo struct {
Id string `json:"id"` Id string `json:"id"`
Share *struct { ParentID *string `json:"parentID,omitempty"`
Share *struct {
Secret string `json:"secret"` Secret string `json:"secret"`
Url string `json:"url"` Url string `json:"url"`
} `json:"share,omitempty"` } `json:"share,omitempty"`
@@ -291,6 +310,11 @@ type SessionInfo struct {
Title string `json:"title"` Title string `json:"title"`
} }
// PostFileSearchJSONBody defines parameters for PostFileSearch.
type PostFileSearchJSONBody struct {
Query string `json:"query"`
}
// PostSessionAbortJSONBody defines parameters for PostSessionAbort. // PostSessionAbortJSONBody defines parameters for PostSessionAbort.
type PostSessionAbortJSONBody struct { type PostSessionAbortJSONBody struct {
SessionID string `json:"sessionID"` SessionID string `json:"sessionID"`
@@ -328,6 +352,9 @@ type PostSessionSummarizeJSONBody struct {
SessionID string `json:"sessionID"` SessionID string `json:"sessionID"`
} }
// PostFileSearchJSONRequestBody defines body for PostFileSearch for application/json ContentType.
type PostFileSearchJSONRequestBody PostFileSearchJSONBody
// PostSessionAbortJSONRequestBody defines body for PostSessionAbort for application/json ContentType. // PostSessionAbortJSONRequestBody defines body for PostSessionAbort for application/json ContentType.
type PostSessionAbortJSONRequestBody PostSessionAbortJSONBody type PostSessionAbortJSONRequestBody PostSessionAbortJSONBody
@@ -346,6 +373,85 @@ type PostSessionShareJSONRequestBody PostSessionShareJSONBody
// PostSessionSummarizeJSONRequestBody defines body for PostSessionSummarize for application/json ContentType. // PostSessionSummarizeJSONRequestBody defines body for PostSessionSummarize for application/json ContentType.
type PostSessionSummarizeJSONRequestBody PostSessionSummarizeJSONBody type PostSessionSummarizeJSONRequestBody PostSessionSummarizeJSONBody
// Getter for additional properties for MessageInfo_Metadata_Tool_AdditionalProperties. Returns the specified
// element and whether it was found
func (a MessageInfo_Metadata_Tool_AdditionalProperties) Get(fieldName string) (value interface{}, found bool) {
if a.AdditionalProperties != nil {
value, found = a.AdditionalProperties[fieldName]
}
return
}
// Setter for additional properties for MessageInfo_Metadata_Tool_AdditionalProperties
func (a *MessageInfo_Metadata_Tool_AdditionalProperties) Set(fieldName string, value interface{}) {
if a.AdditionalProperties == nil {
a.AdditionalProperties = make(map[string]interface{})
}
a.AdditionalProperties[fieldName] = value
}
// Override default JSON handling for MessageInfo_Metadata_Tool_AdditionalProperties to handle AdditionalProperties
func (a *MessageInfo_Metadata_Tool_AdditionalProperties) UnmarshalJSON(b []byte) error {
object := make(map[string]json.RawMessage)
err := json.Unmarshal(b, &object)
if err != nil {
return err
}
if raw, found := object["time"]; found {
err = json.Unmarshal(raw, &a.Time)
if err != nil {
return fmt.Errorf("error reading 'time': %w", err)
}
delete(object, "time")
}
if raw, found := object["title"]; found {
err = json.Unmarshal(raw, &a.Title)
if err != nil {
return fmt.Errorf("error reading 'title': %w", err)
}
delete(object, "title")
}
if len(object) != 0 {
a.AdditionalProperties = make(map[string]interface{})
for fieldName, fieldBuf := range object {
var fieldVal interface{}
err := json.Unmarshal(fieldBuf, &fieldVal)
if err != nil {
return fmt.Errorf("error unmarshaling field %s: %w", fieldName, err)
}
a.AdditionalProperties[fieldName] = fieldVal
}
}
return nil
}
// Override default JSON handling for MessageInfo_Metadata_Tool_AdditionalProperties to handle AdditionalProperties
func (a MessageInfo_Metadata_Tool_AdditionalProperties) MarshalJSON() ([]byte, error) {
var err error
object := make(map[string]json.RawMessage)
object["time"], err = json.Marshal(a.Time)
if err != nil {
return nil, fmt.Errorf("error marshaling 'time': %w", err)
}
object["title"], err = json.Marshal(a.Title)
if err != nil {
return nil, fmt.Errorf("error marshaling 'title': %w", err)
}
for fieldName, field := range a.AdditionalProperties {
object[fieldName], err = json.Marshal(field)
if err != nil {
return nil, fmt.Errorf("error marshaling '%s': %w", fieldName, err)
}
}
return json.Marshal(object)
}
// AsEventStorageWrite returns the union data inside the Event as a EventStorageWrite // AsEventStorageWrite returns the union data inside the Event as a EventStorageWrite
func (t Event) AsEventStorageWrite() (EventStorageWrite, error) { func (t Event) AsEventStorageWrite() (EventStorageWrite, error) {
var body EventStorageWrite var body EventStorageWrite
@@ -1173,6 +1279,11 @@ type ClientInterface interface {
// GetEvent request // GetEvent request
GetEvent(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) GetEvent(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error)
// PostFileSearchWithBody request with any body
PostFileSearchWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
PostFileSearch(ctx context.Context, body PostFileSearchJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
// PostPathGet request // PostPathGet request
PostPathGet(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) PostPathGet(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error)
@@ -1252,6 +1363,30 @@ func (c *Client) GetEvent(ctx context.Context, reqEditors ...RequestEditorFn) (*
return c.Client.Do(req) return c.Client.Do(req)
} }
func (c *Client) PostFileSearchWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewPostFileSearchRequestWithBody(c.Server, contentType, body)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
return c.Client.Do(req)
}
func (c *Client) PostFileSearch(ctx context.Context, body PostFileSearchJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewPostFileSearchRequest(c.Server, body)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
return c.Client.Do(req)
}
func (c *Client) PostPathGet(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { func (c *Client) PostPathGet(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewPostPathGetRequest(c.Server) req, err := NewPostPathGetRequest(c.Server)
if err != nil { if err != nil {
@@ -1525,6 +1660,46 @@ func NewGetEventRequest(server string) (*http.Request, error) {
return req, nil return req, nil
} }
// NewPostFileSearchRequest calls the generic PostFileSearch builder with application/json body
func NewPostFileSearchRequest(server string, body PostFileSearchJSONRequestBody) (*http.Request, error) {
var bodyReader io.Reader
buf, err := json.Marshal(body)
if err != nil {
return nil, err
}
bodyReader = bytes.NewReader(buf)
return NewPostFileSearchRequestWithBody(server, "application/json", bodyReader)
}
// NewPostFileSearchRequestWithBody generates requests for PostFileSearch with any type of body
func NewPostFileSearchRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) {
var err error
serverURL, err := url.Parse(server)
if err != nil {
return nil, err
}
operationPath := fmt.Sprintf("/file_search")
if operationPath[0] == '/' {
operationPath = "." + operationPath
}
queryURL, err := serverURL.Parse(operationPath)
if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", queryURL.String(), body)
if err != nil {
return nil, err
}
req.Header.Add("Content-Type", contentType)
return req, nil
}
// NewPostPathGetRequest generates requests for PostPathGet // NewPostPathGetRequest generates requests for PostPathGet
func NewPostPathGetRequest(server string) (*http.Request, error) { func NewPostPathGetRequest(server string) (*http.Request, error) {
var err error var err error
@@ -1925,6 +2100,11 @@ type ClientWithResponsesInterface interface {
// GetEventWithResponse request // GetEventWithResponse request
GetEventWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetEventResponse, error) GetEventWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetEventResponse, error)
// PostFileSearchWithBodyWithResponse request with any body
PostFileSearchWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostFileSearchResponse, error)
PostFileSearchWithResponse(ctx context.Context, body PostFileSearchJSONRequestBody, reqEditors ...RequestEditorFn) (*PostFileSearchResponse, error)
// PostPathGetWithResponse request // PostPathGetWithResponse request
PostPathGetWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*PostPathGetResponse, error) PostPathGetWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*PostPathGetResponse, error)
@@ -2034,6 +2214,28 @@ func (r GetEventResponse) StatusCode() int {
return 0 return 0
} }
type PostFileSearchResponse struct {
Body []byte
HTTPResponse *http.Response
JSON200 *[]string
}
// Status returns HTTPResponse.Status
func (r PostFileSearchResponse) Status() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Status
}
return http.StatusText(0)
}
// StatusCode returns HTTPResponse.StatusCode
func (r PostFileSearchResponse) StatusCode() int {
if r.HTTPResponse != nil {
return r.HTTPResponse.StatusCode
}
return 0
}
type PostPathGetResponse struct { type PostPathGetResponse struct {
Body []byte Body []byte
HTTPResponse *http.Response HTTPResponse *http.Response
@@ -2290,6 +2492,23 @@ func (c *ClientWithResponses) GetEventWithResponse(ctx context.Context, reqEdito
return ParseGetEventResponse(rsp) return ParseGetEventResponse(rsp)
} }
// PostFileSearchWithBodyWithResponse request with arbitrary body returning *PostFileSearchResponse
func (c *ClientWithResponses) PostFileSearchWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostFileSearchResponse, error) {
rsp, err := c.PostFileSearchWithBody(ctx, contentType, body, reqEditors...)
if err != nil {
return nil, err
}
return ParsePostFileSearchResponse(rsp)
}
func (c *ClientWithResponses) PostFileSearchWithResponse(ctx context.Context, body PostFileSearchJSONRequestBody, reqEditors ...RequestEditorFn) (*PostFileSearchResponse, error) {
rsp, err := c.PostFileSearch(ctx, body, reqEditors...)
if err != nil {
return nil, err
}
return ParsePostFileSearchResponse(rsp)
}
// PostPathGetWithResponse request returning *PostPathGetResponse // PostPathGetWithResponse request returning *PostPathGetResponse
func (c *ClientWithResponses) PostPathGetWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*PostPathGetResponse, error) { func (c *ClientWithResponses) PostPathGetWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*PostPathGetResponse, error) {
rsp, err := c.PostPathGet(ctx, reqEditors...) rsp, err := c.PostPathGet(ctx, reqEditors...)
@@ -2506,6 +2725,32 @@ func ParseGetEventResponse(rsp *http.Response) (*GetEventResponse, error) {
return response, nil return response, nil
} }
// ParsePostFileSearchResponse parses an HTTP response from a PostFileSearchWithResponse call
func ParsePostFileSearchResponse(rsp *http.Response) (*PostFileSearchResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
defer func() { _ = rsp.Body.Close() }()
if err != nil {
return nil, err
}
response := &PostFileSearchResponse{
Body: bodyBytes,
HTTPResponse: rsp,
}
switch {
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
var dest []string
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON200 = &dest
}
return response, nil
}
// ParsePostPathGetResponse parses an HTTP response from a PostPathGetWithResponse call // ParsePostPathGetResponse parses an HTTP response from a PostPathGetWithResponse call
func ParsePostPathGetResponse(rsp *http.Response) (*PostPathGetResponse, error) { func ParsePostPathGetResponse(rsp *http.Response) (*PostPathGetResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body) bodyBytes, err := io.ReadAll(rsp.Body)
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 75 KiB