Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df13b155f9 | ||
|
|
eeed5b8718 | ||
|
|
148ef90210 | ||
|
|
67023bb007 | ||
|
|
a316aed4fe | ||
|
|
9f7c0bd599 | ||
|
|
c7e1068f90 | ||
|
|
e2052d790b | ||
|
|
d3b2763c14 | ||
|
|
c6492de7ac | ||
|
|
d8fa0fb50c | ||
|
|
18ab8faa1d | ||
|
|
f35ce180e2 | ||
|
|
2bee48a9bc | ||
|
|
10ddd654cf | ||
|
|
61396b93ed | ||
|
|
62b9a30a9c | ||
|
|
5706c6ad3a | ||
|
|
e8e03c895a | ||
|
|
38667682a7 | ||
|
|
d7d5fc39fb | ||
|
|
0caf25adee | ||
|
|
37febc6873 | ||
|
|
4169f0c412 | ||
|
|
b7f06bbc1f | ||
|
|
1b8cfe9e99 | ||
|
|
97837d2d23 | ||
|
|
9abc2a0cf8 | ||
|
|
9fb47bc855 | ||
|
|
73e9fb53d5 | ||
|
|
f03637b1fc | ||
|
|
2c376c5abc | ||
|
|
442e1b52ad | ||
|
|
e8c3abc369 | ||
|
|
c8648baba2 | ||
|
|
7b3a799856 |
@@ -34,12 +34,19 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
bun-version: 1.2.16
|
bun-version: 1.2.16
|
||||||
|
|
||||||
|
- name: Install makepkg
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y pacman-package-manager
|
||||||
|
|
||||||
- name: Setup SSH for AUR
|
- name: Setup SSH for AUR
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
echo "${{ secrets.AUR_KEY }}" > ~/.ssh/id_rsa
|
echo "${{ secrets.AUR_KEY }}" > ~/.ssh/id_rsa
|
||||||
chmod 600 ~/.ssh/id_rsa
|
chmod 600 ~/.ssh/id_rsa
|
||||||
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts
|
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts
|
||||||
|
git config --global user.email "opencode@sst.dev"
|
||||||
|
git config --global user.name "opencode"
|
||||||
|
|
||||||
- name: Publish
|
- name: Publish
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -1,658 +1,46 @@
|
|||||||
◧ opencode
|
```
|
||||||
|
█▀▀█ █▀▀█ █▀▀ █▀▀▄ █▀▀ █▀▀█ █▀▀▄ █▀▀
|
||||||
|
█░░█ █░░█ █▀▀ █░░█ █░░ █░░█ █░░█ █▀▀
|
||||||
|
▀▀▀▀ █▀▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀
|
||||||
|
```
|
||||||
|
|
||||||

|
[](https://github.com/sst/opencode)
|
||||||
|
|
||||||
> **⚠️ Notice:** We are in progress of a complete overhaul in the `dontlook` branch - should be released mid June. The README below is for the current version
|
AI coding agent, built for the terminal.
|
||||||
|
|
||||||
A powerful terminal-based AI assistant for developers, providing intelligent coding assistance directly in your terminal.
|
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
|
||||||
|
|
||||||
## 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
|
## Installation
|
||||||
|
|
||||||
### Using the Install Script
|
If you have a previous version of opencode < 0.1.x installed you might have to remove it first.
|
||||||
|
|
||||||
```bash
|
### Curl
|
||||||
# Install the latest version
|
|
||||||
|
```
|
||||||
curl -fsSL https://opencode.ai/install | bash
|
curl -fsSL https://opencode.ai/install | bash
|
||||||
|
|
||||||
# Install a specific version
|
|
||||||
curl -fsSL https://opencode.ai/install | VERSION=0.1.0 bash
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Using Homebrew (macOS and Linux)
|
### NPM
|
||||||
|
|
||||||
```bash
|
```
|
||||||
|
npm i -g opencode-ai@latest
|
||||||
|
bun i -g opencode-ai@latest
|
||||||
|
pnpm i -g opencode-ai@latest
|
||||||
|
yarn global add opencode-ai@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Brew
|
||||||
|
|
||||||
|
```
|
||||||
brew install sst/tap/opencode
|
brew install sst/tap/opencode
|
||||||
```
|
```
|
||||||
|
|
||||||
### Using AUR (Arch Linux)
|
### AUR
|
||||||
|
|
||||||
```bash
|
```
|
||||||
# Using yay
|
|
||||||
yay -S opencode-bin
|
|
||||||
|
|
||||||
# Using paru
|
|
||||||
paru -S opencode-bin
|
paru -S opencode-bin
|
||||||
```
|
```
|
||||||
|
|
||||||
### Using Go
|
## Usage
|
||||||
|
|
||||||
```bash
|
We are working on proper keybinds - right now it's the various function keys press F1 to see them
|
||||||
go install github.com/sst/opencode@latest
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
OpenCode looks for configuration in the following locations:
|
|
||||||
|
|
||||||
- `$HOME/.opencode.json`
|
|
||||||
- `$XDG_CONFIG_HOME/opencode/.opencode.json`
|
|
||||||
- `./.opencode.json` (local directory)
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
You can configure OpenCode using environment variables:
|
|
||||||
|
|
||||||
| Environment Variable | Purpose |
|
|
||||||
| -------------------------- | ------------------------------------------------------ |
|
|
||||||
| `ANTHROPIC_API_KEY` | For Claude models |
|
|
||||||
| `OPENAI_API_KEY` | For OpenAI models |
|
|
||||||
| `GEMINI_API_KEY` | For Google Gemini models |
|
|
||||||
| `VERTEXAI_PROJECT` | For Google Cloud VertexAI (Gemini) |
|
|
||||||
| `VERTEXAI_LOCATION` | For Google Cloud VertexAI (Gemini) |
|
|
||||||
| `GROQ_API_KEY` | For Groq models |
|
|
||||||
| `AWS_ACCESS_KEY_ID` | For AWS Bedrock (Claude) |
|
|
||||||
| `AWS_SECRET_ACCESS_KEY` | For AWS Bedrock (Claude) |
|
|
||||||
| `AWS_REGION` | For AWS Bedrock (Claude) |
|
|
||||||
| `AZURE_OPENAI_ENDPOINT` | For Azure OpenAI models |
|
|
||||||
| `AZURE_OPENAI_API_KEY` | For Azure OpenAI models (optional when using Entra ID) |
|
|
||||||
| `AZURE_OPENAI_API_VERSION` | For Azure OpenAI models |
|
|
||||||
|
|
||||||
### Configuration File Structure
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"directory": ".opencode"
|
|
||||||
},
|
|
||||||
"providers": {
|
|
||||||
"openai": {
|
|
||||||
"apiKey": "your-api-key",
|
|
||||||
"disabled": false
|
|
||||||
},
|
|
||||||
"anthropic": {
|
|
||||||
"apiKey": "your-api-key",
|
|
||||||
"disabled": false
|
|
||||||
},
|
|
||||||
"groq": {
|
|
||||||
"apiKey": "your-api-key",
|
|
||||||
"disabled": false
|
|
||||||
},
|
|
||||||
"openrouter": {
|
|
||||||
"apiKey": "your-api-key",
|
|
||||||
"disabled": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"agents": {
|
|
||||||
"primary": {
|
|
||||||
"model": "claude-3.7-sonnet",
|
|
||||||
"maxTokens": 5000
|
|
||||||
},
|
|
||||||
"task": {
|
|
||||||
"model": "claude-3.7-sonnet",
|
|
||||||
"maxTokens": 5000
|
|
||||||
},
|
|
||||||
"title": {
|
|
||||||
"model": "claude-3.7-sonnet",
|
|
||||||
"maxTokens": 80
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"mcpServers": {
|
|
||||||
"example": {
|
|
||||||
"type": "stdio",
|
|
||||||
"command": "path/to/mcp-server",
|
|
||||||
"env": [],
|
|
||||||
"args": []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"lsp": {
|
|
||||||
"go": {
|
|
||||||
"disabled": false,
|
|
||||||
"command": "gopls"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"shell": {
|
|
||||||
"path": "/bin/zsh",
|
|
||||||
"args": ["-l"]
|
|
||||||
},
|
|
||||||
"debug": false,
|
|
||||||
"debugLSP": false
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Supported AI Models
|
|
||||||
|
|
||||||
OpenCode supports a variety of AI models from different providers:
|
|
||||||
|
|
||||||
### OpenAI
|
|
||||||
|
|
||||||
- GPT-4.1 family (gpt-4.1, gpt-4.1-mini, gpt-4.1-nano)
|
|
||||||
- GPT-4.5 Preview
|
|
||||||
- GPT-4o family (gpt-4o, gpt-4o-mini)
|
|
||||||
- O1 family (o1, o1-pro, o1-mini)
|
|
||||||
- O3 family (o3, o3-mini)
|
|
||||||
- O4 Mini
|
|
||||||
|
|
||||||
### Anthropic
|
|
||||||
|
|
||||||
- Claude 3.5 Sonnet
|
|
||||||
- Claude 3.5 Haiku
|
|
||||||
- Claude 3.7 Sonnet
|
|
||||||
- Claude 3 Haiku
|
|
||||||
- Claude 3 Opus
|
|
||||||
|
|
||||||
### Google
|
|
||||||
|
|
||||||
- Gemini 2.5
|
|
||||||
- Gemini 2.5 Flash
|
|
||||||
- Gemini 2.0 Flash
|
|
||||||
- Gemini 2.0 Flash Lite
|
|
||||||
|
|
||||||
### AWS Bedrock
|
|
||||||
|
|
||||||
- Claude 3.7 Sonnet
|
|
||||||
|
|
||||||
### Groq
|
|
||||||
|
|
||||||
- Llama 4 Maverick (17b-128e-instruct)
|
|
||||||
- Llama 4 Scout (17b-16e-instruct)
|
|
||||||
- QWEN QWQ-32b
|
|
||||||
- Deepseek R1 distill Llama 70b
|
|
||||||
- Llama 3.3 70b Versatile
|
|
||||||
|
|
||||||
### Azure OpenAI
|
|
||||||
|
|
||||||
- GPT-4.1 family (gpt-4.1, gpt-4.1-mini, gpt-4.1-nano)
|
|
||||||
- GPT-4.5 Preview
|
|
||||||
- GPT-4o family (gpt-4o, gpt-4o-mini)
|
|
||||||
- O1 family (o1, o1-mini)
|
|
||||||
- O3 family (o3, o3-mini)
|
|
||||||
- O4 Mini
|
|
||||||
|
|
||||||
### Google Cloud VertexAI
|
|
||||||
|
|
||||||
- Gemini 2.5
|
|
||||||
- Gemini 2.5 Flash
|
|
||||||
|
|
||||||
## Using Bedrock Models
|
|
||||||
|
|
||||||
To use bedrock models with OpenCode you need three things.
|
|
||||||
|
|
||||||
1. Valid AWS credentials (the env vars: `AWS_SECRET_KEY_ID`, `AWS_SECRET_ACCESS_KEY` and `AWS_REGION`)
|
|
||||||
2. Access to the corresponding model in AWS Bedrock in your region.
|
|
||||||
a. You can request access in the AWS console on the Bedrock -> "Model access" page.
|
|
||||||
3. A correct configuration file. You don't need the `providers` key. Instead you have to prefix your models per agent with `bedrock.` and then a valid model. For now only Claude 3.7 is supported.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"agents": {
|
|
||||||
"primary": {
|
|
||||||
"model": "bedrock.claude-3.7-sonnet",
|
|
||||||
"maxTokens": 5000,
|
|
||||||
"reasoningEffort": ""
|
|
||||||
},
|
|
||||||
"task": {
|
|
||||||
"model": "bedrock.claude-3.7-sonnet",
|
|
||||||
"maxTokens": 5000,
|
|
||||||
"reasoningEffort": ""
|
|
||||||
},
|
|
||||||
"title": {
|
|
||||||
"model": "bedrock.claude-3.7-sonnet",
|
|
||||||
"maxTokens": 80,
|
|
||||||
"reasoningEffort": ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Interactive Mode Usage
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Start OpenCode
|
|
||||||
opencode
|
|
||||||
|
|
||||||
# Start with debug logging
|
|
||||||
opencode -d
|
|
||||||
|
|
||||||
# Start with a specific working directory
|
|
||||||
opencode -c /path/to/project
|
|
||||||
```
|
|
||||||
|
|
||||||
## Non-interactive Prompt Mode
|
|
||||||
|
|
||||||
You can run OpenCode in non-interactive mode by passing a prompt directly as a command-line argument or by piping text into the command. This is useful for scripting, automation, or when you want a quick answer without launching the full TUI.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run a single prompt and print the AI's response to the terminal
|
|
||||||
opencode -p "Explain the use of context in Go"
|
|
||||||
|
|
||||||
# Pipe input to OpenCode (equivalent to using -p flag)
|
|
||||||
echo "Explain the use of context in Go" | opencode
|
|
||||||
|
|
||||||
# Get response in JSON format
|
|
||||||
opencode -p "Explain the use of context in Go" -f json
|
|
||||||
# Or with piped input
|
|
||||||
echo "Explain the use of context in Go" | opencode -f json
|
|
||||||
|
|
||||||
# Run without showing the spinner
|
|
||||||
opencode -p "Explain the use of context in Go" -q
|
|
||||||
# Or with piped input
|
|
||||||
echo "Explain the use of context in Go" | opencode -q
|
|
||||||
|
|
||||||
# Enable verbose logging to stderr
|
|
||||||
opencode -p "Explain the use of context in Go" --verbose
|
|
||||||
# Or with piped input
|
|
||||||
echo "Explain the use of context in Go" | opencode --verbose
|
|
||||||
|
|
||||||
# Restrict the agent to only use specific tools
|
|
||||||
opencode -p "Explain the use of context in Go" --allowedTools=view,ls,glob
|
|
||||||
# Or with piped input
|
|
||||||
echo "Explain the use of context in Go" | opencode --allowedTools=view,ls,glob
|
|
||||||
|
|
||||||
# Prevent the agent from using specific tools
|
|
||||||
opencode -p "Explain the use of context in Go" --excludedTools=bash,edit
|
|
||||||
# Or with piped input
|
|
||||||
echo "Explain the use of context in Go" | opencode --excludedTools=bash,edit
|
|
||||||
```
|
|
||||||
|
|
||||||
In this mode, OpenCode will process your prompt, print the result to standard output, and then exit. All permissions are auto-approved for the session.
|
|
||||||
|
|
||||||
### Tool Restrictions
|
|
||||||
|
|
||||||
You can control which tools the AI assistant has access to in non-interactive mode:
|
|
||||||
|
|
||||||
- `--allowedTools`: Comma-separated list of tools that the agent is allowed to use. Only these tools will be available.
|
|
||||||
- `--excludedTools`: Comma-separated list of tools that the agent is not allowed to use. All other tools will be available.
|
|
||||||
|
|
||||||
These flags are mutually exclusive - you can use either `--allowedTools` or `--excludedTools`, but not both at the same time.
|
|
||||||
|
|
||||||
### Output Formats
|
|
||||||
|
|
||||||
OpenCode supports the following output formats in non-interactive mode:
|
|
||||||
|
|
||||||
| Format | Description |
|
|
||||||
| ------ | ------------------------------- |
|
|
||||||
| `text` | Plain text output (default) |
|
|
||||||
| `json` | Output wrapped in a JSON object |
|
|
||||||
|
|
||||||
The output format is implemented as a strongly-typed `OutputFormat` in the codebase, ensuring type safety and validation when processing outputs.
|
|
||||||
|
|
||||||
## Command-line Flags
|
|
||||||
|
|
||||||
| Flag | Short | Description |
|
|
||||||
| ----------------- | ----- | --------------------------------------------------- |
|
|
||||||
| `--help` | `-h` | Display help information |
|
|
||||||
| `--debug` | `-d` | Enable debug mode |
|
|
||||||
| `--cwd` | `-c` | Set current working directory |
|
|
||||||
| `--prompt` | `-p` | Run a single prompt in non-interactive mode |
|
|
||||||
| `--output-format` | `-f` | Output format for non-interactive mode (text, json) |
|
|
||||||
| `--quiet` | `-q` | Hide spinner in non-interactive mode |
|
|
||||||
| `--verbose` | | Display logs to stderr in non-interactive mode |
|
|
||||||
| `--allowedTools` | | Restrict the agent to only use specified tools |
|
|
||||||
| `--excludedTools` | | Prevent the agent from using specified tools |
|
|
||||||
|
|
||||||
## Keyboard Shortcuts
|
|
||||||
|
|
||||||
### Global Shortcuts
|
|
||||||
|
|
||||||
| Shortcut | Action |
|
|
||||||
| -------- | ------------------------------------------------------- |
|
|
||||||
| `Ctrl+C` | Quit application |
|
|
||||||
| `Ctrl+?` | Toggle help dialog |
|
|
||||||
| `?` | Toggle help dialog (when not in editing mode) |
|
|
||||||
| `Ctrl+L` | View logs |
|
|
||||||
| `Ctrl+A` | Switch session |
|
|
||||||
| `Ctrl+K` | Command dialog |
|
|
||||||
| `Ctrl+O` | Toggle model selection dialog |
|
|
||||||
| `Esc` | Close current overlay/dialog or return to previous mode |
|
|
||||||
|
|
||||||
### Chat Page Shortcuts
|
|
||||||
|
|
||||||
| Shortcut | Action |
|
|
||||||
| -------- | --------------------------------------- |
|
|
||||||
| `Ctrl+N` | Create new session |
|
|
||||||
| `Ctrl+X` | Cancel current operation/generation |
|
|
||||||
| `i` | Focus editor (when not in writing mode) |
|
|
||||||
| `Esc` | Exit writing mode and focus messages |
|
|
||||||
|
|
||||||
### Editor Shortcuts
|
|
||||||
|
|
||||||
| Shortcut | Action |
|
|
||||||
| ------------------- | ----------------------------------------- |
|
|
||||||
| `Ctrl+S` | Send message (when editor is focused) |
|
|
||||||
| `Enter` or `Ctrl+S` | Send message (when editor is not focused) |
|
|
||||||
| `Ctrl+E` | Open external editor |
|
|
||||||
| `Esc` | Blur editor and focus messages |
|
|
||||||
|
|
||||||
### Session Dialog Shortcuts
|
|
||||||
|
|
||||||
| Shortcut | Action |
|
|
||||||
| ---------- | ---------------- |
|
|
||||||
| `↑` or `k` | Previous session |
|
|
||||||
| `↓` or `j` | Next session |
|
|
||||||
| `Enter` | Select session |
|
|
||||||
| `Esc` | Close dialog |
|
|
||||||
|
|
||||||
### Model Dialog Shortcuts
|
|
||||||
|
|
||||||
| Shortcut | Action |
|
|
||||||
| ---------- | ----------------- |
|
|
||||||
| `↑` or `k` | Move up |
|
|
||||||
| `↓` or `j` | Move down |
|
|
||||||
| `←` or `h` | Previous provider |
|
|
||||||
| `→` or `l` | Next provider |
|
|
||||||
| `Esc` | Close dialog |
|
|
||||||
|
|
||||||
### Permission Dialog Shortcuts
|
|
||||||
|
|
||||||
| Shortcut | Action |
|
|
||||||
| ----------------------- | ---------------------------- |
|
|
||||||
| `←` or `left` | Switch options left |
|
|
||||||
| `→` or `right` or `tab` | Switch options right |
|
|
||||||
| `Enter` or `space` | Confirm selection |
|
|
||||||
| `a` | Allow permission |
|
|
||||||
| `A` | Allow permission for session |
|
|
||||||
| `d` | Deny permission |
|
|
||||||
|
|
||||||
### Logs Page Shortcuts
|
|
||||||
|
|
||||||
| Shortcut | Action |
|
|
||||||
| ------------------ | ------------------- |
|
|
||||||
| `Backspace` or `q` | Return to chat page |
|
|
||||||
|
|
||||||
## AI Assistant Tools
|
|
||||||
|
|
||||||
OpenCode's AI assistant has access to various tools to help with coding tasks:
|
|
||||||
|
|
||||||
### File and Code Tools
|
|
||||||
|
|
||||||
| Tool | Description | Parameters |
|
|
||||||
| ------------- | --------------------------- | ---------------------------------------------------------------------------------------- |
|
|
||||||
| `glob` | Find files by pattern | `pattern` (required), `path` (optional) |
|
|
||||||
| `grep` | Search file contents | `pattern` (required), `path` (optional), `include` (optional), `literal_text` (optional) |
|
|
||||||
| `ls` | List directory contents | `path` (optional), `ignore` (optional array of patterns) |
|
|
||||||
| `view` | View file contents | `file_path` (required), `offset` (optional), `limit` (optional) |
|
|
||||||
| `write` | Write to files | `file_path` (required), `content` (required) |
|
|
||||||
| `edit` | Edit files | Various parameters for file editing |
|
|
||||||
| `patch` | Apply patches to files | `file_path` (required), `diff` (required) |
|
|
||||||
| `diagnostics` | Get diagnostics information | `file_path` (optional) |
|
|
||||||
|
|
||||||
### Other Tools
|
|
||||||
|
|
||||||
| Tool | Description | Parameters |
|
|
||||||
| ------- | ------------------------------- | ----------------------------------------------------------- |
|
|
||||||
| `bash` | Execute shell commands | `command` (required), `timeout` (optional) |
|
|
||||||
| `fetch` | Fetch data from URLs | `url` (required), `format` (required), `timeout` (optional) |
|
|
||||||
| `agent` | Run sub-tasks with the AI agent | `prompt` (required) |
|
|
||||||
|
|
||||||
### Shell Configuration
|
|
||||||
|
|
||||||
OpenCode allows you to configure the shell used by the `bash` tool. By default, it uses:
|
|
||||||
|
|
||||||
1. The shell specified in the config file (if provided)
|
|
||||||
2. The shell from the `$SHELL` environment variable (if available)
|
|
||||||
3. Falls back to `/bin/bash` if neither of the above is available
|
|
||||||
|
|
||||||
To configure a custom shell, add a `shell` section to your `.opencode.json` configuration file:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"shell": {
|
|
||||||
"path": "/bin/zsh",
|
|
||||||
"args": ["-l"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
You can specify any shell executable and custom arguments:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"shell": {
|
|
||||||
"path": "/usr/bin/fish",
|
|
||||||
"args": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
OpenCode is built with a modular architecture:
|
|
||||||
|
|
||||||
- **cmd**: Command-line interface using Cobra
|
|
||||||
- **internal/app**: Core application services
|
|
||||||
- **internal/config**: Configuration management
|
|
||||||
- **internal/db**: Database operations and migrations
|
|
||||||
- **internal/llm**: LLM providers and tools integration
|
|
||||||
- **internal/tui**: Terminal UI components and layouts
|
|
||||||
- **internal/logging**: Logging infrastructure
|
|
||||||
- **internal/message**: Message handling
|
|
||||||
- **internal/session**: Session management
|
|
||||||
- **internal/lsp**: Language Server Protocol integration
|
|
||||||
|
|
||||||
## Custom Commands
|
|
||||||
|
|
||||||
OpenCode supports custom commands that can be created by users to quickly send predefined prompts to the AI assistant.
|
|
||||||
|
|
||||||
### Creating Custom Commands
|
|
||||||
|
|
||||||
Custom commands are predefined prompts stored as Markdown files in one of three locations:
|
|
||||||
|
|
||||||
1. **User Commands** (prefixed with `user:`):
|
|
||||||
|
|
||||||
```
|
|
||||||
$XDG_CONFIG_HOME/opencode/commands/
|
|
||||||
```
|
|
||||||
|
|
||||||
(typically `~/.config/opencode/commands/` on Linux/macOS)
|
|
||||||
|
|
||||||
or
|
|
||||||
|
|
||||||
```
|
|
||||||
$HOME/.opencode/commands/
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Project Commands** (prefixed with `project:`):
|
|
||||||
```
|
|
||||||
<PROJECT DIR>/.opencode/commands/
|
|
||||||
```
|
|
||||||
|
|
||||||
Each `.md` file in these directories becomes a custom command. The file name (without extension) becomes the command ID.
|
|
||||||
|
|
||||||
For example, creating a file at `~/.config/opencode/commands/prime-context.md` with content:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
RUN git ls-files
|
|
||||||
READ README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
This creates a command called `user:prime-context`.
|
|
||||||
|
|
||||||
### Command Arguments
|
|
||||||
|
|
||||||
OpenCode supports named arguments in custom commands using placeholders in the format `$NAME` (where NAME consists of uppercase letters, numbers, and underscores, and must start with a letter).
|
|
||||||
|
|
||||||
For example:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# Fetch Context for Issue $ISSUE_NUMBER
|
|
||||||
|
|
||||||
RUN gh issue view $ISSUE_NUMBER --json title,body,comments
|
|
||||||
RUN git grep --author="$AUTHOR_NAME" -n .
|
|
||||||
RUN grep -R "$SEARCH_PATTERN" $DIRECTORY
|
|
||||||
```
|
|
||||||
|
|
||||||
When you run a command with arguments, OpenCode will prompt you to enter values for each unique placeholder. Named arguments provide several benefits:
|
|
||||||
|
|
||||||
- Clear identification of what each argument represents
|
|
||||||
- Ability to use the same argument multiple times
|
|
||||||
- Better organization for commands with multiple inputs
|
|
||||||
|
|
||||||
### Organizing Commands
|
|
||||||
|
|
||||||
You can organize commands in subdirectories:
|
|
||||||
|
|
||||||
```
|
|
||||||
~/.config/opencode/commands/git/commit.md
|
|
||||||
```
|
|
||||||
|
|
||||||
This creates a command with ID `user:git:commit`.
|
|
||||||
|
|
||||||
### Using Custom Commands
|
|
||||||
|
|
||||||
1. Press `Ctrl+K` to open the command dialog
|
|
||||||
2. Select your custom command (prefixed with either `user:` or `project:`)
|
|
||||||
3. Press Enter to execute the command
|
|
||||||
|
|
||||||
The content of the command file will be sent as a message to the AI assistant.
|
|
||||||
|
|
||||||
## MCP (Model Context Protocol)
|
|
||||||
|
|
||||||
OpenCode implements the Model Context Protocol (MCP) to extend its capabilities through external tools. MCP provides a standardized way for the AI assistant to interact with external services and tools.
|
|
||||||
|
|
||||||
### MCP Features
|
|
||||||
|
|
||||||
- **External Tool Integration**: Connect to external tools and services via a standardized protocol
|
|
||||||
- **Tool Discovery**: Automatically discover available tools from MCP servers
|
|
||||||
- **Multiple Connection Types**:
|
|
||||||
- **Stdio**: Communicate with tools via standard input/output
|
|
||||||
- **SSE**: Communicate with tools via Server-Sent Events
|
|
||||||
- **Security**: Permission system for controlling access to MCP tools
|
|
||||||
|
|
||||||
### Configuring MCP Servers
|
|
||||||
|
|
||||||
MCP servers are defined in the configuration file under the `mcpServers` section:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"example": {
|
|
||||||
"type": "stdio",
|
|
||||||
"command": "path/to/mcp-server",
|
|
||||||
"env": [],
|
|
||||||
"args": []
|
|
||||||
},
|
|
||||||
"web-example": {
|
|
||||||
"type": "sse",
|
|
||||||
"url": "https://example.com/mcp",
|
|
||||||
"headers": {
|
|
||||||
"Authorization": "Bearer token"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### MCP Tool Usage
|
|
||||||
|
|
||||||
Once configured, MCP tools are automatically available to the AI assistant alongside built-in tools. They follow the same permission model as other tools, requiring user approval before execution.
|
|
||||||
|
|
||||||
## LSP (Language Server Protocol)
|
|
||||||
|
|
||||||
OpenCode integrates with Language Server Protocol to provide code intelligence features across multiple programming languages.
|
|
||||||
|
|
||||||
### LSP Features
|
|
||||||
|
|
||||||
- **Multi-language Support**: Connect to language servers for different programming languages
|
|
||||||
- **Diagnostics**: Receive error checking and linting information
|
|
||||||
- **File Watching**: Automatically notify language servers of file changes
|
|
||||||
|
|
||||||
### Configuring LSP
|
|
||||||
|
|
||||||
Language servers are configured in the configuration file under the `lsp` section:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"lsp": {
|
|
||||||
"go": {
|
|
||||||
"disabled": false,
|
|
||||||
"command": "gopls"
|
|
||||||
},
|
|
||||||
"typescript": {
|
|
||||||
"disabled": false,
|
|
||||||
"command": "typescript-language-server",
|
|
||||||
"args": ["--stdio"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### LSP Integration with AI
|
|
||||||
|
|
||||||
The AI assistant can access LSP features through the `diagnostics` tool, allowing it to:
|
|
||||||
|
|
||||||
- Check for errors in your code
|
|
||||||
- Suggest fixes based on diagnostics
|
|
||||||
|
|
||||||
While the LSP client implementation supports the full LSP protocol (including completions, hover, definition, etc.), currently only diagnostics are exposed to the AI assistant.
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Go 1.24.0 or higher
|
|
||||||
|
|
||||||
### Building from Source
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Clone the repository
|
|
||||||
git clone https://github.com/sst/opencode.git
|
|
||||||
cd opencode
|
|
||||||
|
|
||||||
# Build
|
|
||||||
go build -o opencode
|
|
||||||
|
|
||||||
# Run
|
|
||||||
./opencode
|
|
||||||
```
|
|
||||||
|
|
||||||
## Acknowledgments
|
|
||||||
|
|
||||||
OpenCode gratefully acknowledges the contributions and support from these key individuals:
|
|
||||||
|
|
||||||
- [@isaacphi](https://github.com/isaacphi) - For the [mcp-language-server](https://github.com/isaacphi/mcp-language-server) project which provided the foundation for our LSP client implementation
|
|
||||||
- [@adamdottv](https://github.com/adamdottv) - For the design direction and UI/UX architecture
|
|
||||||
|
|
||||||
Special thanks to the broader open source community whose tools and libraries have made this project possible.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
OpenCode is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
Contributions are welcome! Here's how you can contribute:
|
|
||||||
|
|
||||||
1. Fork the repository
|
|
||||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
|
||||||
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
|
|
||||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
|
||||||
5. Open a Pull Request
|
|
||||||
|
|
||||||
Please make sure to update tests as appropriate and follow the existing code style.
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ else
|
|||||||
done
|
done
|
||||||
|
|
||||||
if [ -z "$resolved" ]; then
|
if [ -z "$resolved" ]; then
|
||||||
printf "It seems that your package manager failed to install the right version of the SST CLI for your platform. You can try manually installing the \"%s\" package\n" "$name" >&2
|
printf "It seems that your package manager failed to install the right version of the OpenCode CLI for your platform. You can try manually installing the \"%s\" package\n" "$name" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -118,30 +118,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 +173,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`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
import { Global } from "../global"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
|
import path from "path"
|
||||||
|
import { NamedError } from "../util/error"
|
||||||
|
|
||||||
export namespace BunProc {
|
export namespace BunProc {
|
||||||
const log = Log.create({ service: "bun" })
|
const log = Log.create({ service: "bun" })
|
||||||
|
|
||||||
@@ -30,4 +35,24 @@ export namespace BunProc {
|
|||||||
export function which() {
|
export function which() {
|
||||||
return process.execPath
|
return process.execPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const InstallFailedError = NamedError.create(
|
||||||
|
"BunInstallFailedError",
|
||||||
|
z.object({
|
||||||
|
pkg: z.string(),
|
||||||
|
version: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
export async function install(pkg: string, version = "latest") {
|
||||||
|
const dir = path.join(Global.Path.cache, `node_modules`, pkg)
|
||||||
|
if (!(await Bun.file(path.join(dir, "package.json")).exists())) {
|
||||||
|
log.info("installing", { pkg })
|
||||||
|
await BunProc.run(["add", `${pkg}@${version}`], {
|
||||||
|
cwd: Global.Path.cache,
|
||||||
|
}).catch(() => {
|
||||||
|
throw new InstallFailedError({ pkg, version })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import * as prompts from "@clack/prompts"
|
|||||||
import open from "open"
|
import open from "open"
|
||||||
import { UI } from "../ui"
|
import { UI } from "../ui"
|
||||||
import { ModelsDev } from "../../provider/models"
|
import { ModelsDev } from "../../provider/models"
|
||||||
|
import { map, pipe, sort, sortBy, values } from "remeda"
|
||||||
|
|
||||||
export const AuthCommand = cmd({
|
export const AuthCommand = cmd({
|
||||||
command: "auth",
|
command: "auth",
|
||||||
@@ -43,26 +44,53 @@ export const AuthLoginCommand = cmd({
|
|||||||
async handler() {
|
async handler() {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
prompts.intro("Add credential")
|
prompts.intro("Add credential")
|
||||||
const provider = await prompts.select({
|
const providers = await ModelsDev.get()
|
||||||
|
const priority: Record<string, number> = {
|
||||||
|
anthropic: 0,
|
||||||
|
openai: 1,
|
||||||
|
google: 2,
|
||||||
|
}
|
||||||
|
let provider = await prompts.select({
|
||||||
message: "Select provider",
|
message: "Select provider",
|
||||||
maxItems: 2,
|
maxItems: 8,
|
||||||
options: [
|
options: [
|
||||||
|
...pipe(
|
||||||
|
providers,
|
||||||
|
values(),
|
||||||
|
sortBy(
|
||||||
|
(x) => priority[x.id] ?? 99,
|
||||||
|
(x) => x.name ?? x.id,
|
||||||
|
),
|
||||||
|
map((x) => ({
|
||||||
|
label: x.name,
|
||||||
|
value: x.id,
|
||||||
|
hint: priority[x.id] === 0 ? "recommended" : undefined,
|
||||||
|
})),
|
||||||
|
),
|
||||||
{
|
{
|
||||||
label: "Anthropic",
|
value: "other",
|
||||||
value: "anthropic",
|
label: "Other",
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "OpenAI",
|
|
||||||
value: "openai",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Google",
|
|
||||||
value: "google",
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
||||||
|
|
||||||
|
if (provider === "other") {
|
||||||
|
provider = await prompts.text({
|
||||||
|
message: "Enter provider - must match @ai-sdk/<provider>",
|
||||||
|
})
|
||||||
|
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === "amazon-bedrock") {
|
||||||
|
prompts.log.info(
|
||||||
|
"Amazon bedrock can be configured with standard AWS environment variables like AWS_PROFILE or AWS_ACCESS_KEY_ID",
|
||||||
|
)
|
||||||
|
prompts.outro("Done")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (provider === "anthropic") {
|
if (provider === "anthropic") {
|
||||||
const method = await prompts.select({
|
const method = await prompts.select({
|
||||||
message: "Login method",
|
message: "Login method",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { App } from "../../app/app"
|
import { App } from "../../app/app"
|
||||||
|
import { LSP } from "../../lsp"
|
||||||
import { VERSION } from "../version"
|
import { VERSION } from "../version"
|
||||||
import { cmd } from "./cmd"
|
import { cmd } from "./cmd"
|
||||||
|
|
||||||
@@ -6,9 +7,10 @@ export const ScrapCommand = cmd({
|
|||||||
command: "scrap <file>",
|
command: "scrap <file>",
|
||||||
builder: (yargs) =>
|
builder: (yargs) =>
|
||||||
yargs.positional("file", { type: "string", demandOption: true }),
|
yargs.positional("file", { type: "string", demandOption: true }),
|
||||||
async handler() {
|
async handler(args) {
|
||||||
await App.provide({ cwd: process.cwd(), version: VERSION }, async (app) => {
|
await App.provide({ cwd: process.cwd(), version: VERSION }, async (app) => {
|
||||||
Bun.resolveSync("typescript/lib/tsserver.js", app.path.cwd)
|
await LSP.touchFile(args.file, true)
|
||||||
|
console.log(await LSP.diagnostics())
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Log } from "../util/log"
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { App } from "../app/app"
|
import { App } from "../app/app"
|
||||||
import { Filesystem } from "../util/filesystem"
|
import { Filesystem } from "../util/filesystem"
|
||||||
|
import { ModelsDev } from "../provider/models"
|
||||||
|
|
||||||
export namespace Config {
|
export namespace Config {
|
||||||
const log = Log.create({ service: "config" })
|
const log = Log.create({ service: "config" })
|
||||||
@@ -49,7 +50,14 @@ export namespace Config {
|
|||||||
|
|
||||||
export const Info = z
|
export const Info = z
|
||||||
.object({
|
.object({
|
||||||
provider: z.record(z.string(), z.record(z.string(), z.any())).optional(),
|
provider: z
|
||||||
|
.record(
|
||||||
|
ModelsDev.Provider.partial().extend({
|
||||||
|
models: z.record(ModelsDev.Model.partial()),
|
||||||
|
options: z.record(z.any()).optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
tool: z
|
tool: z
|
||||||
.object({
|
.object({
|
||||||
provider: z.record(z.string(), z.string().array()).optional(),
|
provider: z.record(z.string(), z.string().array()).optional(),
|
||||||
|
|||||||
@@ -1,17 +1,54 @@
|
|||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
export namespace ModelsDev {
|
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({
|
||||||
|
name: z.string(),
|
||||||
|
attachment: z.boolean(),
|
||||||
|
reasoning: z.boolean(),
|
||||||
|
temperature: z.boolean(),
|
||||||
|
cost: z.object({
|
||||||
|
input: z.number(),
|
||||||
|
output: z.number(),
|
||||||
|
inputCached: z.number(),
|
||||||
|
outputCached: z.number(),
|
||||||
|
}),
|
||||||
|
limit: z.object({
|
||||||
|
context: z.number(),
|
||||||
|
output: z.number(),
|
||||||
|
}),
|
||||||
|
id: z.string(),
|
||||||
|
})
|
||||||
|
.openapi({
|
||||||
|
ref: "Model.Info",
|
||||||
|
})
|
||||||
|
export type Model = z.infer<typeof Model>
|
||||||
|
|
||||||
|
export const Provider = z
|
||||||
|
.object({
|
||||||
|
name: z.string(),
|
||||||
|
env: z.array(z.string()),
|
||||||
|
id: z.string(),
|
||||||
|
models: z.record(Model),
|
||||||
|
})
|
||||||
|
.openapi({
|
||||||
|
ref: "Provider.Info",
|
||||||
|
})
|
||||||
|
|
||||||
|
export type Provider = z.infer<typeof Provider>
|
||||||
|
|
||||||
export async function get() {
|
export async function get() {
|
||||||
const file = Bun.file(filepath)
|
const file = Bun.file(filepath)
|
||||||
const result = await file.json().catch(() => {})
|
const result = await file.json().catch(() => {})
|
||||||
if (result) {
|
if (result) {
|
||||||
refresh()
|
refresh()
|
||||||
return result
|
return result as Record<string, Provider>
|
||||||
}
|
}
|
||||||
await refresh()
|
await refresh()
|
||||||
return get()
|
return get()
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import { Config } from "../config/config"
|
|||||||
import { mergeDeep, sortBy } from "remeda"
|
import { mergeDeep, sortBy } from "remeda"
|
||||||
import { NoSuchModelError, type LanguageModel, type Provider as SDK } from "ai"
|
import { NoSuchModelError, type LanguageModel, type Provider as SDK } from "ai"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import path from "path"
|
|
||||||
import { Global } from "../global"
|
|
||||||
import { BunProc } from "../bun"
|
import { BunProc } from "../bun"
|
||||||
import { BashTool } from "../tool/bash"
|
import { BashTool } from "../tool/bash"
|
||||||
import { EditTool } from "../tool/edit"
|
import { EditTool } from "../tool/edit"
|
||||||
@@ -29,106 +27,59 @@ import { TaskTool } from "../tool/task"
|
|||||||
export namespace Provider {
|
export namespace Provider {
|
||||||
const log = Log.create({ service: "provider" })
|
const log = Log.create({ service: "provider" })
|
||||||
|
|
||||||
export const Model = z
|
type CustomLoader = (
|
||||||
.object({
|
provider: ModelsDev.Provider,
|
||||||
id: z.string(),
|
) => Promise<Record<string, any> | false>
|
||||||
name: z.string().optional(),
|
|
||||||
attachment: z.boolean(),
|
|
||||||
reasoning: z.boolean().optional(),
|
|
||||||
cost: z.object({
|
|
||||||
input: z.number(),
|
|
||||||
inputCached: z.number(),
|
|
||||||
output: z.number(),
|
|
||||||
outputCached: z.number(),
|
|
||||||
}),
|
|
||||||
limit: z.object({
|
|
||||||
context: z.number(),
|
|
||||||
output: z.number(),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.openapi({
|
|
||||||
ref: "Provider.Model",
|
|
||||||
})
|
|
||||||
export type Model = z.output<typeof Model>
|
|
||||||
|
|
||||||
export const Info = z
|
type Source = "env" | "config" | "custom" | "api"
|
||||||
.object({
|
|
||||||
id: z.string(),
|
|
||||||
name: z.string(),
|
|
||||||
models: z.record(z.string(), Model),
|
|
||||||
})
|
|
||||||
.openapi({
|
|
||||||
ref: "Provider.Info",
|
|
||||||
})
|
|
||||||
export type Info = z.output<typeof Info>
|
|
||||||
|
|
||||||
type Autodetector = (provider: Info) => Promise<
|
const CUSTOM_LOADERS: Record<string, CustomLoader> = {
|
||||||
| {
|
|
||||||
source: Source
|
|
||||||
options: Record<string, any>
|
|
||||||
}
|
|
||||||
| false
|
|
||||||
>
|
|
||||||
|
|
||||||
function env(...keys: string[]) {
|
|
||||||
const result: Autodetector = async () => {
|
|
||||||
for (const key of keys) {
|
|
||||||
if (process.env[key])
|
|
||||||
return {
|
|
||||||
source: "env",
|
|
||||||
options: {},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
type Source = "oauth" | "env" | "config" | "api"
|
|
||||||
|
|
||||||
const AUTODETECT: Record<string, Autodetector> = {
|
|
||||||
async anthropic(provider) {
|
async anthropic(provider) {
|
||||||
const access = await AuthAnthropic.access()
|
const access = await AuthAnthropic.access()
|
||||||
if (access) {
|
if (!access) return false
|
||||||
// claude sub doesn't have usage cost
|
for (const model of Object.values(provider.models)) {
|
||||||
for (const model of Object.values(provider.models)) {
|
model.cost = {
|
||||||
model.cost = {
|
input: 0,
|
||||||
input: 0,
|
inputCached: 0,
|
||||||
inputCached: 0,
|
output: 0,
|
||||||
output: 0,
|
outputCached: 0,
|
||||||
outputCached: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
source: "oauth",
|
|
||||||
options: {
|
|
||||||
apiKey: "",
|
|
||||||
headers: {
|
|
||||||
authorization: `Bearer ${access}`,
|
|
||||||
"anthropic-beta": "oauth-2025-04-20",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return env("ANTHROPIC_API_KEY")(provider)
|
return {
|
||||||
|
apiKey: "",
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${access}`,
|
||||||
|
"anthropic-beta": "oauth-2025-04-20",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"amazon-bedrock": async () => {
|
||||||
|
if (!process.env["AWS_PROFILE"]) return false
|
||||||
|
const { fromNodeProviderChain } = await import(
|
||||||
|
await BunProc.install("@aws-sdk/credential-providers")
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
region: process.env["AWS_REGION"] ?? "us-east-1",
|
||||||
|
credentialProvider: fromNodeProviderChain(),
|
||||||
|
}
|
||||||
},
|
},
|
||||||
google: env("GOOGLE_GENERATIVE_AI_API_KEY"),
|
|
||||||
openai: env("OPENAI_API_KEY"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = App.state("provider", async () => {
|
const state = App.state("provider", async () => {
|
||||||
const config = await Config.get()
|
const config = await Config.get()
|
||||||
const database: Record<string, Provider.Info> = await ModelsDev.get()
|
const database = await ModelsDev.get()
|
||||||
|
|
||||||
const providers: {
|
const providers: {
|
||||||
[providerID: string]: {
|
[providerID: string]: {
|
||||||
source: Source
|
source: Source
|
||||||
info: Provider.Info
|
info: ModelsDev.Provider
|
||||||
options: Record<string, any>
|
options: Record<string, any>
|
||||||
}
|
}
|
||||||
} = {}
|
} = {}
|
||||||
const models = new Map<string, { info: Model; language: LanguageModel }>()
|
const models = new Map<
|
||||||
|
string,
|
||||||
|
{ info: ModelsDev.Model; language: LanguageModel }
|
||||||
|
>()
|
||||||
const sdk = new Map<string, SDK>()
|
const sdk = new Map<string, SDK>()
|
||||||
|
|
||||||
log.info("loading")
|
log.info("loading")
|
||||||
@@ -142,11 +93,7 @@ export namespace Provider {
|
|||||||
if (!provider) {
|
if (!provider) {
|
||||||
providers[id] = {
|
providers[id] = {
|
||||||
source,
|
source,
|
||||||
info: database[id] ?? {
|
info: database[id],
|
||||||
id,
|
|
||||||
name: id,
|
|
||||||
models: [],
|
|
||||||
},
|
|
||||||
options,
|
options,
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -155,22 +102,68 @@ export namespace Provider {
|
|||||||
provider.source = source
|
provider.source = source
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [providerID, fn] of Object.entries(AUTODETECT)) {
|
for (const [providerID, provider] of Object.entries(
|
||||||
const provider = database[providerID]
|
config.provider ?? {},
|
||||||
if (!provider) continue
|
)) {
|
||||||
const result = await fn(provider)
|
const existing = database[providerID]
|
||||||
if (!result) continue
|
const parsed: ModelsDev.Provider = {
|
||||||
mergeProvider(providerID, result.options, result.source)
|
id: providerID,
|
||||||
|
name: provider.name ?? existing?.name ?? providerID,
|
||||||
|
env: provider.env ?? existing?.env ?? [],
|
||||||
|
models: existing?.models ?? {},
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [modelID, model] of Object.entries(provider.models ?? {})) {
|
||||||
|
const existing = parsed.models[modelID]
|
||||||
|
const parsedModel: ModelsDev.Model = {
|
||||||
|
id: modelID,
|
||||||
|
name: model.name ?? existing?.name ?? modelID,
|
||||||
|
attachment: model.attachment ?? existing?.attachment ?? false,
|
||||||
|
reasoning: model.reasoning ?? existing?.reasoning ?? false,
|
||||||
|
temperature: model.temperature ?? existing?.temperature ?? false,
|
||||||
|
cost: model.cost ??
|
||||||
|
existing?.cost ?? {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
inputCached: 0,
|
||||||
|
outputCached: 0,
|
||||||
|
},
|
||||||
|
limit: model.limit ??
|
||||||
|
existing?.limit ?? {
|
||||||
|
context: 0,
|
||||||
|
output: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
parsed.models[modelID] = parsedModel
|
||||||
|
}
|
||||||
|
database[providerID] = parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [providerID, info] of Object.entries(await Auth.all())) {
|
// load env
|
||||||
if (info.type === "api") {
|
for (const [providerID, provider] of Object.entries(database)) {
|
||||||
mergeProvider(providerID, { apiKey: info.key }, "api")
|
if (provider.env.some((item) => process.env[item])) {
|
||||||
|
mergeProvider(providerID, {}, "env")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [providerID, options] of Object.entries(config.provider ?? {})) {
|
// load apikeys
|
||||||
mergeProvider(providerID, options, "config")
|
for (const [providerID, provider] of Object.entries(await Auth.all())) {
|
||||||
|
if (provider.type === "api") {
|
||||||
|
mergeProvider(providerID, { apiKey: provider.key }, "api")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// load custom
|
||||||
|
for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) {
|
||||||
|
const result = await fn(database[providerID])
|
||||||
|
if (result) mergeProvider(providerID, result, "custom")
|
||||||
|
}
|
||||||
|
|
||||||
|
// load config
|
||||||
|
for (const [providerID, provider] of Object.entries(
|
||||||
|
config.provider ?? {},
|
||||||
|
)) {
|
||||||
|
mergeProvider(providerID, provider.options ?? {}, "config")
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const providerID of Object.keys(providers)) {
|
for (const providerID of Object.keys(providers)) {
|
||||||
@@ -193,21 +186,9 @@ export namespace Provider {
|
|||||||
const s = await state()
|
const s = await state()
|
||||||
const existing = s.sdk.get(providerID)
|
const existing = s.sdk.get(providerID)
|
||||||
if (existing) return existing
|
if (existing) return existing
|
||||||
const dir = path.join(
|
const mod = await import(
|
||||||
Global.Path.cache,
|
await BunProc.install(`@ai-sdk/${providerID}`, "alpha")
|
||||||
`node_modules`,
|
|
||||||
`@ai-sdk`,
|
|
||||||
providerID,
|
|
||||||
)
|
)
|
||||||
if (!(await Bun.file(path.join(dir, "package.json")).exists())) {
|
|
||||||
log.info("installing", {
|
|
||||||
providerID,
|
|
||||||
})
|
|
||||||
await BunProc.run(["add", `@ai-sdk/${providerID}@alpha`], {
|
|
||||||
cwd: Global.Path.cache,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const mod = await import(path.join(dir))
|
|
||||||
const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!]
|
const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!]
|
||||||
const loaded = fn(s.providers[providerID]?.options)
|
const loaded = fn(s.providers[providerID]?.options)
|
||||||
s.sdk.set(providerID, loaded)
|
s.sdk.set(providerID, loaded)
|
||||||
@@ -261,7 +242,7 @@ export namespace Provider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const priority = ["gemini-2.5-pro-preview", "codex-mini", "claude-sonnet-4"]
|
const priority = ["gemini-2.5-pro-preview", "codex-mini", "claude-sonnet-4"]
|
||||||
export function sort(models: Model[]) {
|
export function sort(models: ModelsDev.Model[]) {
|
||||||
return sortBy(
|
return sortBy(
|
||||||
models,
|
models,
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Global } from "../global"
|
|||||||
import { mapValues } from "remeda"
|
import { mapValues } from "remeda"
|
||||||
import { NamedError } from "../util/error"
|
import { NamedError } from "../util/error"
|
||||||
import { Fzf } from "../external/fzf"
|
import { Fzf } from "../external/fzf"
|
||||||
|
import { ModelsDev } from "../provider/models"
|
||||||
|
|
||||||
const ERRORS = {
|
const ERRORS = {
|
||||||
400: {
|
400: {
|
||||||
@@ -406,7 +407,7 @@ export namespace Server {
|
|||||||
"application/json": {
|
"application/json": {
|
||||||
schema: resolver(
|
schema: resolver(
|
||||||
z.object({
|
z.object({
|
||||||
providers: Provider.Info.array(),
|
providers: ModelsDev.Provider.array(),
|
||||||
default: z.record(z.string(), z.string()),
|
default: z.record(z.string(), z.string()),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -421,7 +422,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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -92,12 +92,6 @@ 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)
|
|
||||||
share(result.id).then((share) => {
|
|
||||||
update(result.id, (draft) => {
|
|
||||||
draft.share = share
|
|
||||||
})
|
|
||||||
})
|
|
||||||
Bus.publish(Event.Updated, {
|
Bus.publish(Event.Updated, {
|
||||||
info: result,
|
info: result,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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?
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
||||||
|
|||||||
+22
-31
@@ -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,
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 276 KiB |
Reference in New Issue
Block a user