Compare commits
118 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fe163416d | ||
|
|
d054f88130 | ||
|
|
b929b4f4b9 | ||
|
|
4c0c83b02d | ||
|
|
d6d45bdc63 | ||
|
|
13a83721b0 | ||
|
|
f0edffbae9 | ||
|
|
8131bee49a | ||
|
|
b5f44ae13f | ||
|
|
0d23f2a7fd | ||
|
|
ac096d84ad | ||
|
|
fcaf0e6dbf | ||
|
|
19e259d90d | ||
|
|
2c9fd1e776 | ||
|
|
63996c4189 | ||
|
|
c7bb7ce4de | ||
|
|
c8eb1b24c3 | ||
|
|
b9f894f1e9 | ||
|
|
7c0d10a4ce | ||
|
|
06af406146 | ||
|
|
0e3458b112 | ||
|
|
2d15c683e0 | ||
|
|
3c94d26570 | ||
|
|
1a553e525f | ||
|
|
3c4e966216 | ||
|
|
0721620ed8 | ||
|
|
9fc6734f32 | ||
|
|
e1733a423d | ||
|
|
d42e3db7e0 | ||
|
|
cdb26f6d83 | ||
|
|
fe05edaa79 | ||
|
|
7d174767b0 | ||
|
|
c5eefd1752 | ||
|
|
77a6b3bdd6 | ||
|
|
7effff56c0 | ||
|
|
e30fba0d3c | ||
|
|
7fbb2ca9a6 | ||
|
|
230d0a1510 | ||
|
|
46ff2c0ae0 | ||
|
|
b8a89dab0f | ||
|
|
7351e12886 | ||
|
|
38879dee2d | ||
|
|
c4ff8dd205 | ||
|
|
0e035b3115 | ||
|
|
b855511d9a | ||
|
|
783faf554d | ||
|
|
bfd4269d7d | ||
|
|
25f78b053b | ||
|
|
87f260ee17 | ||
|
|
12931a869d | ||
|
|
f759e1804d | ||
|
|
c9b4564d36 | ||
|
|
d097c546db | ||
|
|
adb54521b4 | ||
|
|
2ea0399aa7 | ||
|
|
fa1266263d | ||
|
|
fe109c921e | ||
|
|
37bb8895fe | ||
|
|
89b95be4de | ||
|
|
eaf295bac7 | ||
|
|
27d3cec477 | ||
|
|
574d494c3c | ||
|
|
0239761f31 | ||
|
|
a53f9165e9 | ||
|
|
ffc231bd8b | ||
|
|
3cf4ef56fb | ||
|
|
c738e26438 | ||
|
|
9c6aa82ac1 | ||
|
|
ef74d97491 | ||
|
|
af892e5432 | ||
|
|
d7aca6230d | ||
|
|
0f9c2c5c27 | ||
|
|
6a261dedb4 | ||
|
|
ec928d88b5 | ||
|
|
59a5f120c0 | ||
|
|
ce07f80b19 | ||
|
|
168fd9b2e3 | ||
|
|
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 | ||
|
|
9356b6c35a | ||
|
|
29a6603a89 | ||
|
|
a454ba8895 | ||
|
|
5eae7aef0e | ||
|
|
1031bceef7 |
37
.github/workflows/build.yml
vendored
@@ -1,37 +0,0 @@
|
||||
name: build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- run: git fetch --force --tags
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ">=1.23.2"
|
||||
cache: true
|
||||
cache-dependency-path: go.sum
|
||||
|
||||
- run: go mod download
|
||||
|
||||
- uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: latest
|
||||
args: build --snapshot --clean
|
||||
10
.github/workflows/publish.yml
vendored
@@ -4,7 +4,7 @@ on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- dontlook
|
||||
- dev
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
@@ -34,11 +34,19 @@ jobs:
|
||||
with:
|
||||
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
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.AUR_KEY }}" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
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
|
||||
run: |
|
||||
|
||||
4
.gitignore
vendored
@@ -3,3 +3,7 @@ node_modules
|
||||
.opencode
|
||||
.sst
|
||||
.env
|
||||
.idea
|
||||
.vscode
|
||||
app.log
|
||||
gopls.log
|
||||
712
README.md
@@ -1,658 +1,178 @@
|
||||
◧ opencode
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai">
|
||||
<picture>
|
||||
<source srcset="packages/web/src/assets/logo-dark.svg" media="(prefers-color-scheme: dark)">
|
||||
<source srcset="packages/web/src/assets/logo-light.svg" media="(prefers-color-scheme: light)">
|
||||
<img src="packages/web/src/assets/logo-light.svg" alt="opencode logo">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||
<a href="https://github.com/sst/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/sst/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||
</p>
|
||||
|
||||

|
||||
---
|
||||
|
||||
> **⚠️ 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
|
||||
[](https://opencode.ai)
|
||||
|
||||
OpenCode is a Go-based CLI application that brings AI assistance to your terminal. It provides a TUI (Terminal User Interface) for interacting with various AI models to help with coding tasks, debugging, and more.
|
||||
|
||||
## Features
|
||||
|
||||
- **Interactive TUI**: Built with [Bubble Tea](https://github.com/charmbracelet/bubbletea) for a smooth terminal experience
|
||||
- **Multiple AI Providers**: Support for OpenAI, Anthropic Claude, Google Gemini, AWS Bedrock, Groq, Azure OpenAI, and OpenRouter
|
||||
- **Session Management**: Save and manage multiple conversation sessions
|
||||
- **Tool Integration**: AI can execute commands, search files, and modify code
|
||||
- **Vim-like Editor**: Integrated editor with text input capabilities
|
||||
- **Persistent Storage**: SQLite database for storing conversations and sessions
|
||||
- **LSP Integration**: Language Server Protocol support for code intelligence
|
||||
- **File Change Tracking**: Track and visualize file changes during sessions
|
||||
- **External Editor Support**: Open your preferred editor for composing messages
|
||||
- **Named Arguments for Custom Commands**: Create powerful custom commands with multiple named placeholders
|
||||
|
||||
## Installation
|
||||
|
||||
### Using the Install Script
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Install the latest version
|
||||
# YOLO
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
# Install a specific version
|
||||
curl -fsSL https://opencode.ai/install | VERSION=0.1.0 bash
|
||||
# Package managers
|
||||
npm i -g opencode-ai@latest # or bun/pnpm/yarn
|
||||
brew install sst/tap/opencode # macOS
|
||||
paru -S opencode-bin # Arch Linux
|
||||
```
|
||||
|
||||
### Using Homebrew (macOS and Linux)
|
||||
> **Note:** Remove versions older than 0.1.x before installing
|
||||
|
||||
### Providers
|
||||
|
||||
The recommended approach is to sign up for Claude Pro or Max, run `opencode auth login`, and select Anthropic. It's the most cost-effective way to use opencode.
|
||||
|
||||
opencode is powered by the provider list at [Models.dev](https://models.dev), so you can use `opencode auth login` to configure API keys for any provider you'd like to use. This is stored in `~/.local/share/opencode/auth.json`.
|
||||
|
||||
```bash
|
||||
brew install sst/tap/opencode
|
||||
$ opencode auth login
|
||||
|
||||
┌ Add credential
|
||||
│
|
||||
◆ Select provider
|
||||
│ ● Anthropic (recommended)
|
||||
│ ○ OpenAI
|
||||
│ ○ Google
|
||||
│ ○ Amazon Bedrock
|
||||
│ ○ Azure
|
||||
│ ○ DeepSeek
|
||||
│ ○ Groq
|
||||
│ ...
|
||||
└
|
||||
```
|
||||
|
||||
### Using AUR (Arch Linux)
|
||||
The Models.dev dataset is also used to detect common environment variables like `OPENAI_API_KEY` to autoload that provider.
|
||||
|
||||
```bash
|
||||
# Using yay
|
||||
yay -S opencode-bin
|
||||
If there are additional providers you want to use you can submit a PR to the [Models.dev repo](https://github.com/sst/models.dev). If configuring just for yourself check out the Config section below.
|
||||
|
||||
# Using paru
|
||||
paru -S opencode-bin
|
||||
```
|
||||
### Project Config
|
||||
|
||||
### Using Go
|
||||
Project configuration is optional. You can place an `opencode.json` file in the root of your repo, and it'll be loaded.
|
||||
|
||||
```bash
|
||||
go install github.com/sst/opencode@latest
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
OpenCode looks for configuration in the following locations:
|
||||
|
||||
- `$HOME/.opencode.json`
|
||||
- `$XDG_CONFIG_HOME/opencode/.opencode.json`
|
||||
- `./.opencode.json` (local directory)
|
||||
|
||||
### Environment Variables
|
||||
|
||||
You can configure OpenCode using environment variables:
|
||||
|
||||
| Environment Variable | Purpose |
|
||||
| -------------------------- | ------------------------------------------------------ |
|
||||
| `ANTHROPIC_API_KEY` | For Claude models |
|
||||
| `OPENAI_API_KEY` | For OpenAI models |
|
||||
| `GEMINI_API_KEY` | For Google Gemini models |
|
||||
| `VERTEXAI_PROJECT` | For Google Cloud VertexAI (Gemini) |
|
||||
| `VERTEXAI_LOCATION` | For Google Cloud VertexAI (Gemini) |
|
||||
| `GROQ_API_KEY` | For Groq models |
|
||||
| `AWS_ACCESS_KEY_ID` | For AWS Bedrock (Claude) |
|
||||
| `AWS_SECRET_ACCESS_KEY` | For AWS Bedrock (Claude) |
|
||||
| `AWS_REGION` | For AWS Bedrock (Claude) |
|
||||
| `AZURE_OPENAI_ENDPOINT` | For Azure OpenAI models |
|
||||
| `AZURE_OPENAI_API_KEY` | For Azure OpenAI models (optional when using Entra ID) |
|
||||
| `AZURE_OPENAI_API_VERSION` | For Azure OpenAI models |
|
||||
|
||||
### Configuration File Structure
|
||||
|
||||
```json
|
||||
```json title="opencode.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
|
||||
"$schema": "http://opencode.ai/config.json"
|
||||
}
|
||||
```
|
||||
|
||||
## Supported AI Models
|
||||
#### MCP
|
||||
|
||||
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
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"agents": {
|
||||
"primary": {
|
||||
"model": "bedrock.claude-3.7-sonnet",
|
||||
"maxTokens": 5000,
|
||||
"reasoningEffort": ""
|
||||
"$schema": "http://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"localmcp": {
|
||||
"type": "local",
|
||||
"command": ["bun", "x", "my-mcp-command"],
|
||||
"environment": {
|
||||
"MY_ENV_VAR": "my_env_var_value"
|
||||
}
|
||||
},
|
||||
"task": {
|
||||
"model": "bedrock.claude-3.7-sonnet",
|
||||
"maxTokens": 5000,
|
||||
"reasoningEffort": ""
|
||||
},
|
||||
"title": {
|
||||
"model": "bedrock.claude-3.7-sonnet",
|
||||
"maxTokens": 80,
|
||||
"reasoningEffort": ""
|
||||
"remotemcp": {
|
||||
"type": "remote",
|
||||
"url": "https://my-mcp-server.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Interactive Mode Usage
|
||||
#### Providers
|
||||
|
||||
```bash
|
||||
# Start OpenCode
|
||||
opencode
|
||||
You can use opencode with any provider listed at [here](https://ai-sdk.dev/providers/ai-sdk-providers). Be sure to specify the npm package to use to load the provider.
|
||||
|
||||
# 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
|
||||
```json title="opencode.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"
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"ollama": {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"options": {
|
||||
"baseURL": "http://localhost:11434/v1"
|
||||
},
|
||||
"models": {
|
||||
"llama2": {
|
||||
"name": "llama2"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### MCP Tool Usage
|
||||
### Contributing
|
||||
|
||||
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.
|
||||
To run opencode locally you need.
|
||||
|
||||
## LSP (Language Server Protocol)
|
||||
- Bun
|
||||
- Golang 1.24.x
|
||||
|
||||
OpenCode integrates with Language Server Protocol to provide code intelligence features across multiple programming languages.
|
||||
To run.
|
||||
|
||||
### LSP Features
|
||||
```bash
|
||||
$ bun install
|
||||
$ cd packages/opencode
|
||||
$ bun run src/index.ts
|
||||
```
|
||||
|
||||
- **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
|
||||
### FAQ
|
||||
|
||||
### Configuring LSP
|
||||
#### How do I use this with OpenRouter?
|
||||
|
||||
Language servers are configured in the configuration file under the `lsp` section:
|
||||
OpenRouter is not in the Models.dev database yet, but you can configure it manually.
|
||||
|
||||
```json
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"lsp": {
|
||||
"go": {
|
||||
"disabled": false,
|
||||
"command": "gopls"
|
||||
},
|
||||
"typescript": {
|
||||
"disabled": false,
|
||||
"command": "typescript-language-server",
|
||||
"args": ["--stdio"]
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"openrouter": {
|
||||
"npm": "@openrouter/ai-sdk-provider",
|
||||
"name": "OpenRouter",
|
||||
"options": {
|
||||
"apiKey": "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
},
|
||||
"models": {
|
||||
"anthropic/claude-3.5-sonnet": {
|
||||
"name": "Claude 3.5 Sonnet"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### LSP Integration with AI
|
||||
#### How is this different than Claude Code?
|
||||
|
||||
The AI assistant can access LSP features through the `diagnostics` tool, allowing it to:
|
||||
It's very similar to Claude Code in terms of capability. Here are the key differences:
|
||||
|
||||
- Check for errors in your code
|
||||
- Suggest fixes based on diagnostics
|
||||
- 100% open source
|
||||
- Not coupled to any provider. Although Anthropic is recommended, opencode can be used with OpenAI, Google or even local models. As models evolve the gaps between them will close and pricing will drop so being provider agnostic is important.
|
||||
- A focus on TUI. opencode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal.
|
||||
- A client/server architecture. This for example can allow opencode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients.
|
||||
|
||||
While the LSP client implementation supports the full LSP protocol (including completions, hover, definition, etc.), currently only diagnostics are exposed to the AI assistant.
|
||||
#### What about Windows support?
|
||||
|
||||
## Development
|
||||
There are some minor problems blocking opencode from working on windows. We are working on on them now. You'll need to use WSL for now.
|
||||
|
||||
### Prerequisites
|
||||
#### What's the other repo?
|
||||
|
||||
- Go 1.24.0 or higher
|
||||
The other confusingly named repo has no relation to this one. You can [read the story behind it here](https://x.com/thdxr/status/1933561254481666466).
|
||||
|
||||
### 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.
|
||||
**Join our community** [YouTube](https://www.youtube.com/c/sst-dev) | [X.com](https://x.com/SST_dev)
|
||||
|
||||
29
bun.lock
@@ -50,6 +50,7 @@
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/yargs": "17.0.33",
|
||||
"typescript": "catalog:",
|
||||
"zod-to-json-schema": "3.24.5",
|
||||
},
|
||||
},
|
||||
"packages/web": {
|
||||
@@ -90,16 +91,18 @@
|
||||
},
|
||||
"catalog": {
|
||||
"@types/node": "22.13.9",
|
||||
"ai": "5.0.0-alpha.7",
|
||||
"ai": "4.3.16",
|
||||
"typescript": "5.8.2",
|
||||
"zod": "3.24.2",
|
||||
},
|
||||
"packages": {
|
||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@1.0.0-alpha.7", "", { "dependencies": { "@ai-sdk/provider": "2.0.0-alpha.7", "@ai-sdk/provider-utils": "3.0.0-alpha.7" }, "peerDependencies": { "zod": "^3.24.0" } }, "sha512-gz1V165eiJnQIexfLyKm11vimrmQ3zdcJhPpjeLFmDU9wrvZwLuklfZ0WgfYSb+EjiP1cKypwt6JSGvWkfKIAQ=="],
|
||||
"@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
|
||||
|
||||
"@ai-sdk/provider": ["@ai-sdk/provider@2.0.0-alpha.7", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-lhdrARU3SSmt5p/GNNK7VhazvZpKSCIOjpHUfX7f5jIhVGi/vvlxP1rD6Go57nn1MtuGKNqL04AebSRFDQsQbw=="],
|
||||
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="],
|
||||
|
||||
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0-alpha.7", "", { "dependencies": { "@ai-sdk/provider": "2.0.0-alpha.7", "@standard-schema/spec": "^1.0.0", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-AYkT3jskmo7Lwzijo/yHKD1jC+UZizsROO8ULTg9aJZUwR4ABZzAxh4NxDIEy4TWRfBGufp+/9ICHAn6pkU71w=="],
|
||||
"@ai-sdk/react": ["@ai-sdk/react@1.2.12", "", { "dependencies": { "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/ui-utils": "1.2.11", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["zod"] }, "sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g=="],
|
||||
|
||||
"@ai-sdk/ui-utils": ["@ai-sdk/ui-utils@1.2.11", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w=="],
|
||||
|
||||
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
|
||||
|
||||
@@ -429,6 +432,8 @@
|
||||
|
||||
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
||||
|
||||
"@types/diff-match-patch": ["@types/diff-match-patch@1.0.36", "", {}, "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
|
||||
|
||||
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
|
||||
@@ -473,7 +478,7 @@
|
||||
|
||||
"acorn-walk": ["acorn-walk@8.3.2", "", {}, "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A=="],
|
||||
|
||||
"ai": ["ai@5.0.0-alpha.7", "", { "dependencies": { "@ai-sdk/gateway": "1.0.0-alpha.7", "@ai-sdk/provider": "2.0.0-alpha.7", "@ai-sdk/provider-utils": "3.0.0-alpha.7", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-ShCk3frIMdVtK9knvWKiFS7N6Vwnf8mLMv670+T//W9oqfoetSVPBhTF6Dy+oDM/bjVSsBf1BuYImLDvHICOIQ=="],
|
||||
"ai": ["ai@4.3.16", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/react": "1.2.12", "@ai-sdk/ui-utils": "1.2.11", "@opentelemetry/api": "1.9.0", "jsondiffpatch": "0.6.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["react"] }, "sha512-KUDwlThJ5tr2Vw0A1ZkbDKNME3wzWhuVfAOwIvFUzl1TPVDFAXDFTXio3p+jaKneB+dKNCvFFlolYmmgHttG1g=="],
|
||||
|
||||
"ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="],
|
||||
|
||||
@@ -685,6 +690,8 @@
|
||||
|
||||
"diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="],
|
||||
|
||||
"diff-match-patch": ["diff-match-patch@1.0.5", "", {}, "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="],
|
||||
|
||||
"direction": ["direction@2.0.1", "", { "bin": { "direction": "cli.js" } }, "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA=="],
|
||||
|
||||
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
|
||||
@@ -969,6 +976,8 @@
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"jsondiffpatch": ["jsondiffpatch@0.6.0", "", { "dependencies": { "@types/diff-match-patch": "^1.0.36", "chalk": "^5.3.0", "diff-match-patch": "^1.0.5" }, "bin": { "jsondiffpatch": "bin/jsondiffpatch.js" } }, "sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ=="],
|
||||
|
||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
|
||||
"klona": ["klona@2.0.6", "", {}, "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA=="],
|
||||
@@ -1273,6 +1282,8 @@
|
||||
|
||||
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
|
||||
|
||||
"react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
|
||||
|
||||
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||
|
||||
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
@@ -1353,6 +1364,8 @@
|
||||
|
||||
"sax": ["sax@1.2.1", "", {}, "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA=="],
|
||||
|
||||
"secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="],
|
||||
|
||||
"semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
|
||||
"send": ["send@1.2.0", "", { "dependencies": { "debug": "^4.3.5", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.0", "mime-types": "^3.0.1", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.1" } }, "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw=="],
|
||||
@@ -1453,6 +1466,8 @@
|
||||
|
||||
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"swr": ["swr@2.3.3", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A=="],
|
||||
|
||||
"tar-fs": ["tar-fs@3.0.9", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-XF4w9Xp+ZQgifKakjZYmFdkLoSWd34VGKcsTCwlNWM7QG3ZbaxnTsaBwnjFZqHRf/rROxaR8rXnbtwdvaDI+lA=="],
|
||||
|
||||
"tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="],
|
||||
@@ -1461,6 +1476,8 @@
|
||||
|
||||
"thread-stream": ["thread-stream@0.15.2", "", { "dependencies": { "real-require": "^0.1.0" } }, "sha512-UkEhKIg2pD+fjkHQKyJO3yoIvAP3N6RlNFt2dUhcS1FGvCD1cQa1M/PGknCLFIyZdtJOWQjejp7bdNqmN7zwdA=="],
|
||||
|
||||
"throttleit": ["throttleit@2.1.0", "", {}, "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw=="],
|
||||
|
||||
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
|
||||
|
||||
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
||||
@@ -1545,6 +1562,8 @@
|
||||
|
||||
"url": ["url@0.10.3", "", { "dependencies": { "punycode": "1.3.2", "querystring": "0.2.0" } }, "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ=="],
|
||||
|
||||
"use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="],
|
||||
|
||||
"util": ["util@0.12.5", "", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
11
infra/app.ts
@@ -31,17 +31,6 @@ export const api = new sst.cloudflare.Worker("Api", {
|
||||
},
|
||||
})
|
||||
|
||||
// new sst.cloudflare.StaticSite("Web", {
|
||||
// path: "packages/web",
|
||||
// domain,
|
||||
// environment: {
|
||||
// VITE_API_URL: api.url,
|
||||
// },
|
||||
// build: {
|
||||
// command: "bun run build",
|
||||
// output: "dist",
|
||||
// },
|
||||
// })
|
||||
new sst.cloudflare.x.Astro("Web", {
|
||||
domain,
|
||||
path: "packages/web",
|
||||
|
||||
25
install
@@ -12,23 +12,28 @@ requested_version=${VERSION:-}
|
||||
|
||||
os=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
if [[ "$os" == "darwin" ]]; then
|
||||
os="mac"
|
||||
os="darwin"
|
||||
fi
|
||||
arch=$(uname -m)
|
||||
|
||||
if [[ "$arch" == "aarch64" ]]; then
|
||||
arch="arm64"
|
||||
elif [[ "$arch" == "x86_64" ]]; then
|
||||
arch="x64"
|
||||
fi
|
||||
|
||||
filename="$APP-$os-$arch.tar.gz"
|
||||
filename="$APP-$os-$arch.zip"
|
||||
|
||||
|
||||
case "$filename" in
|
||||
*"-linux-"*)
|
||||
[[ "$arch" == "x86_64" || "$arch" == "arm64" || "$arch" == "i386" ]] || exit 1
|
||||
[[ "$arch" == "x64" || "$arch" == "arm64" ]] || exit 1
|
||||
;;
|
||||
*"-mac-"*)
|
||||
[[ "$arch" == "x86_64" || "$arch" == "arm64" ]] || exit 1
|
||||
*"-darwin-"*)
|
||||
[[ "$arch" == "x64" || "$arch" == "arm64" ]] || exit 1
|
||||
;;
|
||||
*"-windows-"*)
|
||||
[[ "$arch" == "x64" ]] || exit 1
|
||||
;;
|
||||
*)
|
||||
echo "${RED}Unsupported OS/Arch: $os/$arch${NC}"
|
||||
@@ -88,8 +93,9 @@ check_version() {
|
||||
download_and_install() {
|
||||
print_message info "Downloading ${ORANGE}opencode ${GREEN}version: ${YELLOW}$specific_version ${GREEN}..."
|
||||
mkdir -p opencodetmp && cd opencodetmp
|
||||
curl -# -L $url | tar xz
|
||||
mv opencode $INSTALL_DIR
|
||||
curl -# -L -o "$filename" "$url"
|
||||
unzip -q "$filename"
|
||||
mv opencode "$INSTALL_DIR"
|
||||
cd .. && rm -rf opencodetmp
|
||||
}
|
||||
|
||||
@@ -101,7 +107,9 @@ add_to_path() {
|
||||
local config_file=$1
|
||||
local command=$2
|
||||
|
||||
if [[ -w $config_file ]]; then
|
||||
if grep -Fxq "$command" "$config_file"; then
|
||||
print_message info "Command already exists in $config_file, skipping write."
|
||||
elif [[ -w $config_file ]]; then
|
||||
echo -e "\n# opencode" >> "$config_file"
|
||||
echo "$command" >> "$config_file"
|
||||
print_message info "Successfully added ${ORANGE}opencode ${GREEN}to \$PATH in $config_file"
|
||||
@@ -167,6 +175,7 @@ if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
|
||||
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
|
||||
;;
|
||||
*)
|
||||
export PATH=$INSTALL_DIR:$PATH
|
||||
print_message warning "Manually add the directory to $config_file (or similar):"
|
||||
print_message info " export PATH=$INSTALL_DIR:\$PATH"
|
||||
;;
|
||||
|
||||
5
opencode.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {},
|
||||
"provider": {}
|
||||
}
|
||||
@@ -5,8 +5,7 @@
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.2.14",
|
||||
"scripts": {
|
||||
"typecheck": "bun run --filter='*' typecheck",
|
||||
"dev": "sst dev"
|
||||
"typecheck": "bun run --filter='*' typecheck"
|
||||
},
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
@@ -16,7 +15,7 @@
|
||||
"typescript": "5.8.2",
|
||||
"@types/node": "22.13.9",
|
||||
"zod": "3.24.2",
|
||||
"ai": "5.0.0-alpha.7"
|
||||
"ai": "4.3.16"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# OpenCode Agent Guidelines
|
||||
# opencode agent guidelines
|
||||
|
||||
## Build/Test Commands
|
||||
|
||||
@@ -16,9 +16,19 @@
|
||||
- **Naming**: camelCase for variables/functions, PascalCase for classes/namespaces
|
||||
- **Error handling**: Use Result patterns, avoid throwing exceptions in tools
|
||||
- **File structure**: Namespace-based organization (e.g., `Tool.define()`, `Session.create()`)
|
||||
|
||||
## IMPORTANT
|
||||
|
||||
- Try to keep things in one function unless composable or reusable
|
||||
- DO NOT do unnecessary destructuring of variables
|
||||
- DO NOT use else statements unless necessary
|
||||
- DO NOT use try catch if it can be avoided
|
||||
- DO NOT use `else` statements unless necessary
|
||||
- DO NOT use `try`/`catch` if it can be avoided
|
||||
- AVOID `try`/`catch` where possible
|
||||
- AVOID `else` statements
|
||||
- AVOID using `any` type
|
||||
- AVOID `let` statements
|
||||
- PREFER single word variable names where possible
|
||||
- Use as many bun apis as possible like Bun.file()
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -27,4 +37,3 @@
|
||||
- **Validation**: All inputs validated with Zod schemas
|
||||
- **Logging**: Use `Log.create({ service: "name" })` pattern
|
||||
- **Storage**: Use `Storage` namespace for persistence
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ else
|
||||
done
|
||||
|
||||
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
|
||||
fi
|
||||
fi
|
||||
|
||||
155
packages/opencode/config.schema.json
Normal file
@@ -0,0 +1,155 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"provider": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"env": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"npm": {
|
||||
"type": "string"
|
||||
},
|
||||
"models": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"attachment": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"reasoning": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"temperature": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"cost": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"input": {
|
||||
"type": "number"
|
||||
},
|
||||
"output": {
|
||||
"type": "number"
|
||||
},
|
||||
"inputCached": {
|
||||
"type": "number"
|
||||
},
|
||||
"outputCached": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"input",
|
||||
"output",
|
||||
"inputCached",
|
||||
"outputCached"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"limit": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"context": {
|
||||
"type": "number"
|
||||
},
|
||||
"output": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"context",
|
||||
"output"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"models"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"mcp": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "local"
|
||||
},
|
||||
"command": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"command"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "remote"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"url"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||
}
|
||||
@@ -5,7 +5,8 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "bun run ./src/index.ts"
|
||||
},
|
||||
"exports": {
|
||||
"./*": [
|
||||
@@ -18,7 +19,8 @@
|
||||
"@types/bun": "latest",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/yargs": "17.0.33",
|
||||
"typescript": "catalog:"
|
||||
"typescript": "catalog:",
|
||||
"zod-to-json-schema": "3.24.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "0.11.0",
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# Maintainer: dax
|
||||
# Maintainer: adam
|
||||
|
||||
pkgname='opencode-bin'
|
||||
pkgver={{VERSION}}
|
||||
pkgrel=1
|
||||
pkgdesc='The AI coding agent built for the terminal.'
|
||||
url='https://github.com/sst/opencode'
|
||||
arch=('aarch64' 'x86_64')
|
||||
license=('MIT')
|
||||
provides=('opencode')
|
||||
conflicts=('opencode')
|
||||
depends=('fzf' 'ripgrep')
|
||||
|
||||
source_aarch64=("${pkgname}_${pkgver}_aarch64.zip::{{ARM64_URL}}")
|
||||
sha256sums_aarch64=('{{ARM64_SHA}}')
|
||||
|
||||
source_x86_64=("${pkgname}_${pkgver}_x86_64.zip::{{X64_URL}}")
|
||||
sha256sums_x86_64=('{{X64_SHA}}')
|
||||
|
||||
package() {
|
||||
install -Dm755 ./opencode "${pkgdir}/usr/bin/opencode"
|
||||
}
|
||||
@@ -29,7 +29,7 @@ const targets = [
|
||||
["linux", "x64"],
|
||||
["darwin", "x64"],
|
||||
["darwin", "arm64"],
|
||||
["windows", "x64"],
|
||||
// ["windows", "x64"],
|
||||
]
|
||||
|
||||
await $`rm -rf dist`
|
||||
@@ -64,7 +64,7 @@ for (const [os, arch] of targets) {
|
||||
|
||||
await $`mkdir -p ./dist/${pkg.name}`
|
||||
await $`cp -r ./bin ./dist/${pkg.name}/bin`
|
||||
await $`cp ./script/postinstall.js ./dist/${pkg.name}/postinstall.js`
|
||||
await $`cp ./script/postinstall.mjs ./dist/${pkg.name}/postinstall.mjs`
|
||||
await Bun.file(`./dist/${pkg.name}/package.json`).write(
|
||||
JSON.stringify(
|
||||
{
|
||||
@@ -73,7 +73,7 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write(
|
||||
[pkg.name]: `./bin/${pkg.name}`,
|
||||
},
|
||||
scripts: {
|
||||
postinstall: "node ./postinstall.js",
|
||||
postinstall: "node ./postinstall.mjs",
|
||||
},
|
||||
version,
|
||||
optionalDependencies,
|
||||
@@ -108,9 +108,10 @@ if (!snapshot) {
|
||||
.filter((x: string) => {
|
||||
const lower = x.toLowerCase()
|
||||
return (
|
||||
!lower.includes("chore:") &&
|
||||
!lower.includes("ignore:") &&
|
||||
!lower.includes("ci:") &&
|
||||
!lower.includes("docs:")
|
||||
!lower.includes("docs:") &&
|
||||
!lower.includes("doc:")
|
||||
)
|
||||
})
|
||||
.join("\n")
|
||||
@@ -118,30 +119,52 @@ if (!snapshot) {
|
||||
if (!dry)
|
||||
await $`gh release create v${version} --title "v${version}" --notes ${notes} ./dist/*.zip`
|
||||
|
||||
// Calculate SHA values
|
||||
const arm64Sha =
|
||||
await $`sha256sum ./dist/opencode-linux-arm64.zip | cut -d' ' -f1`
|
||||
.text()
|
||||
.then((x) => x.trim())
|
||||
const x64Sha =
|
||||
await $`sha256sum ./dist/opencode-linux-x64.zip | cut -d' ' -f1`
|
||||
.text()
|
||||
.then((x) => x.trim())
|
||||
const macX64Sha =
|
||||
await $`sha256sum ./dist/opencode-darwin-x64.zip | cut -d' ' -f1`
|
||||
.text()
|
||||
.then((x) => x.trim())
|
||||
const macArm64Sha =
|
||||
await $`sha256sum ./dist/opencode-darwin-arm64.zip | cut -d' ' -f1`
|
||||
.text()
|
||||
.then((x) => x.trim())
|
||||
|
||||
// AUR package
|
||||
const pkgbuildTemplate = await Bun.file("./script/PKGBUILD.template").text()
|
||||
const pkgbuild = pkgbuildTemplate
|
||||
.replace("{{VERSION}}", version.split("-")[0])
|
||||
.replace(
|
||||
"{{ARM64_URL}}",
|
||||
`https://github.com/sst/opencode/releases/download/v${version}/opencode-linux-arm64.zip`,
|
||||
)
|
||||
.replace(
|
||||
"{{ARM64_SHA}}",
|
||||
await $`sha256sum ./dist/opencode-linux-arm64.zip | cut -d' ' -f1`
|
||||
.text()
|
||||
.then((x) => x.trim()),
|
||||
)
|
||||
.replace(
|
||||
"{{X64_URL}}",
|
||||
`https://github.com/sst/opencode/releases/download/v${version}/opencode-linux-x64.zip`,
|
||||
)
|
||||
.replace(
|
||||
"{{X64_SHA}}",
|
||||
await $`sha256sum ./dist/opencode-linux-x64.zip | cut -d' ' -f1`
|
||||
.text()
|
||||
.then((x) => x.trim()),
|
||||
)
|
||||
const pkgbuild = [
|
||||
"# Maintainer: dax",
|
||||
"# Maintainer: adam",
|
||||
"",
|
||||
"pkgname='opencode-bin'",
|
||||
`pkgver=${version.split("-")[0]}`,
|
||||
"options=('!debug' '!strip')",
|
||||
"pkgrel=1",
|
||||
"pkgdesc='The AI coding agent built for the terminal.'",
|
||||
"url='https://github.com/sst/opencode'",
|
||||
"arch=('aarch64' 'x86_64')",
|
||||
"license=('MIT')",
|
||||
"provides=('opencode')",
|
||||
"conflicts=('opencode')",
|
||||
"depends=('fzf' 'ripgrep')",
|
||||
"",
|
||||
`source_aarch64=("\${pkgname}_\${pkgver}_aarch64.zip::https://github.com/sst/opencode/releases/download/v${version}/opencode-linux-arm64.zip")`,
|
||||
`sha256sums_aarch64=('${arm64Sha}')`,
|
||||
"",
|
||||
`source_x86_64=("\${pkgname}_\${pkgver}_x86_64.zip::https://github.com/sst/opencode/releases/download/v${version}/opencode-linux-x64.zip")`,
|
||||
`sha256sums_x86_64=('${x64Sha}')`,
|
||||
"",
|
||||
"package() {",
|
||||
' install -Dm755 ./opencode "${pkgdir}/usr/bin/opencode"',
|
||||
"}",
|
||||
"",
|
||||
].join("\n")
|
||||
|
||||
await $`rm -rf ./dist/aur-opencode-bin`
|
||||
|
||||
@@ -151,4 +174,62 @@ if (!snapshot) {
|
||||
await $`cd ./dist/aur-opencode-bin && git add PKGBUILD .SRCINFO`
|
||||
await $`cd ./dist/aur-opencode-bin && git commit -m "Update to v${version}"`
|
||||
if (!dry) await $`cd ./dist/aur-opencode-bin && git push`
|
||||
|
||||
// Homebrew formula
|
||||
const homebrewFormula = [
|
||||
"# typed: false",
|
||||
"# frozen_string_literal: true",
|
||||
"",
|
||||
"# This file was generated by GoReleaser. DO NOT EDIT.",
|
||||
"class Opencode < Formula",
|
||||
` desc "The AI coding agent built for the terminal."`,
|
||||
` homepage "https://github.com/sst/opencode"`,
|
||||
` version "${version.split("-")[0]}"`,
|
||||
"",
|
||||
" on_macos do",
|
||||
" if Hardware::CPU.intel?",
|
||||
` url "https://github.com/sst/opencode/releases/download/v${version}/opencode-darwin-x64.zip"`,
|
||||
` sha256 "${macX64Sha}"`,
|
||||
"",
|
||||
" def install",
|
||||
' bin.install "opencode"',
|
||||
" end",
|
||||
" end",
|
||||
" if Hardware::CPU.arm?",
|
||||
` url "https://github.com/sst/opencode/releases/download/v${version}/opencode-darwin-arm64.zip"`,
|
||||
` sha256 "${macArm64Sha}"`,
|
||||
"",
|
||||
" def install",
|
||||
' bin.install "opencode"',
|
||||
" end",
|
||||
" end",
|
||||
" end",
|
||||
"",
|
||||
" on_linux do",
|
||||
" if Hardware::CPU.intel? and Hardware::CPU.is_64_bit?",
|
||||
` url "https://github.com/sst/opencode/releases/download/v${version}/opencode-linux-x64.zip"`,
|
||||
` sha256 "${x64Sha}"`,
|
||||
" def install",
|
||||
' bin.install "opencode"',
|
||||
" end",
|
||||
" end",
|
||||
" if Hardware::CPU.arm? and Hardware::CPU.is_64_bit?",
|
||||
` url "https://github.com/sst/opencode/releases/download/v${version}/opencode-linux-arm64.zip"`,
|
||||
` sha256 "${arm64Sha}"`,
|
||||
" def install",
|
||||
' bin.install "opencode"',
|
||||
" end",
|
||||
" end",
|
||||
" end",
|
||||
"end",
|
||||
"",
|
||||
"",
|
||||
].join("\n")
|
||||
|
||||
await $`rm -rf ./dist/homebrew-tap`
|
||||
await $`git clone https://${process.env["GITHUB_TOKEN"]}@github.com/sst/homebrew-tap.git ./dist/homebrew-tap`
|
||||
await Bun.file("./dist/homebrew-tap/opencode.rb").write(homebrewFormula)
|
||||
await $`cd ./dist/homebrew-tap && git add opencode.rb`
|
||||
await $`cd ./dist/homebrew-tap && git commit -m "Update to v${version}"`
|
||||
if (!dry) await $`cd ./dist/homebrew-tap && git push`
|
||||
}
|
||||
|
||||
8
packages/opencode/script/schema.ts
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import "zod-openapi/extend"
|
||||
import { Config } from "../src/config/config"
|
||||
import { zodToJsonSchema } from "zod-to-json-schema"
|
||||
|
||||
const result = zodToJsonSchema(Config.Info)
|
||||
await Bun.write("config.schema.json", JSON.stringify(result, null, 2))
|
||||
@@ -1,5 +1,4 @@
|
||||
import { generatePKCE } from "@openauthjs/openauth/pkce"
|
||||
import fs from "fs/promises"
|
||||
import { Auth } from "./index"
|
||||
|
||||
export namespace AuthAnthropic {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { z } from "zod"
|
||||
import { Global } from "../global"
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import { NamedError } from "../util/error"
|
||||
|
||||
export namespace BunProc {
|
||||
const log = Log.create({ service: "bun" })
|
||||
|
||||
@@ -30,4 +35,34 @@ export namespace BunProc {
|
||||
export function which() {
|
||||
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 mod = path.join(Global.Path.cache, "node_modules", pkg)
|
||||
const pkgjson = Bun.file(path.join(Global.Path.cache, "package.json"))
|
||||
const parsed = await pkgjson.json().catch(() => ({
|
||||
dependencies: {},
|
||||
}))
|
||||
if (parsed.dependencies[pkg] === version) return mod
|
||||
parsed.dependencies[pkg] = version
|
||||
await Bun.write(pkgjson, JSON.stringify(parsed, null, 2))
|
||||
await BunProc.run(["install"], {
|
||||
cwd: Global.Path.cache,
|
||||
}).catch((e) => {
|
||||
new InstallFailedError(
|
||||
{ pkg, version },
|
||||
{
|
||||
cause: e,
|
||||
},
|
||||
)
|
||||
})
|
||||
return mod
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as prompts from "@clack/prompts"
|
||||
import open from "open"
|
||||
import { UI } from "../ui"
|
||||
import { ModelsDev } from "../../provider/models"
|
||||
import { map, pipe, sortBy, values } from "remeda"
|
||||
|
||||
export const AuthCommand = cmd({
|
||||
command: "auth",
|
||||
@@ -15,7 +16,7 @@ export const AuthCommand = cmd({
|
||||
.command(AuthLogoutCommand)
|
||||
.command(AuthListCommand)
|
||||
.demandCommand(),
|
||||
async handler(args) {},
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
export const AuthListCommand = cmd({
|
||||
@@ -43,26 +44,60 @@ export const AuthLoginCommand = cmd({
|
||||
async handler() {
|
||||
UI.empty()
|
||||
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",
|
||||
maxItems: 2,
|
||||
maxItems: 8,
|
||||
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: "anthropic",
|
||||
},
|
||||
{
|
||||
label: "OpenAI",
|
||||
value: "openai",
|
||||
},
|
||||
{
|
||||
label: "Google",
|
||||
value: "google",
|
||||
value: "other",
|
||||
label: "Other",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
||||
|
||||
if (provider === "other") {
|
||||
provider = await prompts.text({
|
||||
message: "Enter provider id",
|
||||
validate: (x) =>
|
||||
x.match(/^[a-z-]+$/) ? undefined : "a-z and hyphens only",
|
||||
})
|
||||
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
||||
provider = provider.replace(/^@ai-sdk\//, "")
|
||||
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
||||
prompts.log.warn(
|
||||
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
|
||||
)
|
||||
}
|
||||
|
||||
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") {
|
||||
const method = await prompts.select({
|
||||
message: "Login method",
|
||||
@@ -83,8 +118,14 @@ export const AuthLoginCommand = cmd({
|
||||
// some weird bug where program exits without this
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
const { url, verifier } = await AuthAnthropic.authorize()
|
||||
prompts.note("Opening browser...")
|
||||
await open(url)
|
||||
prompts.note("Trying to open browser...")
|
||||
try {
|
||||
await open(url)
|
||||
} catch (e) {
|
||||
prompts.log.error(
|
||||
"Failed to open browser perhaps you are running without a display or X server, please open the following URL in your browser:",
|
||||
)
|
||||
}
|
||||
prompts.log.info(url)
|
||||
|
||||
const code = await prompts.text({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Server } from "../../server/server"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import type { CommandModule } from "yargs"
|
||||
import { Config } from "../../config/config"
|
||||
|
||||
export const GenerateCommand = {
|
||||
command: "generate",
|
||||
|
||||
@@ -83,6 +83,7 @@ export const RunCommand = {
|
||||
}
|
||||
|
||||
Bus.subscribe(Message.Event.PartUpdated, async (evt) => {
|
||||
if (evt.properties.sessionID !== session.id) return
|
||||
const part = evt.properties.part
|
||||
const message = await Session.getMessage(
|
||||
evt.properties.sessionID,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { App } from "../../app/app"
|
||||
import { LSP } from "../../lsp"
|
||||
import { VERSION } from "../version"
|
||||
import { cmd } from "./cmd"
|
||||
|
||||
@@ -6,9 +7,10 @@ export const ScrapCommand = cmd({
|
||||
command: "scrap <file>",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("file", { type: "string", demandOption: true }),
|
||||
async handler() {
|
||||
async handler(args) {
|
||||
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())
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
109
packages/opencode/src/cli/cmd/upgrade.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { Argv } from "yargs"
|
||||
import { UI } from "../ui"
|
||||
import { VERSION } from "../version"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { Installation } from "../../installation"
|
||||
|
||||
export const UpgradeCommand = {
|
||||
command: "upgrade [target]",
|
||||
describe: "Upgrade opencode to the latest version or a specific version",
|
||||
builder: (yargs: Argv) => {
|
||||
return yargs.positional("target", {
|
||||
describe: "Specific version to upgrade to (e.g., '0.1.48' or 'v0.1.48')",
|
||||
type: "string",
|
||||
})
|
||||
},
|
||||
handler: async (args: { target?: string }) => {
|
||||
UI.empty()
|
||||
UI.println(UI.logo(" "))
|
||||
UI.empty()
|
||||
prompts.intro("Upgrade")
|
||||
const method = await Installation.method()
|
||||
if (method === "unknown") {
|
||||
prompts.log.error(
|
||||
`opencode is installed to ${process.execPath} and seems to be managed by a package manager`,
|
||||
)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
const target = args.target ?? (await Installation.latest())
|
||||
prompts.log.info(`From ${VERSION} → ${target}`)
|
||||
const spinner = prompts.spinner()
|
||||
spinner.start("Upgrading...")
|
||||
const err = await Installation.upgrade(method, target).catch((err) => err)
|
||||
if (err) {
|
||||
spinner.stop("Upgrade failed")
|
||||
if (err instanceof Installation.UpgradeFailedError)
|
||||
prompts.log.error(err.data.stderr)
|
||||
else if (err instanceof Error) prompts.log.error(err.message)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
spinner.stop("Upgrade complete")
|
||||
prompts.outro("Done")
|
||||
return
|
||||
|
||||
/*
|
||||
if (!process.execPath.includes(path.join(".opencode", "bin")) && false) {
|
||||
return
|
||||
}
|
||||
|
||||
const release = args.target
|
||||
? await specific(args.target).catch(() => {})
|
||||
: await latest().catch(() => {})
|
||||
if (!release) {
|
||||
prompts.log.error("Failed to fetch release information")
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
const target = release.tag_name
|
||||
|
||||
if (VERSION !== "dev" && compare(VERSION, target) >= 0) {
|
||||
prompts.log.success(`Already up to date`)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
prompts.log.info(`From ${VERSION} → ${target}`)
|
||||
|
||||
const name = asset()
|
||||
const found = release.assets.find((a) => a.name === name)
|
||||
|
||||
if (!found) {
|
||||
prompts.log.error(`No binary found for platform: ${name}`)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
const spinner = prompts.spinner()
|
||||
spinner.start("Downloading update...")
|
||||
|
||||
const downloadPath = await download(found.browser_download_url).catch(
|
||||
() => {},
|
||||
)
|
||||
if (!downloadPath) {
|
||||
spinner.stop("Download failed")
|
||||
prompts.log.error("Download failed")
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
spinner.stop("Download complete")
|
||||
|
||||
const renamed = await fs
|
||||
.rename(downloadPath, process.execPath)
|
||||
.catch(() => {})
|
||||
|
||||
if (renamed === undefined) {
|
||||
prompts.log.error("Install failed")
|
||||
await fs.unlink(downloadPath).catch(() => {})
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
prompts.log.success(`Successfully upgraded to ${target}`)
|
||||
prompts.outro("Done")
|
||||
*/
|
||||
},
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import { createCli, type TrpcCliMeta } from "trpc-cli"
|
||||
import { initTRPC } from "@trpc/server"
|
||||
import { z } from "zod"
|
||||
import { Server } from "../server/server"
|
||||
import { AuthAnthropic } from "../auth/anthropic"
|
||||
import { UI } from "./ui"
|
||||
import { App } from "../app/app"
|
||||
import { Bus } from "../bus"
|
||||
import { Provider } from "../provider/provider"
|
||||
import { Session } from "../session"
|
||||
import { Share } from "../share/share"
|
||||
import { Message } from "../session/message"
|
||||
import { VERSION } from "./version"
|
||||
import { LSP } from "../lsp"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
|
||||
const t = initTRPC.meta<TrpcCliMeta>().create()
|
||||
|
||||
export const router = t.router({
|
||||
generate: t.procedure
|
||||
.meta({
|
||||
description: "Generate OpenAPI and event specs",
|
||||
})
|
||||
.input(z.object({}))
|
||||
.mutation(async () => {
|
||||
const specs = await Server.openapi()
|
||||
const dir = "gen"
|
||||
await fs.rmdir(dir, { recursive: true }).catch(() => {})
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
await Bun.write(
|
||||
path.join(dir, "openapi.json"),
|
||||
JSON.stringify(specs, null, 2),
|
||||
)
|
||||
return "Generated OpenAPI specs in gen/ directory"
|
||||
}),
|
||||
|
||||
run: t.procedure
|
||||
.meta({
|
||||
description: "Run OpenCode with a message",
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
message: z.array(z.string()).default([]).describe("Message to send"),
|
||||
session: z.string().optional().describe("Session ID to continue"),
|
||||
}),
|
||||
)
|
||||
.mutation(
|
||||
async ({ input }: { input: { message: string[]; session?: string } }) => {
|
||||
const message = input.message.join(" ")
|
||||
await App.provide(
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
version: "0.0.0",
|
||||
},
|
||||
async () => {
|
||||
await Share.init()
|
||||
const session = input.session
|
||||
? await Session.get(input.session)
|
||||
: await Session.create()
|
||||
|
||||
UI.println(UI.Style.TEXT_HIGHLIGHT_BOLD + "◍ OpenCode", VERSION)
|
||||
UI.empty()
|
||||
UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message)
|
||||
UI.empty()
|
||||
UI.println(
|
||||
UI.Style.TEXT_INFO_BOLD +
|
||||
"~ https://dev.opencode.ai/s?id=" +
|
||||
session.id.slice(-8),
|
||||
)
|
||||
UI.empty()
|
||||
|
||||
function printEvent(color: string, type: string, title: string) {
|
||||
UI.println(
|
||||
color + `|`,
|
||||
UI.Style.TEXT_NORMAL +
|
||||
UI.Style.TEXT_DIM +
|
||||
` ${type.padEnd(7, " ")}`,
|
||||
"",
|
||||
UI.Style.TEXT_NORMAL + title,
|
||||
)
|
||||
}
|
||||
|
||||
Bus.subscribe(Message.Event.PartUpdated, async (message) => {
|
||||
const part = message.properties.part
|
||||
if (
|
||||
part.type === "tool-invocation" &&
|
||||
part.toolInvocation.state === "result"
|
||||
) {
|
||||
if (part.toolInvocation.toolName === "opencode_todowrite")
|
||||
return
|
||||
|
||||
const args = part.toolInvocation.args as any
|
||||
const tool = part.toolInvocation.toolName
|
||||
|
||||
if (tool === "opencode_edit")
|
||||
printEvent(UI.Style.TEXT_SUCCESS_BOLD, "Edit", args.filePath)
|
||||
if (tool === "opencode_bash")
|
||||
printEvent(
|
||||
UI.Style.TEXT_WARNING_BOLD,
|
||||
"Execute",
|
||||
args.command,
|
||||
)
|
||||
if (tool === "opencode_read")
|
||||
printEvent(UI.Style.TEXT_INFO_BOLD, "Read", args.filePath)
|
||||
if (tool === "opencode_write")
|
||||
printEvent(
|
||||
UI.Style.TEXT_SUCCESS_BOLD,
|
||||
"Create",
|
||||
args.filePath,
|
||||
)
|
||||
if (tool === "opencode_list")
|
||||
printEvent(UI.Style.TEXT_INFO_BOLD, "List", args.path)
|
||||
if (tool === "opencode_glob")
|
||||
printEvent(
|
||||
UI.Style.TEXT_INFO_BOLD,
|
||||
"Glob",
|
||||
args.pattern + (args.path ? " in " + args.path : ""),
|
||||
)
|
||||
}
|
||||
|
||||
if (part.type === "text") {
|
||||
if (part.text.includes("\n")) {
|
||||
UI.empty()
|
||||
UI.println(part.text)
|
||||
UI.empty()
|
||||
return
|
||||
}
|
||||
printEvent(UI.Style.TEXT_NORMAL_BOLD, "Text", part.text)
|
||||
}
|
||||
})
|
||||
|
||||
const { providerID, modelID } = await Provider.defaultModel()
|
||||
await Session.chat({
|
||||
sessionID: session.id,
|
||||
providerID,
|
||||
modelID,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: message,
|
||||
},
|
||||
],
|
||||
})
|
||||
UI.empty()
|
||||
},
|
||||
)
|
||||
return "Session completed"
|
||||
},
|
||||
),
|
||||
|
||||
scrap: t.procedure
|
||||
.meta({
|
||||
description: "Test command for scraping files",
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
file: z.string().describe("File to process"),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }: { input: { file: string } }) => {
|
||||
await App.provide({ cwd: process.cwd(), version: VERSION }, async () => {
|
||||
await LSP.touchFile(input.file, true)
|
||||
await LSP.diagnostics()
|
||||
})
|
||||
return `Processed file: ${input.file}`
|
||||
}),
|
||||
|
||||
login: t.router({
|
||||
anthropic: t.procedure
|
||||
.meta({
|
||||
description: "Login to Anthropic",
|
||||
})
|
||||
.input(z.object({}))
|
||||
.mutation(async () => {
|
||||
const { url, verifier } = await AuthAnthropic.authorize()
|
||||
|
||||
UI.println("Login to Anthropic")
|
||||
UI.println("Open the following URL in your browser:")
|
||||
UI.println(url)
|
||||
UI.println("")
|
||||
|
||||
const code = await UI.input("Paste the authorization code here: ")
|
||||
await AuthAnthropic.exchange(code, verifier)
|
||||
return "Successfully logged in to Anthropic"
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
export function createOpenCodeCli() {
|
||||
return createCli({ router })
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ import { NamedError } from "../util/error"
|
||||
|
||||
export namespace UI {
|
||||
const LOGO = [
|
||||
`█▀▀█ █▀▀█ █▀▀ █▀▀▄ █▀▀ █▀▀█ █▀▀▄ █▀▀`,
|
||||
`█░░█ █░░█ █▀▀ █░░█ █░░ █░░█ █░░█ █▀▀`,
|
||||
`▀▀▀▀ █▀▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀`,
|
||||
[`█▀▀█ █▀▀█ █▀▀ █▀▀▄ `, `█▀▀ █▀▀█ █▀▀▄ █▀▀`],
|
||||
[`█░░█ █░░█ █▀▀ █░░█ `, `█░░ █░░█ █░░█ █▀▀`],
|
||||
[`▀▀▀▀ █▀▀▀ ▀▀▀ ▀ ▀ `, `▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀`],
|
||||
]
|
||||
|
||||
export const CancelledError = NamedError.create("UICancelledError", z.void())
|
||||
@@ -48,12 +48,10 @@ export namespace UI {
|
||||
const result = []
|
||||
for (const row of LOGO) {
|
||||
if (pad) result.push(pad)
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
const color =
|
||||
i > 18 ? Bun.color("white", "ansi") : Bun.color("gray", "ansi")
|
||||
const char = row[i]
|
||||
result.push(color + char)
|
||||
}
|
||||
result.push(Bun.color("gray", "ansi"))
|
||||
result.push(row[0])
|
||||
result.push("\x1b[0m")
|
||||
result.push(row[1])
|
||||
result.push("\n")
|
||||
}
|
||||
return result.join("").trimEnd()
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Log } from "../util/log"
|
||||
import { z } from "zod"
|
||||
import { App } from "../app/app"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { ModelsDev } from "../provider/models"
|
||||
|
||||
export namespace Config {
|
||||
const log = Log.create({ service: "config" })
|
||||
@@ -49,11 +50,14 @@ export namespace Config {
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
provider: z.record(z.string(), z.record(z.string(), z.any())).optional(),
|
||||
tool: z
|
||||
.object({
|
||||
provider: z.record(z.string(), z.string().array()).optional(),
|
||||
})
|
||||
$schema: z.string().optional(),
|
||||
provider: z
|
||||
.record(
|
||||
ModelsDev.Provider.partial().extend({
|
||||
models: z.record(ModelsDev.Model.partial()),
|
||||
options: z.record(z.any()).optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
mcp: z.record(z.string(), Mcp).optional(),
|
||||
})
|
||||
|
||||
28
packages/opencode/src/external/fzf.ts
vendored
@@ -1,4 +1,3 @@
|
||||
import { App } from "../app/app"
|
||||
import path from "path"
|
||||
import { Global } from "../global"
|
||||
import fs from "fs/promises"
|
||||
@@ -6,6 +5,7 @@ import { z } from "zod"
|
||||
import { NamedError } from "../util/error"
|
||||
import { lazy } from "../util/lazy"
|
||||
import { Log } from "../util/log"
|
||||
import { $ } from "bun"
|
||||
|
||||
export namespace Fzf {
|
||||
const log = Log.create({ service: "fzf" })
|
||||
@@ -116,19 +116,23 @@ export namespace Fzf {
|
||||
return filepath
|
||||
}
|
||||
|
||||
export async function search(cwd: string, query: string) {
|
||||
const process = Bun.spawn({
|
||||
cwd,
|
||||
stdin: "inherit",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
cmd: [await filepath(), "--filter", query],
|
||||
})
|
||||
await process.exited
|
||||
const stdout = await Bun.readableStreamToText(process.stdout)
|
||||
return stdout
|
||||
export async function search(input: {
|
||||
cwd: string
|
||||
query: string
|
||||
limit?: number
|
||||
}) {
|
||||
const results = await $`${await filepath()} --filter=${input.query}`
|
||||
.quiet()
|
||||
.throws(false)
|
||||
.cwd(input.cwd)
|
||||
.text()
|
||||
const split = results
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((line) => line.length > 0)
|
||||
log.info("results", {
|
||||
count: split.length,
|
||||
})
|
||||
return split
|
||||
}
|
||||
}
|
||||
|
||||
16
packages/opencode/src/external/ripgrep.ts
vendored
@@ -5,6 +5,8 @@ import fs from "fs/promises"
|
||||
import { z } from "zod"
|
||||
import { NamedError } from "../util/error"
|
||||
import { lazy } from "../util/lazy"
|
||||
import { $ } from "bun"
|
||||
import { Fzf } from "./fzf"
|
||||
|
||||
export namespace Ripgrep {
|
||||
const PLATFORM = {
|
||||
@@ -111,4 +113,18 @@ export namespace Ripgrep {
|
||||
const { filepath } = await state()
|
||||
return filepath
|
||||
}
|
||||
|
||||
export async function files(input: {
|
||||
cwd: string
|
||||
query?: string
|
||||
limit?: number
|
||||
}) {
|
||||
const commands = [`${await filepath()} --files --hidden --glob='!.git/*'`]
|
||||
if (input.query)
|
||||
commands.push(`${await Fzf.filepath()} --filter=${input.query}`)
|
||||
if (input.limit) commands.push(`head -n ${input.limit}`)
|
||||
const joined = commands.join(" | ")
|
||||
const result = await $`${{ raw: joined }}`.cwd(input.cwd).text()
|
||||
return result.split("\n").filter(Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
8
packages/opencode/src/flag/flag.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export namespace Flag {
|
||||
export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE")
|
||||
|
||||
function truthy(key: string) {
|
||||
const value = process.env[key]?.toLowerCase()
|
||||
return value === "true" || value === "1"
|
||||
}
|
||||
}
|
||||
24
packages/opencode/src/global/config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { z } from "zod"
|
||||
import { Global } from "."
|
||||
import { lazy } from "../util/lazy"
|
||||
import path from "path"
|
||||
|
||||
export namespace GlobalConfig {
|
||||
export const Info = z.object({
|
||||
autoupdate: z.boolean().optional(),
|
||||
provider: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export const get = lazy(async () => {
|
||||
const toml = await import(path.join(Global.Path.config, "config"), {
|
||||
with: {
|
||||
type: "toml",
|
||||
},
|
||||
})
|
||||
.then((mod) => mod.default)
|
||||
.catch(() => ({}))
|
||||
return Info.parse(toml)
|
||||
})
|
||||
}
|
||||
@@ -3,11 +3,8 @@ import { App } from "./app/app"
|
||||
import { Server } from "./server/server"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
|
||||
import { Share } from "./share/share"
|
||||
|
||||
import { Global } from "./global"
|
||||
|
||||
import yargs from "yargs"
|
||||
import { hideBin } from "yargs/helpers"
|
||||
import { RunCommand } from "./cli/cmd/run"
|
||||
@@ -16,8 +13,20 @@ import { VERSION } from "./cli/version"
|
||||
import { ScrapCommand } from "./cli/cmd/scrap"
|
||||
import { Log } from "./util/log"
|
||||
import { AuthCommand, AuthLoginCommand } from "./cli/cmd/auth"
|
||||
import { UpgradeCommand } from "./cli/cmd/upgrade"
|
||||
import { Provider } from "./provider/provider"
|
||||
import { UI } from "./cli/ui"
|
||||
import { GlobalConfig } from "./global/config"
|
||||
import { Installation } from "./installation"
|
||||
;(async () => {
|
||||
if (Installation.VERSION === "dev") return
|
||||
const config = await GlobalConfig.get()
|
||||
if (config.autoupdate === false) return
|
||||
const latest = await Installation.latest()
|
||||
if (Installation.VERSION === latest) return
|
||||
const method = await Installation.method()
|
||||
await Installation.upgrade(method, latest).catch(() => {})
|
||||
})()
|
||||
|
||||
const cli = yargs(hideBin(process.argv))
|
||||
.scriptName("opencode")
|
||||
@@ -36,7 +45,7 @@ const cli = yargs(hideBin(process.argv))
|
||||
.usage("\n" + UI.logo())
|
||||
.command({
|
||||
command: "$0 [project]",
|
||||
describe: "Start OpenCode TUI",
|
||||
describe: "Start opencode TUI",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("project", {
|
||||
type: "string",
|
||||
@@ -48,7 +57,7 @@ const cli = yargs(hideBin(process.argv))
|
||||
process.chdir(cwd)
|
||||
const result = await App.provide(
|
||||
{ cwd, version: VERSION },
|
||||
async () => {
|
||||
async (app) => {
|
||||
const providers = await Provider.list()
|
||||
if (Object.keys(providers).length === 0) {
|
||||
return "needs_provider"
|
||||
@@ -72,7 +81,7 @@ const cli = yargs(hideBin(process.argv))
|
||||
cmd = [binary]
|
||||
}
|
||||
const proc = Bun.spawn({
|
||||
cmd,
|
||||
cmd: [...cmd, ...process.argv.slice(2)],
|
||||
cwd,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
@@ -80,6 +89,7 @@ const cli = yargs(hideBin(process.argv))
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCODE_SERVER: server.url.toString(),
|
||||
OPENCODE_APP_INFO: JSON.stringify(app),
|
||||
},
|
||||
onExit: () => {
|
||||
server.stop()
|
||||
@@ -105,6 +115,7 @@ const cli = yargs(hideBin(process.argv))
|
||||
.command(GenerateCommand)
|
||||
.command(ScrapCommand)
|
||||
.command(AuthCommand)
|
||||
.command(UpgradeCommand)
|
||||
.fail((msg, err) => {
|
||||
if (
|
||||
msg.startsWith("Unknown argument") ||
|
||||
|
||||
104
packages/opencode/src/installation/index.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import path from "path"
|
||||
import { $ } from "bun"
|
||||
import { z } from "zod"
|
||||
import { NamedError } from "../util/error"
|
||||
|
||||
export namespace Installation {
|
||||
export type Method = Awaited<ReturnType<typeof method>>
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
version: z.string(),
|
||||
latest: z.string(),
|
||||
})
|
||||
.openapi({
|
||||
ref: "InstallationInfo",
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export async function info() {
|
||||
return {
|
||||
version: VERSION,
|
||||
latest: await latest(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function method() {
|
||||
if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl"
|
||||
const exec = process.execPath.toLowerCase()
|
||||
|
||||
const checks = [
|
||||
{
|
||||
name: "npm" as const,
|
||||
command: () => $`npm list -g --depth=0`.throws(false).text(),
|
||||
},
|
||||
{
|
||||
name: "yarn" as const,
|
||||
command: () => $`yarn global list`.throws(false).text(),
|
||||
},
|
||||
{
|
||||
name: "pnpm" as const,
|
||||
command: () => $`pnpm list -g --depth=0`.throws(false).text(),
|
||||
},
|
||||
{
|
||||
name: "bun" as const,
|
||||
command: () => $`bun pm ls -g`.throws(false).text(),
|
||||
},
|
||||
]
|
||||
|
||||
checks.sort((a, b) => {
|
||||
const aMatches = exec.includes(a.name)
|
||||
const bMatches = exec.includes(b.name)
|
||||
if (aMatches && !bMatches) return -1
|
||||
if (!aMatches && bMatches) return 1
|
||||
return 0
|
||||
})
|
||||
|
||||
for (const check of checks) {
|
||||
const output = await check.command()
|
||||
if (output.includes("opencode-ai")) {
|
||||
return check.name
|
||||
}
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
export const UpgradeFailedError = NamedError.create(
|
||||
"UpgradeFailedError",
|
||||
z.object({
|
||||
stderr: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export async function upgrade(method: Method, target: string) {
|
||||
const cmd = (() => {
|
||||
switch (method) {
|
||||
case "curl":
|
||||
return $`curl -fsSL https://opencode.ai/install | bash`
|
||||
case "npm":
|
||||
return $`npm install -g opencode-ai@${target}`
|
||||
case "pnpm":
|
||||
return $`pnpm install -g opencode-ai@${target}`
|
||||
case "bun":
|
||||
return $`bun install -g opencode-ai@${target}`
|
||||
default:
|
||||
throw new Error(`Unknown method: ${method}`)
|
||||
}
|
||||
})()
|
||||
const result = await cmd.quiet().throws(false)
|
||||
if (result.exitCode !== 0)
|
||||
throw new UpgradeFailedError({
|
||||
stderr: result.stderr.toString("utf8"),
|
||||
})
|
||||
}
|
||||
|
||||
export const VERSION =
|
||||
typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "dev"
|
||||
|
||||
export async function latest() {
|
||||
return fetch("https://api.github.com/repos/sst/opencode/releases/latest")
|
||||
.then((res) => res.json())
|
||||
.then((data) => data.tag_name.slice(1))
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { LANGUAGE_EXTENSIONS } from "./language"
|
||||
import { Bus } from "../bus"
|
||||
import z from "zod"
|
||||
import type { LSPServer } from "./server"
|
||||
import { NamedError } from "../util/error"
|
||||
|
||||
export namespace LSPClient {
|
||||
const log = Log.create({ service: "lsp.client" })
|
||||
@@ -19,6 +20,13 @@ export namespace LSPClient {
|
||||
|
||||
export type Diagnostic = VSCodeDiagnostic
|
||||
|
||||
export const InitializeError = NamedError.create(
|
||||
"LSPInitializeError",
|
||||
z.object({
|
||||
serverID: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const Event = {
|
||||
Diagnostics: Bus.event(
|
||||
"lsp.client.diagnostics",
|
||||
@@ -52,32 +60,40 @@ export namespace LSPClient {
|
||||
})
|
||||
connection.listen()
|
||||
|
||||
await connection.sendRequest("initialize", {
|
||||
processId: server.process.pid,
|
||||
workspaceFolders: [
|
||||
{
|
||||
name: "workspace",
|
||||
uri: "file://" + app.path.cwd,
|
||||
},
|
||||
],
|
||||
initializationOptions: {
|
||||
...server.initialization,
|
||||
},
|
||||
capabilities: {
|
||||
workspace: {
|
||||
configuration: true,
|
||||
},
|
||||
textDocument: {
|
||||
synchronization: {
|
||||
didOpen: true,
|
||||
didChange: true,
|
||||
log.info("sending initialize", { id: serverID })
|
||||
await Promise.race([
|
||||
connection.sendRequest("initialize", {
|
||||
processId: server.process.pid,
|
||||
workspaceFolders: [
|
||||
{
|
||||
name: "workspace",
|
||||
uri: "file://" + app.path.cwd,
|
||||
},
|
||||
publishDiagnostics: {
|
||||
versionSupport: true,
|
||||
],
|
||||
initializationOptions: {
|
||||
...server.initialization,
|
||||
},
|
||||
capabilities: {
|
||||
workspace: {
|
||||
configuration: true,
|
||||
},
|
||||
textDocument: {
|
||||
synchronization: {
|
||||
didOpen: true,
|
||||
didChange: true,
|
||||
},
|
||||
publishDiagnostics: {
|
||||
versionSupport: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new InitializeError({ serverID }))
|
||||
}, 5_000)
|
||||
}),
|
||||
])
|
||||
await connection.sendNotification("initialized", {})
|
||||
log.info("initialized")
|
||||
|
||||
|
||||
@@ -12,9 +12,10 @@ export namespace LSP {
|
||||
async () => {
|
||||
log.info("initializing")
|
||||
const clients = new Map<string, LSPClient.Info>()
|
||||
|
||||
const skip = new Set<string>()
|
||||
return {
|
||||
clients,
|
||||
skip,
|
||||
}
|
||||
},
|
||||
async (state) => {
|
||||
@@ -31,11 +32,19 @@ export namespace LSP {
|
||||
x.extensions.includes(extension),
|
||||
)
|
||||
for (const match of matches) {
|
||||
if (s.skip.has(match.id)) continue
|
||||
const existing = s.clients.get(match.id)
|
||||
if (existing) continue
|
||||
const handle = await match.spawn(App.info())
|
||||
if (!handle) continue
|
||||
const client = await LSPClient.create(match.id, handle)
|
||||
if (!handle) {
|
||||
s.skip.add(match.id)
|
||||
continue
|
||||
}
|
||||
const client = await LSPClient.create(match.id, handle).catch(() => {})
|
||||
if (!client) {
|
||||
s.skip.add(match.id)
|
||||
continue
|
||||
}
|
||||
s.clients.set(match.id, client)
|
||||
}
|
||||
if (waitForDiagnostics) {
|
||||
|
||||
@@ -1,17 +1,56 @@
|
||||
import { Global } from "../global"
|
||||
import { lazy } from "../util/lazy"
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import { z } from "zod"
|
||||
|
||||
export namespace ModelsDev {
|
||||
const log = Log.create({ service: "models.dev" })
|
||||
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(),
|
||||
npm: z.string().optional(),
|
||||
models: z.record(Model),
|
||||
})
|
||||
.openapi({
|
||||
ref: "Provider.Info",
|
||||
})
|
||||
|
||||
export type Provider = z.infer<typeof Provider>
|
||||
|
||||
export async function get() {
|
||||
const file = Bun.file(filepath)
|
||||
const result = await file.json().catch(() => {})
|
||||
if (result) {
|
||||
refresh()
|
||||
return result
|
||||
return result as Record<string, Provider>
|
||||
}
|
||||
await refresh()
|
||||
return get()
|
||||
@@ -25,4 +64,30 @@ export namespace ModelsDev {
|
||||
throw new Error(`Failed to fetch models.dev: ${result.statusText}`)
|
||||
await Bun.write(file, result)
|
||||
}
|
||||
|
||||
const aisdk = lazy(async () => {
|
||||
log.info("fetching ai-sdk")
|
||||
const response = await fetch(
|
||||
"https://registry.npmjs.org/-/v1/search?text=scope:@ai-sdk",
|
||||
)
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
`Failed to fetch ai-sdk information: ${response.statusText}`,
|
||||
)
|
||||
const result = await response.json()
|
||||
log.info("found ai-sdk", result.objects.length)
|
||||
return result.objects
|
||||
.filter((obj: any) => obj.package.name.startsWith("@ai-sdk/"))
|
||||
.reduce((acc: any, obj: any) => {
|
||||
acc[obj.package.name] = obj
|
||||
return acc
|
||||
}, {})
|
||||
})
|
||||
|
||||
export async function pkg(providerID: string): Promise<[string, string]> {
|
||||
const packages = await aisdk()
|
||||
const match = packages[`@ai-sdk/${providerID}`]
|
||||
if (match) return [match.package.name, "latest"]
|
||||
return [providerID, "latest"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import { Config } from "../config/config"
|
||||
import { mergeDeep, sortBy } from "remeda"
|
||||
import { NoSuchModelError, type LanguageModel, type Provider as SDK } from "ai"
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import { Global } from "../global"
|
||||
import { BunProc } from "../bun"
|
||||
import { BashTool } from "../tool/bash"
|
||||
import { EditTool } from "../tool/edit"
|
||||
@@ -24,113 +22,67 @@ import { AuthAnthropic } from "../auth/anthropic"
|
||||
import { ModelsDev } from "./models"
|
||||
import { NamedError } from "../util/error"
|
||||
import { Auth } from "../auth"
|
||||
import { TaskTool } from "../tool/task"
|
||||
|
||||
export namespace Provider {
|
||||
const log = Log.create({ service: "provider" })
|
||||
|
||||
export const Model = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
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>
|
||||
type CustomLoader = (
|
||||
provider: ModelsDev.Provider,
|
||||
) => Promise<Record<string, any> | false>
|
||||
|
||||
export const Info = z
|
||||
.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 Source = "env" | "config" | "custom" | "api"
|
||||
|
||||
type Autodetector = (provider: Info) => Promise<
|
||||
| {
|
||||
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> = {
|
||||
const CUSTOM_LOADERS: Record<string, CustomLoader> = {
|
||||
async anthropic(provider) {
|
||||
const access = await AuthAnthropic.access()
|
||||
if (access) {
|
||||
// claude sub doesn't have usage cost
|
||||
for (const model of Object.values(provider.models)) {
|
||||
model.cost = {
|
||||
input: 0,
|
||||
inputCached: 0,
|
||||
output: 0,
|
||||
outputCached: 0,
|
||||
}
|
||||
}
|
||||
return {
|
||||
source: "oauth",
|
||||
options: {
|
||||
apiKey: "",
|
||||
headers: {
|
||||
authorization: `Bearer ${access}`,
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
},
|
||||
},
|
||||
if (!access) return false
|
||||
for (const model of Object.values(provider.models)) {
|
||||
model.cost = {
|
||||
input: 0,
|
||||
inputCached: 0,
|
||||
output: 0,
|
||||
outputCached: 0,
|
||||
}
|
||||
}
|
||||
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 config = await Config.get()
|
||||
const database: Record<string, Provider.Info> = await ModelsDev.get()
|
||||
const database = await ModelsDev.get()
|
||||
|
||||
const providers: {
|
||||
[providerID: string]: {
|
||||
source: Source
|
||||
info: Provider.Info
|
||||
info: ModelsDev.Provider
|
||||
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>()
|
||||
|
||||
log.info("loading")
|
||||
log.info("init")
|
||||
|
||||
function mergeProvider(
|
||||
id: string,
|
||||
@@ -141,11 +93,7 @@ export namespace Provider {
|
||||
if (!provider) {
|
||||
providers[id] = {
|
||||
source,
|
||||
info: database[id] ?? {
|
||||
id,
|
||||
name: id,
|
||||
models: [],
|
||||
},
|
||||
info: database[id],
|
||||
options,
|
||||
}
|
||||
return
|
||||
@@ -154,26 +102,73 @@ export namespace Provider {
|
||||
provider.source = source
|
||||
}
|
||||
|
||||
for (const [providerID, fn] of Object.entries(AUTODETECT)) {
|
||||
const provider = database[providerID]
|
||||
if (!provider) continue
|
||||
const result = await fn(provider)
|
||||
if (!result) continue
|
||||
mergeProvider(providerID, result.options, result.source)
|
||||
for (const [providerID, provider] of Object.entries(
|
||||
config.provider ?? {},
|
||||
)) {
|
||||
const existing = database[providerID]
|
||||
const parsed: ModelsDev.Provider = {
|
||||
id: providerID,
|
||||
npm: provider.npm ?? existing?.npm,
|
||||
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())) {
|
||||
if (info.type === "api") {
|
||||
mergeProvider(providerID, { apiKey: info.key }, "api")
|
||||
// load env
|
||||
for (const [providerID, provider] of Object.entries(database)) {
|
||||
if (provider.env.some((item) => process.env[item])) {
|
||||
mergeProvider(providerID, {}, "env")
|
||||
}
|
||||
}
|
||||
|
||||
for (const [providerID, options] of Object.entries(config.provider ?? {})) {
|
||||
mergeProvider(providerID, options, "config")
|
||||
// load apikeys
|
||||
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)) {
|
||||
log.info("loaded", { providerID })
|
||||
log.info("found", { providerID })
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -187,32 +182,22 @@ export namespace Provider {
|
||||
return state().then((state) => state.providers)
|
||||
}
|
||||
|
||||
async function getSDK(providerID: string) {
|
||||
async function getSDK(provider: ModelsDev.Provider) {
|
||||
return (async () => {
|
||||
using _ = log.time("getSDK", {
|
||||
providerID: provider.id,
|
||||
})
|
||||
const s = await state()
|
||||
const existing = s.sdk.get(providerID)
|
||||
const existing = s.sdk.get(provider.id)
|
||||
if (existing) return existing
|
||||
const dir = path.join(
|
||||
Global.Path.cache,
|
||||
`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 [pkg, version] = await ModelsDev.pkg(provider.npm ?? provider.id)
|
||||
const mod = await import(await BunProc.install(pkg, version))
|
||||
const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!]
|
||||
const loaded = fn(s.providers[providerID]?.options)
|
||||
s.sdk.set(providerID, loaded)
|
||||
const loaded = fn(s.providers[provider.id]?.options)
|
||||
s.sdk.set(provider.id, loaded)
|
||||
return loaded as SDK
|
||||
})().catch((e) => {
|
||||
throw new InitError({ providerID: providerID }, { cause: e })
|
||||
throw new InitError({ providerID: provider.id }, { cause: e })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -221,7 +206,7 @@ export namespace Provider {
|
||||
const s = await state()
|
||||
if (s.models.has(key)) return s.models.get(key)!
|
||||
|
||||
log.info("loading", {
|
||||
log.info("getModel", {
|
||||
providerID,
|
||||
modelID,
|
||||
})
|
||||
@@ -230,8 +215,7 @@ export namespace Provider {
|
||||
if (!provider) throw new ModelNotFoundError({ providerID, modelID })
|
||||
const info = provider.info.models[modelID]
|
||||
if (!info) throw new ModelNotFoundError({ providerID, modelID })
|
||||
|
||||
const sdk = await getSDK(providerID)
|
||||
const sdk = await getSDK(provider.info)
|
||||
|
||||
try {
|
||||
const language =
|
||||
@@ -260,7 +244,7 @@ export namespace Provider {
|
||||
}
|
||||
|
||||
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(
|
||||
models,
|
||||
[
|
||||
@@ -298,6 +282,7 @@ export namespace Provider {
|
||||
// MultiEditTool,
|
||||
WriteTool,
|
||||
TodoWriteTool,
|
||||
TaskTool,
|
||||
TodoReadTool,
|
||||
]
|
||||
const TOOL_MAPPING: Record<string, Tool.Info[]> = {
|
||||
@@ -306,11 +291,13 @@ export namespace Provider {
|
||||
google: TOOLS,
|
||||
}
|
||||
export async function tools(providerID: string) {
|
||||
/*
|
||||
const cfg = await Config.get()
|
||||
if (cfg.tool?.provider?.[providerID])
|
||||
return cfg.tool.provider[providerID].map(
|
||||
(id) => TOOLS.find((t) => t.id === id)!,
|
||||
)
|
||||
*/
|
||||
return TOOL_MAPPING[providerID] ?? TOOLS
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ import { Global } from "../global"
|
||||
import { mapValues } from "remeda"
|
||||
import { NamedError } from "../util/error"
|
||||
import { Fzf } from "../external/fzf"
|
||||
import { ModelsDev } from "../provider/models"
|
||||
import { Ripgrep } from "../external/ripgrep"
|
||||
import { Installation } from "../installation"
|
||||
|
||||
const ERRORS = {
|
||||
400: {
|
||||
@@ -55,12 +58,16 @@ export namespace Server {
|
||||
},
|
||||
)
|
||||
})
|
||||
.use((c, next) => {
|
||||
.use(async (c, next) => {
|
||||
log.info("request", {
|
||||
method: c.req.method,
|
||||
path: c.req.path,
|
||||
})
|
||||
return next()
|
||||
const start = Date.now()
|
||||
await next()
|
||||
log.info("response", {
|
||||
duration: Date.now() - start,
|
||||
})
|
||||
})
|
||||
.get(
|
||||
"/openapi",
|
||||
@@ -406,7 +413,7 @@ export namespace Server {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.object({
|
||||
providers: Provider.Info.array(),
|
||||
providers: ModelsDev.Provider.array(),
|
||||
default: z.record(z.string(), z.string()),
|
||||
}),
|
||||
),
|
||||
@@ -421,7 +428,7 @@ export namespace Server {
|
||||
)
|
||||
return c.json({
|
||||
providers: Object.values(providers),
|
||||
defaults: mapValues(
|
||||
default: mapValues(
|
||||
providers,
|
||||
(item) => Provider.sort(Object.values(item.models))[0].id,
|
||||
),
|
||||
@@ -452,10 +459,33 @@ export namespace Server {
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
const app = App.info()
|
||||
const result = await Fzf.search(app.path.cwd, body.query)
|
||||
const result = await Ripgrep.files({
|
||||
cwd: app.path.cwd,
|
||||
query: body.query,
|
||||
limit: 10,
|
||||
})
|
||||
return c.json(result)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"installation_info",
|
||||
describeRoute({
|
||||
description: "Get installation info",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Get installation info",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Installation.Info),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(Installation.info())
|
||||
},
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -4,32 +4,32 @@ import { Identifier } from "../id/id"
|
||||
import { Storage } from "../storage/storage"
|
||||
import { Log } from "../util/log"
|
||||
import {
|
||||
convertToModelMessages,
|
||||
generateText,
|
||||
LoadAPIKeyError,
|
||||
stepCountIs,
|
||||
convertToCoreMessages,
|
||||
streamText,
|
||||
tool,
|
||||
type Tool as AITool,
|
||||
type LanguageModelUsage,
|
||||
type CoreMessage,
|
||||
type UIMessage,
|
||||
type ProviderMetadata,
|
||||
} from "ai"
|
||||
import { z, ZodSchema } from "zod"
|
||||
import { Decimal } from "decimal.js"
|
||||
|
||||
import PROMPT_ANTHROPIC from "./prompt/anthropic.txt"
|
||||
import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt"
|
||||
import PROMPT_TITLE from "./prompt/title.txt"
|
||||
import PROMPT_SUMMARIZE from "./prompt/summarize.txt"
|
||||
import PROMPT_INITIALIZE from "../session/prompt/initialize.txt"
|
||||
|
||||
import { Share } from "../share/share"
|
||||
import { Message } from "./message"
|
||||
import { Bus } from "../bus"
|
||||
import { Provider } from "../provider/provider"
|
||||
import { SessionContext } from "./context"
|
||||
import { ListTool } from "../tool/ls"
|
||||
import { MCP } from "../mcp"
|
||||
import { NamedError } from "../util/error"
|
||||
import type { Tool } from "../tool/tool"
|
||||
import { SystemPrompt } from "./system"
|
||||
import { Flag } from "../flag/flag"
|
||||
import type { ModelsDev } from "../provider/models"
|
||||
|
||||
export namespace Session {
|
||||
const log = Log.create({ service: "session" })
|
||||
@@ -37,6 +37,7 @@ export namespace Session {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: Identifier.schema("session"),
|
||||
parentID: Identifier.schema("session").optional(),
|
||||
share: z
|
||||
.object({
|
||||
secret: z.string(),
|
||||
@@ -79,10 +80,13 @@ export namespace Session {
|
||||
}
|
||||
})
|
||||
|
||||
export async function create() {
|
||||
export async function create(parentID?: string) {
|
||||
const result: Info = {
|
||||
id: Identifier.descending("session"),
|
||||
title: "New Session - " + new Date().toISOString(),
|
||||
parentID,
|
||||
title:
|
||||
(parentID ? "Child session - " : "New Session - ") +
|
||||
new Date().toISOString(),
|
||||
time: {
|
||||
created: Date.now(),
|
||||
updated: Date.now(),
|
||||
@@ -91,11 +95,12 @@ export namespace Session {
|
||||
log.info("created", result)
|
||||
state().sessions.set(result.id, result)
|
||||
await Storage.writeJSON("session/info/" + result.id, result)
|
||||
share(result.id).then((share) => {
|
||||
update(result.id, (draft) => {
|
||||
draft.share = share
|
||||
if (!result.parentID && Flag.OPENCODE_AUTO_SHARE)
|
||||
share(result.id).then((share) => {
|
||||
update(result.id, (draft) => {
|
||||
draft.share = share
|
||||
})
|
||||
})
|
||||
})
|
||||
Bus.publish(Event.Updated, {
|
||||
info: result,
|
||||
})
|
||||
@@ -186,15 +191,21 @@ export namespace Session {
|
||||
providerID: string
|
||||
modelID: string
|
||||
parts: Message.Part[]
|
||||
system?: string[]
|
||||
tools?: Tool.Info[]
|
||||
}) {
|
||||
const l = log.clone().tag("session", input.sessionID)
|
||||
l.info("chatting")
|
||||
const model = await Provider.getModel(input.providerID, input.modelID)
|
||||
let msgs = await messages(input.sessionID)
|
||||
const previous = msgs.at(-1)
|
||||
|
||||
// auto summarize if too long
|
||||
if (previous?.metadata.assistant) {
|
||||
const tokens =
|
||||
previous.metadata.assistant.tokens.input +
|
||||
previous.metadata.assistant.tokens.cache.read +
|
||||
previous.metadata.assistant.tokens.cache.write +
|
||||
previous.metadata.assistant.tokens.output
|
||||
if (
|
||||
tokens >
|
||||
@@ -214,110 +225,46 @@ export namespace Session {
|
||||
const lastSummary = msgs.findLast(
|
||||
(msg) => msg.metadata.assistant?.summary === true,
|
||||
)
|
||||
if (lastSummary)
|
||||
msgs = msgs.filter(
|
||||
(msg) => msg.role === "system" || msg.id >= lastSummary.id,
|
||||
)
|
||||
if (lastSummary) msgs = msgs.filter((msg) => msg.id >= lastSummary.id)
|
||||
|
||||
if (msgs.length === 0) {
|
||||
const app = App.info()
|
||||
if (input.providerID === "anthropic") {
|
||||
const claude: Message.Info = {
|
||||
id: Identifier.ascending("message"),
|
||||
role: "system",
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: PROMPT_ANTHROPIC_SPOOF.trim(),
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
sessionID: input.sessionID,
|
||||
time: {
|
||||
created: Date.now(),
|
||||
},
|
||||
tool: {},
|
||||
},
|
||||
}
|
||||
await updateMessage(claude)
|
||||
msgs.push(claude)
|
||||
}
|
||||
const system: Message.Info = {
|
||||
id: Identifier.ascending("message"),
|
||||
role: "system",
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: PROMPT_ANTHROPIC,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: [
|
||||
`Here is some useful information about the environment you are running in:`,
|
||||
`<env>`,
|
||||
`Working directory: ${app.path.cwd}`,
|
||||
`Is directory a git repo: ${app.git ? "yes" : "no"}`,
|
||||
`Platform: ${process.platform}`,
|
||||
`Today's date: ${new Date().toISOString()}`,
|
||||
`</env>`,
|
||||
`<project>`,
|
||||
`${app.git ? await ListTool.execute({ path: app.path.cwd, ignore: [] }, { sessionID: input.sessionID, abort: abort.signal }).then((x) => x.output) : ""}`,
|
||||
`</project>`,
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
sessionID: input.sessionID,
|
||||
time: {
|
||||
created: Date.now(),
|
||||
},
|
||||
tool: {},
|
||||
},
|
||||
}
|
||||
const context = await SessionContext.find()
|
||||
if (context) {
|
||||
system.parts.push({
|
||||
type: "text",
|
||||
text: context,
|
||||
})
|
||||
}
|
||||
msgs.push(system)
|
||||
const app = App.info()
|
||||
const session = await get(input.sessionID)
|
||||
if (msgs.length === 0 && !session.parentID) {
|
||||
generateText({
|
||||
maxOutputTokens: 20,
|
||||
messages: convertToModelMessages([
|
||||
{
|
||||
role: "system",
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: PROMPT_ANTHROPIC_SPOOF.trim(),
|
||||
maxTokens: input.providerID === "google" ? 1024 : 20,
|
||||
messages: [
|
||||
...SystemPrompt.title(input.providerID).map(
|
||||
(x): CoreMessage => ({
|
||||
role: "system",
|
||||
content: x,
|
||||
providerOptions: {
|
||||
...(input.providerID === "anthropic"
|
||||
? {
|
||||
anthropic: {
|
||||
cacheControl: { type: "ephemeral" },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "system",
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: PROMPT_TITLE,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: input.parts,
|
||||
},
|
||||
]),
|
||||
temperature: 0,
|
||||
}),
|
||||
),
|
||||
...convertToCoreMessages([
|
||||
{
|
||||
role: "user",
|
||||
content: "",
|
||||
parts: toParts(input.parts),
|
||||
},
|
||||
]),
|
||||
],
|
||||
model: model.language,
|
||||
})
|
||||
.then((result) => {
|
||||
return Session.update(input.sessionID, (draft) => {
|
||||
draft.title = result.text
|
||||
})
|
||||
if (result.text)
|
||||
return Session.update(input.sessionID, (draft) => {
|
||||
draft.title = result.text
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
await updateMessage(system)
|
||||
}
|
||||
const msg: Message.Info = {
|
||||
role: "user",
|
||||
@@ -334,17 +281,27 @@ export namespace Session {
|
||||
await updateMessage(msg)
|
||||
msgs.push(msg)
|
||||
|
||||
const system = input.system ?? SystemPrompt.provider(input.providerID)
|
||||
system.push(...(await SystemPrompt.environment()))
|
||||
system.push(...(await SystemPrompt.custom()))
|
||||
|
||||
const next: Message.Info = {
|
||||
id: Identifier.ascending("message"),
|
||||
role: "assistant",
|
||||
parts: [],
|
||||
metadata: {
|
||||
assistant: {
|
||||
system,
|
||||
path: {
|
||||
cwd: app.path.cwd,
|
||||
root: app.path.root,
|
||||
},
|
||||
cost: 0,
|
||||
tokens: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
modelID: input.modelID,
|
||||
providerID: input.providerID,
|
||||
@@ -358,6 +315,7 @@ export namespace Session {
|
||||
}
|
||||
await updateMessage(next)
|
||||
const tools: Record<string, AITool> = {}
|
||||
|
||||
for (const item of await Provider.tools(input.providerID)) {
|
||||
tools[item.id.replaceAll(".", "_")] = tool({
|
||||
id: item.id as any,
|
||||
@@ -369,6 +327,7 @@ export namespace Session {
|
||||
const result = await item.execute(args, {
|
||||
sessionID: input.sessionID,
|
||||
abort: abort.signal,
|
||||
messageID: next.id,
|
||||
})
|
||||
next.metadata!.tool![opts.toolCallId] = {
|
||||
...result.metadata,
|
||||
@@ -395,6 +354,7 @@ export namespace Session {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
for (const [key, item] of Object.entries(await MCP.tools())) {
|
||||
const execute = item.execute
|
||||
if (!execute) continue
|
||||
@@ -432,13 +392,29 @@ export namespace Session {
|
||||
}
|
||||
|
||||
let text: Message.TextPart | undefined
|
||||
await Bun.write(
|
||||
"/tmp/message.json",
|
||||
JSON.stringify(
|
||||
[
|
||||
...system.map(
|
||||
(x): CoreMessage => ({
|
||||
role: "system",
|
||||
content: x,
|
||||
}),
|
||||
),
|
||||
...convertToCoreMessages(
|
||||
msgs.map(toUIMessage).filter((x) => x.parts.length > 0),
|
||||
),
|
||||
],
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
const result = streamText({
|
||||
onStepFinish: async (step) => {
|
||||
log.info("step finish", {
|
||||
finishReason: step.finishReason,
|
||||
})
|
||||
log.info("step finish", { finishReason: step.finishReason })
|
||||
const assistant = next.metadata!.assistant!
|
||||
const usage = getUsage(step.usage, model.info)
|
||||
const usage = getUsage(model.info, step.usage, step.providerMetadata)
|
||||
assistant.cost += usage.cost
|
||||
assistant.tokens = usage.tokens
|
||||
await updateMessage(next)
|
||||
@@ -451,18 +427,99 @@ export namespace Session {
|
||||
}
|
||||
text = undefined
|
||||
},
|
||||
async onChunk(input) {
|
||||
const value = input.chunk
|
||||
async onFinish(input) {
|
||||
log.info("message finish", {
|
||||
reason: input.finishReason,
|
||||
})
|
||||
const assistant = next.metadata!.assistant!
|
||||
const usage = getUsage(model.info, input.usage, input.providerMetadata)
|
||||
assistant.cost = usage.cost
|
||||
await updateMessage(next)
|
||||
},
|
||||
onError(err) {
|
||||
log.error("callback error", err)
|
||||
switch (true) {
|
||||
case LoadAPIKeyError.isInstance(err.error):
|
||||
next.metadata.error = new Provider.AuthError(
|
||||
{
|
||||
providerID: input.providerID,
|
||||
message: err.error.message,
|
||||
},
|
||||
{ cause: err.error },
|
||||
).toObject()
|
||||
break
|
||||
case err.error instanceof Error:
|
||||
next.metadata.error = new NamedError.Unknown(
|
||||
{ message: err.error.toString() },
|
||||
{ cause: err.error },
|
||||
).toObject()
|
||||
break
|
||||
default:
|
||||
next.metadata.error = new NamedError.Unknown(
|
||||
{ message: JSON.stringify(err.error) },
|
||||
{ cause: err.error },
|
||||
)
|
||||
}
|
||||
Bus.publish(Event.Error, {
|
||||
error: next.metadata.error,
|
||||
})
|
||||
},
|
||||
// async prepareStep(step) {
|
||||
// next.parts.push({
|
||||
// type: "step-start",
|
||||
// })
|
||||
// await updateMessage(next)
|
||||
// return step
|
||||
// },
|
||||
toolCallStreaming: true,
|
||||
abortSignal: abort.signal,
|
||||
maxSteps: 1000,
|
||||
messages: [
|
||||
...system.map(
|
||||
(x, index): CoreMessage => ({
|
||||
role: "system",
|
||||
content: x,
|
||||
providerOptions: {
|
||||
...(input.providerID === "anthropic" && index < 4
|
||||
? {
|
||||
anthropic: {
|
||||
cacheControl: { type: "ephemeral" },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}),
|
||||
),
|
||||
...convertToCoreMessages(
|
||||
msgs.map(toUIMessage).filter((x) => x.parts.length > 0),
|
||||
),
|
||||
],
|
||||
temperature: model.info.id === "codex-mini-latest" ? undefined : 0,
|
||||
tools: {
|
||||
...tools,
|
||||
},
|
||||
model: model.language,
|
||||
})
|
||||
try {
|
||||
for await (const value of result.fullStream) {
|
||||
l.info("part", {
|
||||
type: value.type,
|
||||
})
|
||||
switch (value.type) {
|
||||
case "text":
|
||||
case "step-start":
|
||||
next.parts.push({
|
||||
type: "step-start",
|
||||
})
|
||||
break
|
||||
case "text-delta":
|
||||
if (!text) {
|
||||
text = value
|
||||
next.parts.push(value)
|
||||
text = {
|
||||
type: "text",
|
||||
text: value.textDelta,
|
||||
}
|
||||
next.parts.push(text)
|
||||
break
|
||||
} else text.text += value.text
|
||||
} else text.text += value.textDelta
|
||||
break
|
||||
|
||||
case "tool-call": {
|
||||
@@ -503,18 +560,25 @@ export namespace Session {
|
||||
case "tool-call-delta":
|
||||
break
|
||||
|
||||
// for some reason ai sdk claims to not send this part but it does
|
||||
// @ts-expect-error
|
||||
case "tool-result":
|
||||
const match = next.parts.find(
|
||||
(p) =>
|
||||
p.type === "tool-invocation" &&
|
||||
// @ts-expect-error
|
||||
p.toolInvocation.toolCallId === value.toolCallId,
|
||||
)
|
||||
if (match && match.type === "tool-invocation") {
|
||||
match.toolInvocation = {
|
||||
// @ts-expect-error
|
||||
args: value.args,
|
||||
// @ts-expect-error
|
||||
toolCallId: value.toolCallId,
|
||||
// @ts-expect-error
|
||||
toolName: value.toolName,
|
||||
state: "result",
|
||||
// @ts-expect-error
|
||||
result: value.result as string,
|
||||
}
|
||||
Bus.publish(Message.Event.PartUpdated, {
|
||||
@@ -531,66 +595,37 @@ export namespace Session {
|
||||
})
|
||||
}
|
||||
await updateMessage(next)
|
||||
},
|
||||
async onFinish(input) {
|
||||
const assistant = next.metadata!.assistant!
|
||||
const usage = getUsage(input.totalUsage, model.info)
|
||||
assistant.cost = usage.cost
|
||||
await updateMessage(next)
|
||||
},
|
||||
onError(err) {
|
||||
log.error("error", err)
|
||||
switch (true) {
|
||||
case LoadAPIKeyError.isInstance(err.error):
|
||||
next.metadata.error = new Provider.AuthError(
|
||||
{
|
||||
providerID: input.providerID,
|
||||
message: err.error.message,
|
||||
},
|
||||
{ cause: err.error },
|
||||
).toObject()
|
||||
break
|
||||
case err.error instanceof Error:
|
||||
next.metadata.error = new NamedError.Unknown(
|
||||
{ message: err.error.toString() },
|
||||
{ cause: err.error },
|
||||
).toObject()
|
||||
break
|
||||
default:
|
||||
next.metadata.error = new NamedError.Unknown(
|
||||
{ message: JSON.stringify(err.error) },
|
||||
{ cause: err.error },
|
||||
)
|
||||
}
|
||||
Bus.publish(Event.Error, {
|
||||
error: next.metadata.error,
|
||||
})
|
||||
},
|
||||
async prepareStep(step) {
|
||||
next.parts.push({
|
||||
type: "step-start",
|
||||
})
|
||||
await updateMessage(next)
|
||||
return step
|
||||
},
|
||||
toolCallStreaming: true,
|
||||
abortSignal: abort.signal,
|
||||
stopWhen: stepCountIs(1000),
|
||||
messages: convertToModelMessages(msgs),
|
||||
temperature: model.info.id === "codex-mini-latest" ? undefined : 0,
|
||||
tools: {
|
||||
...(await MCP.tools()),
|
||||
...tools,
|
||||
},
|
||||
model: model.language,
|
||||
})
|
||||
await result.consumeStream({
|
||||
onError: (err) => {
|
||||
log.error("stream error", {
|
||||
err,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch (e: any) {
|
||||
log.error("stream error", {
|
||||
error: e,
|
||||
})
|
||||
switch (true) {
|
||||
case LoadAPIKeyError.isInstance(e):
|
||||
next.metadata.error = new Provider.AuthError(
|
||||
{
|
||||
providerID: input.providerID,
|
||||
message: e.message,
|
||||
},
|
||||
{ cause: e },
|
||||
).toObject()
|
||||
break
|
||||
case e instanceof Error:
|
||||
next.metadata.error = new NamedError.Unknown(
|
||||
{ message: e.toString() },
|
||||
{ cause: e },
|
||||
).toObject()
|
||||
break
|
||||
default:
|
||||
next.metadata.error = new NamedError.Unknown(
|
||||
{ message: JSON.stringify(e) },
|
||||
{ cause: e },
|
||||
)
|
||||
}
|
||||
Bus.publish(Event.Error, {
|
||||
error: next.metadata.error,
|
||||
})
|
||||
}
|
||||
next.metadata!.time.completed = Date.now()
|
||||
for (const part of next.parts) {
|
||||
if (
|
||||
@@ -618,10 +653,11 @@ export namespace Session {
|
||||
const lastSummary = msgs.findLast(
|
||||
(msg) => msg.metadata.assistant?.summary === true,
|
||||
)?.id
|
||||
const filtered = msgs.filter(
|
||||
(msg) => msg.role !== "system" && (!lastSummary || msg.id >= lastSummary),
|
||||
)
|
||||
const filtered = msgs.filter((msg) => !lastSummary || msg.id >= lastSummary)
|
||||
const model = await Provider.getModel(input.providerID, input.modelID)
|
||||
const app = App.info()
|
||||
const system = SystemPrompt.summarize(input.providerID)
|
||||
|
||||
const next: Message.Info = {
|
||||
id: Identifier.ascending("message"),
|
||||
role: "assistant",
|
||||
@@ -630,6 +666,11 @@ export namespace Session {
|
||||
tool: {},
|
||||
sessionID: input.sessionID,
|
||||
assistant: {
|
||||
system,
|
||||
path: {
|
||||
cwd: app.path.cwd,
|
||||
root: app.path.root,
|
||||
},
|
||||
summary: true,
|
||||
cost: 0,
|
||||
modelID: input.modelID,
|
||||
@@ -638,6 +679,7 @@ export namespace Session {
|
||||
input: 0,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
},
|
||||
time: {
|
||||
@@ -649,34 +691,31 @@ export namespace Session {
|
||||
const result = await generateText({
|
||||
abortSignal: abort.signal,
|
||||
model: model.language,
|
||||
messages: convertToModelMessages([
|
||||
{
|
||||
role: "system",
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: PROMPT_SUMMARIZE,
|
||||
},
|
||||
],
|
||||
},
|
||||
...filtered,
|
||||
messages: [
|
||||
...system.map(
|
||||
(x): CoreMessage => ({
|
||||
role: "system",
|
||||
content: x,
|
||||
}),
|
||||
),
|
||||
...convertToCoreMessages(filtered.map(toUIMessage)),
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Provide a detailed but concise summary of our conversation above. Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next.",
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
],
|
||||
})
|
||||
next.parts.push({
|
||||
type: "text",
|
||||
text: result.text,
|
||||
})
|
||||
const assistant = next.metadata!.assistant!
|
||||
const usage = getUsage(result.usage, model.info)
|
||||
const usage = getUsage(model.info, result.usage, result.providerMetadata)
|
||||
assistant.cost = usage.cost
|
||||
assistant.tokens = usage.tokens
|
||||
await updateMessage(next)
|
||||
@@ -697,11 +736,21 @@ export namespace Session {
|
||||
}
|
||||
}
|
||||
|
||||
function getUsage(usage: LanguageModelUsage, model: Provider.Model) {
|
||||
function getUsage(
|
||||
model: ModelsDev.Model,
|
||||
usage: LanguageModelUsage,
|
||||
metadata?: ProviderMetadata,
|
||||
) {
|
||||
const tokens = {
|
||||
input: usage.inputTokens ?? 0,
|
||||
output: usage.outputTokens ?? 0,
|
||||
reasoning: usage.reasoningTokens ?? 0,
|
||||
input: usage.promptTokens ?? 0,
|
||||
output: usage.completionTokens ?? 0,
|
||||
reasoning: 0,
|
||||
cache: {
|
||||
write: (metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
|
||||
0) as number,
|
||||
read: (metadata?.["anthropic"]?.["cacheReadInputTokens"] ??
|
||||
0) as number,
|
||||
},
|
||||
}
|
||||
return {
|
||||
cost: new Decimal(0)
|
||||
@@ -738,3 +787,57 @@ export namespace Session {
|
||||
await App.initialize()
|
||||
}
|
||||
}
|
||||
|
||||
function toUIMessage(msg: Message.Info): UIMessage {
|
||||
if (msg.role === "assistant") {
|
||||
return {
|
||||
id: msg.id,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
parts: toParts(msg.parts),
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.role === "user") {
|
||||
return {
|
||||
id: msg.id,
|
||||
role: "user",
|
||||
content: "",
|
||||
parts: toParts(msg.parts),
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("not implemented")
|
||||
}
|
||||
|
||||
function toParts(parts: Message.Part[]): UIMessage["parts"] {
|
||||
const result: UIMessage["parts"] = []
|
||||
for (const part of parts) {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
result.push({ type: "text", text: part.text })
|
||||
break
|
||||
case "file":
|
||||
result.push({
|
||||
type: "file",
|
||||
data: part.url,
|
||||
mimeType: part.mediaType,
|
||||
})
|
||||
break
|
||||
case "tool-invocation":
|
||||
result.push({
|
||||
type: "tool-invocation",
|
||||
toolInvocation: part.toolInvocation,
|
||||
})
|
||||
break
|
||||
case "step-start":
|
||||
result.push({
|
||||
type: "step-start",
|
||||
})
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ export namespace Message {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
role: z.enum(["system", "user", "assistant"]),
|
||||
role: z.enum(["user", "assistant"]),
|
||||
parts: z.array(Part),
|
||||
metadata: z.object({
|
||||
time: z.object({
|
||||
@@ -161,14 +161,23 @@ export namespace Message {
|
||||
),
|
||||
assistant: z
|
||||
.object({
|
||||
system: z.string().array(),
|
||||
modelID: z.string(),
|
||||
providerID: z.string(),
|
||||
path: z.object({
|
||||
cwd: z.string(),
|
||||
root: z.string(),
|
||||
}),
|
||||
cost: z.number(),
|
||||
summary: z.boolean().optional(),
|
||||
tokens: z.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
reasoning: z.number(),
|
||||
cache: z.object({
|
||||
read: z.number(),
|
||||
write: z.number(),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.optional(),
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
You are OpenCode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
|
||||
You are opencode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
|
||||
|
||||
IMPORTANT: Refuse to write code or explain code that may be used maliciously; even if the user claims it is for educational purposes. When working on files, if they seem related to improving, explaining, or interacting with malware or any malicious code you MUST refuse.
|
||||
IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure. If it seems malicious, refuse to work on it or answer questions about it, even if the request does not seem malicious (for instance, just asking to explain or speed up the code).
|
||||
IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.
|
||||
|
||||
If the user asks for help or wants to give feedback inform them of the following:
|
||||
- /help: Get help with using OpenCode
|
||||
- /help: Get help with using opencode
|
||||
- To give feedback, users should report the issue at https://github.com/sst/opencode/issues
|
||||
|
||||
When the user directly asks about OpenCode (eg 'can OpenCode do...', 'does OpenCode have...') or asks in second person (eg 'are you able...', 'can you do...'), first use the WebFetch tool to gather information to answer the question from OpenCode docs at https://opencode.ai
|
||||
When the user directly asks about opencode (eg 'can opencode do...', 'does opencode have...') or asks in second person (eg 'are you able...', 'can you do...'), first use the WebFetch tool to gather information to answer the question from opencode docs at https://opencode.ai
|
||||
|
||||
# Tone and style
|
||||
You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
|
||||
|
||||
124
packages/opencode/src/session/system.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { App } from "../app/app"
|
||||
import { Ripgrep } from "../external/ripgrep"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
|
||||
import PROMPT_ANTHROPIC from "./prompt/anthropic.txt"
|
||||
import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt"
|
||||
import PROMPT_SUMMARIZE from "./prompt/summarize.txt"
|
||||
import PROMPT_TITLE from "./prompt/title.txt"
|
||||
|
||||
export namespace SystemPrompt {
|
||||
export function provider(providerID: string) {
|
||||
const result = []
|
||||
switch (providerID) {
|
||||
case "anthropic":
|
||||
result.push(PROMPT_ANTHROPIC_SPOOF.trim())
|
||||
result.push(PROMPT_ANTHROPIC)
|
||||
break
|
||||
default:
|
||||
result.push(PROMPT_ANTHROPIC)
|
||||
break
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function environment() {
|
||||
const app = App.info()
|
||||
|
||||
const tree = async () => {
|
||||
const files = await Ripgrep.files({
|
||||
cwd: app.path.cwd,
|
||||
})
|
||||
type Node = {
|
||||
children: Record<string, Node>
|
||||
}
|
||||
const root: Node = {
|
||||
children: {},
|
||||
}
|
||||
for (const file of files) {
|
||||
const parts = file.split("/")
|
||||
let node = root
|
||||
for (const part of parts) {
|
||||
const existing = node.children[part]
|
||||
if (existing) {
|
||||
node = existing
|
||||
continue
|
||||
}
|
||||
node.children[part] = {
|
||||
children: {},
|
||||
}
|
||||
node = node.children[part]
|
||||
}
|
||||
}
|
||||
|
||||
function render(path: string[], node: Node): string {
|
||||
// if (path.length === 3) return "\t".repeat(path.length) + "..."
|
||||
const lines: string[] = []
|
||||
const entries = Object.entries(node.children).sort(([a], [b]) =>
|
||||
a.localeCompare(b),
|
||||
)
|
||||
|
||||
for (const [name, child] of entries) {
|
||||
const currentPath = [...path, name]
|
||||
const indent = "\t".repeat(path.length)
|
||||
const hasChildren = Object.keys(child.children).length > 0
|
||||
lines.push(`${indent}${name}` + (hasChildren ? "/" : ""))
|
||||
|
||||
if (hasChildren) lines.push(render(currentPath, child))
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
const result = render([], root)
|
||||
return result
|
||||
}
|
||||
|
||||
return [
|
||||
[
|
||||
`Here is some useful information about the environment you are running in:`,
|
||||
`<env>`,
|
||||
` Working directory: ${app.path.cwd}`,
|
||||
` Is directory a git repo: ${app.git ? "yes" : "no"}`,
|
||||
` Platform: ${process.platform}`,
|
||||
` Today's date: ${new Date().toDateString()}`,
|
||||
`</env>`,
|
||||
`<project>`,
|
||||
` ${app.git ? await tree() : ""}`,
|
||||
`</project>`,
|
||||
].join("\n"),
|
||||
]
|
||||
}
|
||||
|
||||
const CUSTOM_FILES = [
|
||||
"AGENTS.md",
|
||||
"CLAUDE.md",
|
||||
"CONTEXT.md", // deprecated
|
||||
]
|
||||
export async function custom() {
|
||||
const { cwd, root } = App.info().path
|
||||
const found = []
|
||||
for (const item of CUSTOM_FILES) {
|
||||
const matches = await Filesystem.findUp(item, cwd, root)
|
||||
found.push(...matches.map((x) => Bun.file(x).text()))
|
||||
}
|
||||
return Promise.all(found)
|
||||
}
|
||||
|
||||
export function summarize(providerID: string) {
|
||||
switch (providerID) {
|
||||
case "anthropic":
|
||||
return [PROMPT_ANTHROPIC_SPOOF.trim(), PROMPT_SUMMARIZE]
|
||||
default:
|
||||
return [PROMPT_SUMMARIZE]
|
||||
}
|
||||
}
|
||||
|
||||
export function title(providerID: string) {
|
||||
switch (providerID) {
|
||||
case "anthropic":
|
||||
return [PROMPT_ANTHROPIC_SPOOF.trim(), PROMPT_TITLE]
|
||||
default:
|
||||
return [PROMPT_TITLE]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ Usage notes:
|
||||
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
|
||||
- If the output exceeds 30000 characters, output will be truncated before being returned to you.
|
||||
- VERY IMPORTANT: You MUST avoid using search commands like `find` and `grep`. Instead use Grep, Glob, or Task to search. You MUST avoid read tools like `cat`, `head`, `tail`, and `ls`, and use Read and LS to read files.
|
||||
- If you _still_ need to run `grep`, STOP. ALWAYS USE ripgrep at `rg` (or /usr/bin/rg) first, which all OpenCode users have pre-installed.
|
||||
- If you _still_ need to run `grep`, STOP. ALWAYS USE ripgrep at `rg` (or /usr/bin/rg) first, which all opencode users have pre-installed.
|
||||
- When issuing multiple commands, use the ';' or '&&' operator to separate them. DO NOT use newlines (newlines are ok in quoted strings).
|
||||
- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of `cd`. You may use `cd` if the User explicitly requests it.
|
||||
<good-example>
|
||||
@@ -60,9 +60,9 @@ When the user asks you to create a new git commit, follow these steps carefully:
|
||||
3. You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. ALWAYS run the following commands in parallel:
|
||||
- Add relevant untracked files to the staging area.
|
||||
- Create the commit with a message ending with:
|
||||
🤖 Generated with [OpenCode](https://opencode.ai)
|
||||
🤖 Generated with [opencode](https://opencode.ai)
|
||||
|
||||
Co-Authored-By: OpenCode <noreply@opencode.ai>
|
||||
Co-Authored-By: opencode <noreply@opencode.ai>
|
||||
- Run git status to make sure the commit succeeded.
|
||||
|
||||
4. If the commit fails due to pre-commit hook changes, retry the commit ONCE to include these automated changes. If it fails again, it usually means a pre-commit hook is preventing the commit. If the commit succeeds but you notice that files were modified by the pre-commit hook, you MUST amend your commit to include them.
|
||||
@@ -81,9 +81,9 @@ Important notes:
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Commit message here.
|
||||
|
||||
🤖 Generated with [OpenCode](https://opencode.ai)
|
||||
🤖 Generated with [opencode](https://opencode.ai)
|
||||
|
||||
Co-Authored-By: OpenCode <noreply@opencode.ai>
|
||||
Co-Authored-By: opencode <noreply@opencode.ai>
|
||||
EOF
|
||||
)"
|
||||
</example>
|
||||
@@ -128,7 +128,7 @@ gh pr create --title "the pr title" --body "$(cat <<'EOF'
|
||||
## Test plan
|
||||
[Checklist of TODOs for testing the pull request...]
|
||||
|
||||
🤖 Generated with [OpenCode](https://opencode.ai)
|
||||
🤖 Generated with [opencode](https://opencode.ai)
|
||||
EOF
|
||||
)"
|
||||
</example>
|
||||
|
||||
@@ -27,7 +27,7 @@ export const GlobTool = Tool.define({
|
||||
const glob = new Bun.Glob(params.pattern)
|
||||
const files = []
|
||||
let truncated = false
|
||||
for await (const file of glob.scan({ cwd: search })) {
|
||||
for await (const file of glob.scan({ cwd: search, dot: true })) {
|
||||
if (files.length >= limit) {
|
||||
truncated = true
|
||||
break
|
||||
|
||||
@@ -4,7 +4,7 @@ import { App } from "../app/app"
|
||||
import * as path from "path"
|
||||
import DESCRIPTION from "./ls.txt"
|
||||
|
||||
const IGNORE_PATTERNS = [
|
||||
export const IGNORE_PATTERNS = [
|
||||
"node_modules/",
|
||||
"__pycache__/",
|
||||
".git/",
|
||||
@@ -18,6 +18,8 @@ const IGNORE_PATTERNS = [
|
||||
".vscode/",
|
||||
]
|
||||
|
||||
const LIMIT = 100
|
||||
|
||||
export const ListTool = Tool.define({
|
||||
id: "opencode.list",
|
||||
description: DESCRIPTION,
|
||||
@@ -40,13 +42,12 @@ export const ListTool = Tool.define({
|
||||
const glob = new Bun.Glob("**/*")
|
||||
const files = []
|
||||
|
||||
for await (const file of glob.scan({ cwd: searchPath })) {
|
||||
if (file.startsWith(".") || IGNORE_PATTERNS.some((p) => file.includes(p)))
|
||||
continue
|
||||
for await (const file of glob.scan({ cwd: searchPath, dot: true })) {
|
||||
if (IGNORE_PATTERNS.some((p) => file.includes(p))) continue
|
||||
if (params.ignore?.some((pattern) => new Bun.Glob(pattern).match(file)))
|
||||
continue
|
||||
files.push(file)
|
||||
if (files.length >= 1000) break
|
||||
if (files.length >= LIMIT) break
|
||||
}
|
||||
|
||||
// Build directory structure
|
||||
@@ -100,7 +101,7 @@ export const ListTool = Tool.define({
|
||||
return {
|
||||
metadata: {
|
||||
count: files.length,
|
||||
truncated: files.length >= 1000,
|
||||
truncated: files.length >= LIMIT,
|
||||
title: path.relative(app.path.root, searchPath),
|
||||
},
|
||||
output,
|
||||
|
||||
39
packages/opencode/src/tool/task.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Tool } from "./tool"
|
||||
import DESCRIPTION from "./task.txt"
|
||||
import { z } from "zod"
|
||||
import { Session } from "../session"
|
||||
|
||||
export const TaskTool = Tool.define({
|
||||
id: "opencode.task",
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
description: z
|
||||
.string()
|
||||
.describe("A short (3-5 words) description of the task"),
|
||||
prompt: z.string().describe("The task for the agent to perform"),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
const session = await Session.create(ctx.sessionID)
|
||||
const msg = await Session.getMessage(ctx.sessionID, ctx.messageID)
|
||||
const metadata = msg.metadata.assistant!
|
||||
|
||||
const result = await Session.chat({
|
||||
sessionID: session.id,
|
||||
modelID: metadata.modelID,
|
||||
providerID: metadata.providerID,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: params.prompt,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
title: params.description,
|
||||
},
|
||||
output: result.parts.findLast((x) => x.type === "text")!.text,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -7,6 +7,7 @@ export namespace Tool {
|
||||
}
|
||||
export type Context = {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
abort: AbortSignal
|
||||
}
|
||||
export interface Info<
|
||||
|
||||
@@ -29,7 +29,10 @@ export abstract class NamedError extends Error {
|
||||
) {
|
||||
super(name, options)
|
||||
this.name = name
|
||||
log.error(name, this.data)
|
||||
log.error(name, {
|
||||
...this.data,
|
||||
cause: options?.cause?.toString(),
|
||||
})
|
||||
}
|
||||
|
||||
static isInstance(input: any): input is InstanceType<typeof result> {
|
||||
|
||||
@@ -2,7 +2,7 @@ import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Global } from "../global"
|
||||
export namespace Log {
|
||||
export const Default = create()
|
||||
export const Default = create({ service: "default" })
|
||||
|
||||
export interface Options {
|
||||
print: boolean
|
||||
@@ -45,6 +45,7 @@ export namespace Log {
|
||||
)
|
||||
}
|
||||
|
||||
let last = Date.now()
|
||||
export function create(tags?: Record<string, any>) {
|
||||
tags = tags || {}
|
||||
|
||||
@@ -56,9 +57,13 @@ export namespace Log {
|
||||
.filter(([_, value]) => value !== undefined && value !== null)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(" ")
|
||||
const next = new Date()
|
||||
const diff = next.getTime() - last
|
||||
last = next.getTime()
|
||||
return (
|
||||
[new Date().toISOString(), prefix, message].filter(Boolean).join(" ") +
|
||||
"\n"
|
||||
[next.toISOString().split(".")[0], "+" + diff + "ms", prefix, message]
|
||||
.filter(Boolean)
|
||||
.join(" ") + "\n"
|
||||
)
|
||||
}
|
||||
const result = {
|
||||
@@ -78,6 +83,23 @@ export namespace Log {
|
||||
clone() {
|
||||
return Log.create({ ...tags })
|
||||
},
|
||||
time(message: string, extra?: Record<string, any>) {
|
||||
const now = Date.now()
|
||||
result.info(message, { status: "started", ...extra })
|
||||
function stop() {
|
||||
result.info(message, {
|
||||
status: "completed",
|
||||
duration: Date.now() - now,
|
||||
...extra,
|
||||
})
|
||||
}
|
||||
return {
|
||||
stop,
|
||||
[Symbol.dispose]() {
|
||||
stop()
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
time=2025-05-30T22:01:45.386-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-05-30T22:01:45.391-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-05-30T22:01:50.683-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-05-30T22:01:50.683-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-05-30T22:01:50.683-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-05-30T22:01:50.683-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-05-30T22:01:50.683-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-05-30T22:01:50.683-04:00 level=INFO msg="TUI exited" result="{width:98 height:57 currentPage:chat previousPage: pages:map[chat:0xc00013b450] loadedPages:map[chat:true] status:{app:0xc0002e05b0 queue:[] width:98 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0002e05b0 showPermissions:false permissions:0xc000159408 showHelp:false help:0xc0006822d0 showQuit:true quit:0xc00024b479 showSessionDialog:false sessionDialog:0xc0001f0240 showCommandDialog:false commandDialog:0xc0003cbba0 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a7a0} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a620}] showModelDialog:false modelDialog:0xc0001f45a0 showInitDialog:false initDialog:{width:98 height:57 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0001f0480 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0001f04c0}"
|
||||
time=2025-05-30T22:13:24.046-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-05-30T22:13:24.051-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-05-30T22:13:25.991-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-05-30T22:13:25.991-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-05-30T22:13:25.991-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-05-30T22:13:25.991-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-05-30T22:13:25.991-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-05-30T22:13:25.991-04:00 level=INFO msg="TUI exited" result="{width:199 height:57 currentPage:chat previousPage: pages:map[chat:0xc00025f950] loadedPages:map[chat:true] status:{app:0xc0000ca230 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0000ca230 showPermissions:false permissions:0xc00029f908 showHelp:false help:0xc00045d9b0 showQuit:true quit:0xc0005a0be9 showSessionDialog:false sessionDialog:0xc00012e3c0 showCommandDialog:false commandDialog:0xc0004379e0 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a7a0} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a620}] showModelDialog:false modelDialog:0xc0002f2e60 showInitDialog:false initDialog:{width:199 height:57 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc00013ac88 showThemeDialog:false themeDialog:0xc00012e600 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc00012e640}"
|
||||
time=2025-05-31T16:00:29.137-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-05-31T16:00:29.141-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-05-31T16:00:36.530-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-05-31T16:00:36.531-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-05-31T16:00:36.531-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-05-31T16:00:36.531-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-05-31T16:00:36.531-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-05-31T16:00:36.531-04:00 level=INFO msg="TUI exited" result="{width:106 height:54 currentPage:chat previousPage: pages:map[chat:0xc000157450] loadedPages:map[chat:true] status:{app:0xc00020c5b0 queue:[] width:106 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc00020c5b0 showPermissions:false permissions:0xc000175408 showHelp:false help:0xc00070c270 showQuit:true quit:0xc000299979 showSessionDialog:false sessionDialog:0xc0001f02c0 showCommandDialog:false commandDialog:0xc0003cbba0 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a7a0} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a620}] showModelDialog:false modelDialog:0xc00021a5a0 showInitDialog:false initDialog:{width:106 height:54 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0001f0500 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0001f0540}"
|
||||
time=2025-05-31T16:06:20.089-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-05-31T16:06:20.094-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-05-31T16:06:20.095-04:00 level=ERROR msg="Failed to subscribe to events" error="Get \"http://localhost:16713/event\": dial tcp [::1]:16713: connect: connection refused"
|
||||
time=2025-05-31T17:54:04.009-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-05-31T17:54:04.014-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-05-31T17:54:06.337-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-05-31T17:54:06.337-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-05-31T17:54:06.337-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-05-31T17:54:06.337-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-05-31T17:54:06.337-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-05-31T17:54:06.337-04:00 level=INFO msg="TUI exited" result="{width:106 height:25 currentPage:chat previousPage: pages:map[chat:0xc0002332c0] loadedPages:map[chat:true] status:{app:0xc0002b1810 queue:[] width:106 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0002b1810 showPermissions:false permissions:0xc000267408 showHelp:false help:0xc00048dbc0 showQuit:true quit:0xc0004a2719 showSessionDialog:false sessionDialog:0xc000319ec0 showCommandDialog:false commandDialog:0xc000387980 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a7a0} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a620}] showModelDialog:false modelDialog:0xc0000c6960 showInitDialog:false initDialog:{width:106 height:25 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d6c88 showThemeDialog:false themeDialog:0xc0000ac480 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000ac4c0}"
|
||||
time=2025-05-31T17:54:17.103-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-05-31T17:54:17.108-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-05-31T17:54:18.391-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-05-31T17:54:18.392-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-05-31T17:54:18.392-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-05-31T17:54:18.392-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-05-31T17:54:18.392-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-05-31T17:54:18.392-04:00 level=INFO msg="TUI exited" result="{width:106 height:25 currentPage:chat previousPage: pages:map[chat:0xc00042a960] loadedPages:map[chat:true] status:{app:0xc000163ce0 queue:[] width:106 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc000163ce0 showPermissions:false permissions:0xc0001df408 showHelp:false help:0xc0005198f0 showQuit:true quit:0xc0003a5ef9 showSessionDialog:false sessionDialog:0xc000323840 showCommandDialog:false commandDialog:0xc00043b0e0 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a7a0} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a620}] showModelDialog:false modelDialog:0xc0004028c0 showInitDialog:false initDialog:{width:106 height:25 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d6c88 showThemeDialog:false themeDialog:0xc000323a80 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc000323ac0}"
|
||||
time=2025-05-31T17:59:54.360-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-05-31T17:59:54.364-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-05-31T17:59:55.814-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-05-31T17:59:55.815-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-05-31T17:59:55.815-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-05-31T17:59:55.815-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-05-31T17:59:55.815-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-05-31T17:59:55.815-04:00 level=INFO msg="TUI exited" result="{width:106 height:25 currentPage:chat previousPage: pages:map[chat:0xc0002787d0] loadedPages:map[chat:true] status:{app:0xc0003fed90 queue:[] width:106 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0003fed90 showPermissions:false permissions:0xc0002b1908 showHelp:false help:0xc000126150 showQuit:true quit:0xc00011d439 showSessionDialog:false sessionDialog:0xc00025e380 showCommandDialog:false commandDialog:0xc00047fc00 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a7a0} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a620}] showModelDialog:false modelDialog:0xc0002f6d20 showInitDialog:false initDialog:{width:106 height:25 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0001b2c88 showThemeDialog:false themeDialog:0xc00025e5c0 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc00025e600}"
|
||||
time=2025-05-31T17:59:56.746-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-05-31T17:59:56.750-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-05-31T18:00:10.757-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-05-31T18:00:10.757-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-05-31T18:00:10.757-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-05-31T18:00:10.757-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-05-31T18:00:10.757-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-05-31T18:00:10.757-04:00 level=INFO msg="TUI exited" result="{width:211 height:54 currentPage:chat previousPage: pages:map[chat:0xc00053b090] loadedPages:map[chat:true] status:{app:0xc000300cb0 queue:[] width:211 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc000300cb0 showPermissions:false permissions:0xc0002c5408 showHelp:false help:0xc000682f90 showQuit:true quit:0xc0006134d9 showSessionDialog:false sessionDialog:0xc00031f980 showCommandDialog:false commandDialog:0xc0003d9520 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a7a0} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a620}] showModelDialog:false modelDialog:0xc000395220 showInitDialog:false initDialog:{width:211 height:54 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc00013ac88 showThemeDialog:false themeDialog:0xc00031fbc0 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc00031fc00}"
|
||||
time=2025-05-31T18:35:42.289-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-05-31T18:35:42.294-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-05-31T18:36:54.773-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-05-31T18:36:54.773-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-05-31T18:36:54.773-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-05-31T18:36:54.773-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-05-31T18:36:54.773-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-05-31T18:36:54.773-04:00 level=INFO msg="TUI exited" result="{width:106 height:25 currentPage:chat previousPage: pages:map[chat:0xc00012f0e0] loadedPages:map[chat:true] status:{app:0xc0002aa070 queue:[] width:106 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0002aa070 showPermissions:false permissions:0xc000267408 showHelp:false help:0xc00041b8f0 showQuit:true quit:0xc000345ee9 showSessionDialog:false sessionDialog:0xc00032ba40 showCommandDialog:false commandDialog:0xc00043b300 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a7a0} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a620}] showModelDialog:false modelDialog:0xc000426f00 showInitDialog:false initDialog:{width:106 height:25 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d6c88 showThemeDialog:false themeDialog:0xc00032bc80 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc00032bcc0}"
|
||||
time=2025-05-31T18:36:56.011-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-05-31T18:36:56.015-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-05-31T18:37:44.063-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-05-31T18:37:44.064-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-05-31T18:37:44.064-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-05-31T18:37:44.064-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-05-31T18:37:44.064-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-05-31T18:37:44.064-04:00 level=INFO msg="TUI exited" result="{width:211 height:54 currentPage:chat previousPage: pages:map[chat:0xc000420280] loadedPages:map[chat:true] status:{app:0xc0002d8000 queue:[] width:211 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0002d8000 showPermissions:false permissions:0xc000271408 showHelp:false help:0xc00048da70 showQuit:true quit:0xc000390809 showSessionDialog:false sessionDialog:0xc000323b80 showCommandDialog:false commandDialog:0xc0003e5920 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a7a0} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a620}] showModelDialog:false modelDialog:0xc00025f9a0 showInitDialog:false initDialog:{width:211 height:54 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d6c88 showThemeDialog:false themeDialog:0xc000323dc0 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc000323e00}"
|
||||
time=2025-05-31T20:32:32.443-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-05-31T20:32:32.448-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-05-31T20:33:09.783-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-05-31T20:33:09.783-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-05-31T20:33:09.783-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-05-31T20:33:09.783-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-05-31T20:33:09.783-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-05-31T20:33:09.783-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc00032c960] loadedPages:map[chat:true] status:{app:0xc000279420 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc000279420 showPermissions:false permissions:0xc0001fb408 showHelp:false help:0xc000154150 showQuit:true quit:0xc000528849 showSessionDialog:false sessionDialog:0xc000309e40 showCommandDialog:false commandDialog:0xc0003a3800 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a800} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a680}] showModelDialog:false modelDialog:0xc0002e7cc0 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0000ac400 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000ac440}"
|
||||
time=2025-06-01T14:37:36.423-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-06-01T14:37:36.427-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-06-01T14:38:19.951-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-06-01T14:38:19.951-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-06-01T14:38:19.951-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-06-01T14:38:19.951-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-06-01T14:38:19.951-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-06-01T14:38:19.951-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc00035b9f0] loadedPages:map[chat:true] status:{app:0xc000226d90 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc000226d90 showPermissions:false permissions:0xc00027f908 showHelp:false help:0xc0005139e0 showQuit:true quit:0xc000510d49 showSessionDialog:false sessionDialog:0xc0001e84c0 showCommandDialog:false commandDialog:0xc00051a160 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a800} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a680}] showModelDialog:false modelDialog:0xc0002675e0 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc00013ac88 showThemeDialog:false themeDialog:0xc0001e8700 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0001e8740}"
|
||||
time=2025-06-01T14:38:50.886-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-06-01T14:38:50.891-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-06-01T14:38:53.495-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-06-01T14:38:53.495-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-06-01T14:38:53.495-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-06-01T14:38:53.495-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-06-01T14:38:53.495-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-06-01T14:38:53.495-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc0005ac8c0] loadedPages:map[chat:true] status:{app:0xc0002796c0 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0002796c0 showPermissions:false permissions:0xc00028b408 showHelp:false help:0xc000490d80 showQuit:true quit:0xc000582589 showSessionDialog:false sessionDialog:0xc0003359c0 showCommandDialog:false commandDialog:0xc00042d480 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a800} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a680}] showModelDialog:false modelDialog:0xc000389360 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc000335c00 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc000335c80}"
|
||||
time=2025-06-01T14:39:49.852-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-06-01T14:39:49.856-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-06-01T14:39:57.071-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-06-01T14:39:57.071-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-06-01T14:39:57.071-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-06-01T14:39:57.071-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-06-01T14:39:57.071-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-06-01T14:39:57.071-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc000616f00] loadedPages:map[chat:true] status:{app:0xc000333490 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc000333490 showPermissions:false permissions:0xc0004faa08 showHelp:false help:0xc000471140 showQuit:true quit:0xc000459299 showSessionDialog:false sessionDialog:0xc000352500 showCommandDialog:false commandDialog:0xc00041ed80 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a800} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a680}] showModelDialog:false modelDialog:0xc000515a40 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc00013ac88 showThemeDialog:false themeDialog:0xc000352740 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc000352780}"
|
||||
time=2025-06-01T14:40:21.954-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-06-01T14:40:21.958-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-06-01T14:41:29.195-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-06-01T14:41:29.195-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-06-01T14:41:29.195-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-06-01T14:41:29.195-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-06-01T14:41:29.195-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-06-01T14:41:29.195-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc0002cc280] loadedPages:map[chat:true] status:{app:0xc0002e64d0 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0002e64d0 showPermissions:false permissions:0xc00026f408 showHelp:false help:0xc00051c1b0 showQuit:true quit:0xc00051a819 showSessionDialog:false sessionDialog:0xc00030fec0 showCommandDialog:false commandDialog:0xc00042d760 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a800} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a680}] showModelDialog:false modelDialog:0xc0002ce1e0 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0000ac480 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000ac4c0}"
|
||||
time=2025-06-01T14:58:27.272-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-06-01T14:58:27.276-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-06-01T14:58:59.711-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-06-01T14:58:59.711-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-06-01T14:58:59.711-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-06-01T14:58:59.711-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-06-01T14:58:59.711-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-06-01T14:58:59.711-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc000316280] loadedPages:map[chat:true] status:{app:0xc0002b5810 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0002b5810 showPermissions:false permissions:0xc000269408 showHelp:false help:0xc000490e10 showQuit:true quit:0xc00047a929 showSessionDialog:false sessionDialog:0xc0000adb40 showCommandDialog:false commandDialog:0xc0003e59c0 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a800} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a680}] showModelDialog:false modelDialog:0xc00024fd60 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0000add80 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000addc0}"
|
||||
time=2025-06-01T15:02:54.453-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-06-01T15:02:54.458-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-06-01T15:02:56.136-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-06-01T15:02:56.136-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-06-01T15:02:56.136-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-06-01T15:02:56.136-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-06-01T15:02:56.136-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-06-01T15:02:56.136-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc000392ff0] loadedPages:map[chat:true] status:{app:0xc0001ecc40 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0001ecc40 showPermissions:false permissions:0xc000205408 showHelp:false help:0xc00051c0c0 showQuit:true quit:0xc0003b3f49 showSessionDialog:false sessionDialog:0xc000319980 showCommandDialog:false commandDialog:0xc00042d220 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc0005c52c0 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc000319bc0 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc000319c00}"
|
||||
time=2025-06-01T15:02:57.053-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-06-01T15:02:57.057-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-06-01T15:02:58.135-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-06-01T15:02:58.135-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-06-01T15:02:58.135-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-06-01T15:02:58.135-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-06-01T15:02:58.135-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-06-01T15:02:58.135-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc0004411d0] loadedPages:map[chat:true] status:{app:0xc00023ee70 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc00023ee70 showPermissions:false permissions:0xc000177408 showHelp:false help:0xc000520030 showQuit:true quit:0xc000314929 showSessionDialog:false sessionDialog:0xc000319d00 showCommandDialog:false commandDialog:0xc0003e5860 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc0002c9a40 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc000319f40 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000ac000}"
|
||||
time=2025-06-01T15:15:13.582-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-06-01T15:15:13.587-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-06-01T15:15:19.009-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-06-01T15:15:19.010-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-06-01T15:15:19.010-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-06-01T15:15:19.010-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-06-01T15:15:19.010-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-06-01T15:15:19.010-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc0001490e0] loadedPages:map[chat:true] status:{app:0xc0001efb90 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0001efb90 showPermissions:false permissions:0xc000167408 showHelp:false help:0xc00052c1b0 showQuit:true quit:0xc000254629 showSessionDialog:false sessionDialog:0xc00030fe80 showCommandDialog:false commandDialog:0xc0003a3420 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc0000c6640 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0000ac440 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000ac480}"
|
||||
time=2025-06-01T15:15:20.678-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-06-01T15:15:20.683-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-06-01T15:15:23.252-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-06-01T15:15:23.253-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-06-01T15:15:23.253-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-06-01T15:15:23.253-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-06-01T15:15:23.253-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-06-01T15:15:23.253-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc0002c47d0] loadedPages:map[chat:true] status:{app:0xc0003363f0 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0003363f0 showPermissions:false permissions:0xc0002f3408 showHelp:false help:0xc0007055f0 showQuit:true quit:0xc00041c9b9 showSessionDialog:false sessionDialog:0xc00033bd00 showCommandDialog:false commandDialog:0xc000437700 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc0003d9c20 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc00013ac88 showThemeDialog:false themeDialog:0xc00033bf40 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc00034a140}"
|
||||
time=2025-06-02T11:40:21.643-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-06-02T11:40:21.648-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-06-02T11:40:24.684-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-06-02T11:40:24.684-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-06-02T11:40:24.684-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-06-02T11:40:24.684-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-06-02T11:40:24.684-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-06-02T11:40:24.684-04:00 level=INFO msg="TUI exited" result="{width:347 height:89 currentPage:chat previousPage: pages:map[chat:0xc00020d180] loadedPages:map[chat:true] status:{app:0xc0002a8230 queue:[] width:347 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0002a8230 showPermissions:false permissions:0xc000239408 showHelp:false help:0xc00051c1e0 showQuit:true quit:0xc000598909 showSessionDialog:false sessionDialog:0xc000309f00 showCommandDialog:false commandDialog:0xc0003a3660 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc0000c4a00 showInitDialog:false initDialog:{width:347 height:89 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0000aa4c0 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000aa500}"
|
||||
time=2025-06-02T11:40:55.224-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-06-02T11:40:55.228-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-06-02T11:41:03.512-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-06-02T11:41:03.512-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-06-02T11:41:03.512-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-06-02T11:41:03.512-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-06-02T11:41:03.512-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-06-02T11:41:03.512-04:00 level=INFO msg="TUI exited" result="{width:347 height:89 currentPage:chat previousPage: pages:map[chat:0xc0001467d0] loadedPages:map[chat:true] status:{app:0xc0004feee0 queue:[] width:347 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0004feee0 showPermissions:false permissions:0xc000167408 showHelp:false help:0xc00059cd50 showQuit:true quit:0xc00038aaa9 showSessionDialog:false sessionDialog:0xc00030ff00 showCommandDialog:false commandDialog:0xc0003e5aa0 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc00029be00 showInitDialog:false initDialog:{width:347 height:89 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0000aa4c0 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000aa500}"
|
||||
time=2025-06-02T11:41:05.131-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-06-02T11:41:05.136-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-06-02T11:41:06.417-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-06-02T11:41:06.417-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-06-02T11:41:06.417-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-06-02T11:41:06.417-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-06-02T11:41:06.417-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-06-02T11:41:06.417-04:00 level=INFO msg="TUI exited" result="{width:347 height:89 currentPage:chat previousPage: pages:map[chat:0xc0002527d0] loadedPages:map[chat:true] status:{app:0xc0002e0d90 queue:[] width:347 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0002e0d90 showPermissions:false permissions:0xc00027b408 showHelp:false help:0xc0004900c0 showQuit:true quit:0xc00047ae69 showSessionDialog:false sessionDialog:0xc000319f40 showCommandDialog:false commandDialog:0xc00042d880 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc000357e00 showInitDialog:false initDialog:{width:347 height:89 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0000aa500 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000aa540}"
|
||||
time=2025-06-02T19:36:04.879-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-06-02T19:36:04.883-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-06-02T19:36:07.068-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-06-02T19:36:07.068-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-06-02T19:36:07.068-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-06-02T19:36:07.068-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-06-02T19:36:07.068-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-06-02T19:36:07.068-04:00 level=INFO msg="TUI exited" result="{width:145 height:36 currentPage:chat previousPage: pages:map[chat:0xc000544b40] loadedPages:map[chat:true] status:{app:0xc000249b90 queue:[] width:145 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc000249b90 showPermissions:false permissions:0xc000207408 showHelp:false help:0xc00011a1e0 showQuit:true quit:0xc0003890b9 showSessionDialog:false sessionDialog:0xc000319f40 showCommandDialog:false commandDialog:0xc0003e5520 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc000547220 showInitDialog:false initDialog:{width:145 height:36 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0000ac500 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000ac540}"
|
||||
time=2025-06-02T19:44:20.524-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-06-02T19:44:20.529-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-06-02T19:45:43.720-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-06-02T19:45:43.720-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-06-02T19:45:43.720-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-06-02T19:45:43.720-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-06-02T19:45:43.720-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-06-02T19:45:43.720-04:00 level=INFO msg="TUI exited" result="{width:145 height:36 currentPage:chat previousPage: pages:map[chat:0xc0001f87d0] loadedPages:map[chat:true] status:{app:0xc000270cb0 queue:[] width:145 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc000270cb0 showPermissions:false permissions:0xc00022f408 showHelp:false help:0xc000490e70 showQuit:true quit:0xc000388ab9 showSessionDialog:false sessionDialog:0xc000319f00 showCommandDialog:false commandDialog:0xc0003e55e0 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc00030bd60 showInitDialog:false initDialog:{width:145 height:36 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0000ac4c0 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000ac500}"
|
||||
time=2025-06-02T19:45:47.456-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-06-02T19:45:47.462-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-06-02T19:46:50.039-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-06-02T19:46:50.039-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-06-02T19:46:50.039-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-06-02T19:46:50.039-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-06-02T19:46:50.039-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-06-02T19:46:50.039-04:00 level=INFO msg="TUI exited" result="{width:145 height:36 currentPage:chat previousPage: pages:map[chat:0xc00035b9f0] loadedPages:map[chat:true] status:{app:0xc0000ec230 queue:[] width:145 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0000ec230 showPermissions:false permissions:0xc0005e5408 showHelp:false help:0xc0005ad950 showQuit:true quit:0xc0005a0c09 showSessionDialog:false sessionDialog:0xc00012e440 showCommandDialog:false commandDialog:0xc0003c2160 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc0002c74a0 showInitDialog:false initDialog:{width:145 height:36 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc00013ac88 showThemeDialog:false themeDialog:0xc00012e680 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc00012e6c0}"
|
||||
time=2025-06-02T19:47:11.433-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-06-02T19:47:11.438-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-06-02T19:48:43.841-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-06-02T19:48:43.841-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-06-02T19:48:43.842-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-06-02T19:48:43.842-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-06-02T19:48:43.842-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-06-02T19:48:43.842-04:00 level=INFO msg="TUI exited" result="{width:145 height:36 currentPage:chat previousPage: pages:map[chat:0xc0001f9040] loadedPages:map[chat:true] status:{app:0xc000270070 queue:[] width:145 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc000270070 showPermissions:false permissions:0xc00022f408 showHelp:false help:0xc000122090 showQuit:true quit:0xc000447c19 showSessionDialog:false sessionDialog:0xc000323b40 showCommandDialog:false commandDialog:0xc0003cb540 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc00044d5e0 showInitDialog:false initDialog:{width:145 height:36 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc000323d80 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc000323dc0}"
|
||||
time=2025-06-02T19:48:57.679-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-06-02T19:48:57.685-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-06-02T19:50:56.190-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-06-02T19:50:56.190-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-06-02T19:50:56.190-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-06-02T19:50:56.190-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-06-02T19:50:56.190-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-06-02T19:50:56.190-04:00 level=INFO msg="TUI exited" result="{width:145 height:36 currentPage:chat previousPage: pages:map[chat:0xc000564be0] loadedPages:map[chat:true] status:{app:0xc000250d20 queue:[] width:145 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc000250d20 showPermissions:false permissions:0xc0004d6a08 showHelp:false help:0xc00061d5c0 showQuit:true quit:0xc0005578a9 showSessionDialog:false sessionDialog:0xc00032a640 showCommandDialog:false commandDialog:0xc0003e51e0 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc000620aa0 showInitDialog:false initDialog:{width:145 height:36 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc00032a880 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc00032a8c0}"
|
||||
@@ -2,17 +2,14 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
zone "github.com/lrstanley/bubblezone"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/pubsub"
|
||||
"github.com/sst/opencode/internal/tui"
|
||||
"github.com/sst/opencode/pkg/client"
|
||||
)
|
||||
@@ -20,54 +17,36 @@ import (
|
||||
var Version = "dev"
|
||||
|
||||
func main() {
|
||||
version := Version
|
||||
if version != "dev" && !strings.HasPrefix(Version, "v") {
|
||||
version = "v" + Version
|
||||
}
|
||||
|
||||
url := os.Getenv("OPENCODE_SERVER")
|
||||
appInfoStr := os.Getenv("OPENCODE_APP_INFO")
|
||||
var appInfo client.AppInfo
|
||||
json.Unmarshal([]byte(appInfoStr), &appInfo)
|
||||
|
||||
httpClient, err := client.NewClientWithResponses(url)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create client", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
paths, err := httpClient.PostPathGetWithResponse(context.Background())
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
logfile := filepath.Join(paths.JSON200.Data, "log", "tui.log")
|
||||
|
||||
if _, err := os.Stat(filepath.Dir(logfile)); os.IsNotExist(err) {
|
||||
err := os.MkdirAll(filepath.Dir(logfile), 0755)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create log directory", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
file, err := os.Create(logfile)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create log file", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer file.Close()
|
||||
logger := slog.New(slog.NewTextHandler(file, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
// Create main context for the application
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
version := Version
|
||||
if version != "dev" && !strings.HasPrefix(Version, "v") {
|
||||
version = "v" + Version
|
||||
}
|
||||
app_, err := app.New(ctx, version, httpClient)
|
||||
app_, err := app.New(ctx, version, appInfo, httpClient)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Set up the TUI
|
||||
zone.NewGlobal()
|
||||
program := tea.NewProgram(
|
||||
tui.NewModel(app_),
|
||||
// tea.WithMouseCellMotion(),
|
||||
tea.WithKeyboardEnhancements(),
|
||||
tea.WithAltScreen(),
|
||||
tea.WithKeyboardEnhancements(),
|
||||
// tea.WithMouseCellMotion(),
|
||||
)
|
||||
|
||||
eventClient, err := client.NewClient(url)
|
||||
@@ -88,57 +67,32 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
// Setup the subscriptions, this will send services events to the TUI
|
||||
ch, cancelSubs := setupSubscriptions(app_, ctx)
|
||||
|
||||
// Create a context for the TUI message handler
|
||||
tuiCtx, tuiCancel := context.WithCancel(ctx)
|
||||
var tuiWg sync.WaitGroup
|
||||
tuiWg.Add(1)
|
||||
|
||||
// Set up message handling for the TUI
|
||||
go func() {
|
||||
defer tuiWg.Done()
|
||||
// defer logging.RecoverPanic("TUI-message-handler", func() {
|
||||
// attemptTUIRecovery(program)
|
||||
// })
|
||||
paths, err := httpClient.PostPathGetWithResponse(context.Background())
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
logfile := filepath.Join(paths.JSON200.Data, "log", "tui.log")
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-tuiCtx.Done():
|
||||
slog.Info("TUI message handler shutting down")
|
||||
return
|
||||
case msg, ok := <-ch:
|
||||
if !ok {
|
||||
slog.Info("TUI message channel closed")
|
||||
return
|
||||
}
|
||||
program.Send(msg)
|
||||
if _, err := os.Stat(filepath.Dir(logfile)); os.IsNotExist(err) {
|
||||
err := os.MkdirAll(filepath.Dir(logfile), 0755)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create log directory", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
file, err := os.Create(logfile)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create log file", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer file.Close()
|
||||
logger := slog.New(slog.NewTextHandler(file, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
slog.SetDefault(logger)
|
||||
}()
|
||||
|
||||
// Cleanup function for when the program exits
|
||||
cleanup := func() {
|
||||
// Cancel subscriptions first
|
||||
cancelSubs()
|
||||
|
||||
// Then shutdown the app
|
||||
app_.Shutdown()
|
||||
|
||||
// Then cancel TUI message handler
|
||||
tuiCancel()
|
||||
|
||||
// Wait for TUI message handler to finish
|
||||
tuiWg.Wait()
|
||||
|
||||
slog.Info("All goroutines cleaned up")
|
||||
}
|
||||
|
||||
// Run the TUI
|
||||
result, err := program.Run()
|
||||
cleanup()
|
||||
|
||||
if err != nil {
|
||||
slog.Error("TUI error", "error", err)
|
||||
// return fmt.Errorf("TUI error: %v", err)
|
||||
@@ -146,78 +100,3 @@ func main() {
|
||||
|
||||
slog.Info("TUI exited", "result", result)
|
||||
}
|
||||
|
||||
func setupSubscriber[T any](
|
||||
ctx context.Context,
|
||||
wg *sync.WaitGroup,
|
||||
name string,
|
||||
subscriber func(context.Context) <-chan pubsub.Event[T],
|
||||
outputCh chan<- tea.Msg,
|
||||
) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
// defer logging.RecoverPanic(fmt.Sprintf("subscription-%s", name), nil)
|
||||
|
||||
subCh := subscriber(ctx)
|
||||
if subCh == nil {
|
||||
slog.Warn("subscription channel is nil", "name", name)
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-subCh:
|
||||
if !ok {
|
||||
slog.Info("subscription channel closed", "name", name)
|
||||
return
|
||||
}
|
||||
|
||||
var msg tea.Msg = event
|
||||
|
||||
select {
|
||||
case outputCh <- msg:
|
||||
case <-time.After(2 * time.Second):
|
||||
slog.Warn("message dropped due to slow consumer", "name", name)
|
||||
case <-ctx.Done():
|
||||
slog.Info("subscription cancelled", "name", name)
|
||||
return
|
||||
}
|
||||
case <-ctx.Done():
|
||||
slog.Info("subscription cancelled", "name", name)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func setupSubscriptions(app *app.App, parentCtx context.Context) (chan tea.Msg, func()) {
|
||||
ch := make(chan tea.Msg, 100)
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
ctx, cancel := context.WithCancel(parentCtx) // Inherit from parent context
|
||||
|
||||
setupSubscriber(ctx, &wg, "status", app.Status.Subscribe, ch)
|
||||
|
||||
cleanupFunc := func() {
|
||||
slog.Info("Cancelling all subscriptions")
|
||||
cancel() // Signal all goroutines to stop
|
||||
|
||||
waitCh := make(chan struct{})
|
||||
go func() {
|
||||
// defer logging.RecoverPanic("subscription-cleanup", nil)
|
||||
wg.Wait()
|
||||
close(waitCh)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-waitCh:
|
||||
slog.Info("All subscription goroutines completed successfully")
|
||||
close(ch) // Only close after all writers are confirmed done
|
||||
case <-time.After(5 * time.Second):
|
||||
slog.Warn("Timed out waiting for some subscription goroutines to complete")
|
||||
close(ch)
|
||||
}
|
||||
}
|
||||
return ch, cleanupFunc
|
||||
}
|
||||
|
||||
@@ -5,14 +5,12 @@ go 1.24.0
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.5.0
|
||||
github.com/alecthomas/chroma/v2 v2.18.0
|
||||
github.com/bmatcuk/doublestar/v4 v4.8.1
|
||||
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1
|
||||
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3
|
||||
github.com/charmbracelet/glamour v0.10.0
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1
|
||||
github.com/charmbracelet/x/ansi v0.8.0
|
||||
github.com/lithammer/fuzzysearch v1.1.8
|
||||
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
|
||||
github.com/muesli/reflow v0.3.0
|
||||
github.com/muesli/termenv v0.16.0
|
||||
@@ -27,7 +25,6 @@ require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||
github.com/atombender/go-jsonschema v0.20.0 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.4 // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
||||
github.com/charmbracelet/x/input v0.3.5-0.20250424101541-abb4d9a9b197 // indirect
|
||||
@@ -66,13 +63,11 @@ require (
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
|
||||
@@ -24,12 +24,8 @@ github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
||||
github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
|
||||
github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 h1:swACzss0FjnyPz1enfX56GKkLiuKg5FlyVmOLIlU2kE=
|
||||
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
|
||||
github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
|
||||
github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
|
||||
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3 h1:5A2e3myxXMpCES+kjEWgGsaf9VgZXjZbLi5iMTH7j40=
|
||||
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3/go.mod h1:ZFDg5oPjyRYrPAa3iFrtP1DO8xy+LUQxd9JFHEcuwJY=
|
||||
github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
|
||||
@@ -38,8 +34,8 @@ github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V
|
||||
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 h1:SOylT6+BQzPHEjn15TIzawBPVD0QmhKXbcb3jY0ZIKU=
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc=
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 h1:D9AJJuYTN5pvz6mpIGO1ijLKpfTYSHOtKGgwoTQ4Gog=
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc=
|
||||
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
|
||||
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250501183327-ad3bc78c6a81 h1:iGrflaL5jQW6crML+pZx/ulWAVZQR3CQoRGvFsr2Tyg=
|
||||
@@ -69,8 +65,6 @@ github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cn
|
||||
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
||||
@@ -126,16 +120,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
|
||||
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
|
||||
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231 h1:9rjt7AfnrXKNSZhp36A3/4QAZAwGGCGD/p8Bse26zms=
|
||||
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231/go.mod h1:S5etECMx+sZnW0Gm100Ma9J1PgVCTgNyFaqGu2b08b4=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
@@ -267,7 +257,6 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
||||
BIN
packages/tui/internal/app/.DS_Store
vendored
@@ -9,118 +9,134 @@ import (
|
||||
"log/slog"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/fileutil"
|
||||
"github.com/sst/opencode/internal/state"
|
||||
"github.com/sst/opencode/internal/status"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
"github.com/sst/opencode/pkg/client"
|
||||
)
|
||||
|
||||
var RootPath string
|
||||
|
||||
type App struct {
|
||||
Info client.AppInfo
|
||||
Version string
|
||||
ConfigPath string
|
||||
Config *config.Config
|
||||
Client *client.ClientWithResponses
|
||||
Provider *client.ProviderInfo
|
||||
Model *client.ProviderModel
|
||||
Model *client.ModelInfo
|
||||
Session *client.SessionInfo
|
||||
Messages []client.MessageInfo
|
||||
Status status.Service
|
||||
|
||||
// UI state
|
||||
filepickerOpen bool
|
||||
completionDialogOpen bool
|
||||
Commands commands.Registry
|
||||
}
|
||||
|
||||
type AppInfo struct {
|
||||
client.AppInfo
|
||||
Version string
|
||||
}
|
||||
func New(
|
||||
ctx context.Context,
|
||||
version string,
|
||||
appInfo client.AppInfo,
|
||||
httpClient *client.ClientWithResponses,
|
||||
) (*App, error) {
|
||||
RootPath = appInfo.Path.Root
|
||||
|
||||
var Info AppInfo
|
||||
|
||||
func New(ctx context.Context, version string, httpClient *client.ClientWithResponses) (*App, error) {
|
||||
err := status.InitService()
|
||||
if err != nil {
|
||||
slog.Error("Failed to initialize status service", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
appInfoResponse, _ := httpClient.PostAppInfoWithResponse(ctx)
|
||||
appInfo := appInfoResponse.JSON200
|
||||
Info = AppInfo{Version: version}
|
||||
Info.Git = appInfo.Git
|
||||
Info.Path = appInfo.Path
|
||||
Info.Time = appInfo.Time
|
||||
Info.User = appInfo.User
|
||||
|
||||
providersResponse, err := httpClient.PostProviderListWithResponse(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
providers := []client.ProviderInfo{}
|
||||
var defaultProvider *client.ProviderInfo
|
||||
var defaultModel *client.ProviderModel
|
||||
|
||||
for i, provider := range providersResponse.JSON200.Providers {
|
||||
if i == 0 || provider.Id == "anthropic" {
|
||||
defaultProvider = &providersResponse.JSON200.Providers[i]
|
||||
if match, ok := providersResponse.JSON200.Default[provider.Id]; ok {
|
||||
model := defaultProvider.Models[match]
|
||||
defaultModel = &model
|
||||
} else {
|
||||
for _, model := range provider.Models {
|
||||
defaultModel = &model
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
providers = append(providers, provider)
|
||||
}
|
||||
if len(providers) == 0 {
|
||||
return nil, fmt.Errorf("no providers found")
|
||||
}
|
||||
|
||||
appConfigPath := filepath.Join(Info.Path.Config, "tui.toml")
|
||||
appConfigPath := filepath.Join(appInfo.Path.Config, "config")
|
||||
appConfig, err := config.LoadConfig(appConfigPath)
|
||||
if err != nil {
|
||||
slog.Info("No TUI config found, using default values", "error", err)
|
||||
appConfig = config.NewConfig("opencode", defaultProvider.Id, defaultModel.Id)
|
||||
appConfig = config.NewConfig()
|
||||
config.SaveConfig(appConfigPath, appConfig)
|
||||
}
|
||||
|
||||
var currentProvider *client.ProviderInfo
|
||||
var currentModel *client.ProviderModel
|
||||
for _, provider := range providers {
|
||||
if provider.Id == appConfig.Provider {
|
||||
currentProvider = &provider
|
||||
|
||||
for _, model := range provider.Models {
|
||||
if model.Id == appConfig.Model {
|
||||
currentModel = &model
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
theme.SetTheme(appConfig.Theme)
|
||||
|
||||
app := &App{
|
||||
Info: appInfo,
|
||||
Version: version,
|
||||
ConfigPath: appConfigPath,
|
||||
Config: appConfig,
|
||||
Client: httpClient,
|
||||
Provider: currentProvider,
|
||||
Model: currentModel,
|
||||
Session: &client.SessionInfo{},
|
||||
Messages: []client.MessageInfo{},
|
||||
Status: status.GetService(),
|
||||
Commands: commands.NewCommandRegistry(),
|
||||
}
|
||||
|
||||
theme.SetTheme(appConfig.Theme)
|
||||
fileutil.Init()
|
||||
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func (a *App) InitializeProvider() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
providersResponse, err := a.Client.PostProviderListWithResponse(context.Background())
|
||||
if err != nil {
|
||||
slog.Error("Failed to list providers", "error", err)
|
||||
// TODO: notify user
|
||||
return nil
|
||||
}
|
||||
providers := []client.ProviderInfo{}
|
||||
var defaultProvider *client.ProviderInfo
|
||||
var defaultModel *client.ModelInfo
|
||||
|
||||
var anthropic *client.ProviderInfo
|
||||
for _, provider := range providersResponse.JSON200.Providers {
|
||||
if provider.Id == "anthropic" {
|
||||
anthropic = &provider
|
||||
}
|
||||
}
|
||||
|
||||
// default to anthropic if available
|
||||
if anthropic != nil {
|
||||
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)
|
||||
}
|
||||
if len(providers) == 0 {
|
||||
slog.Error("No providers configured")
|
||||
return nil
|
||||
}
|
||||
|
||||
var currentProvider *client.ProviderInfo
|
||||
var currentModel *client.ModelInfo
|
||||
for _, provider := range providers {
|
||||
if provider.Id == a.Config.Provider {
|
||||
currentProvider = &provider
|
||||
|
||||
for _, model := range provider.Models {
|
||||
if model.Id == a.Config.Model {
|
||||
currentModel = &model
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if currentProvider == nil || currentModel == nil {
|
||||
currentProvider = defaultProvider
|
||||
currentModel = defaultModel
|
||||
}
|
||||
|
||||
// TODO: handle no provider or model setup, yet
|
||||
return state.ModelSelectedMsg{
|
||||
Provider: *currentProvider,
|
||||
Model: *currentModel,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
FilePath string
|
||||
FileName string
|
||||
@@ -146,7 +162,7 @@ func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
|
||||
|
||||
session, err := a.CreateSession(ctx)
|
||||
if err != nil {
|
||||
status.Error(err.Error())
|
||||
// status.Error(err.Error())
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -154,23 +170,39 @@ func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
|
||||
cmds = append(cmds, util.CmdHandler(state.SessionSelectedMsg(session)))
|
||||
|
||||
go func() {
|
||||
// TODO: Handle no provider or model setup, yet
|
||||
response, err := a.Client.PostSessionInitialize(ctx, client.PostSessionInitializeJSONRequestBody{
|
||||
SessionID: a.Session.Id,
|
||||
ProviderID: a.Provider.Id,
|
||||
ModelID: a.Model.Id,
|
||||
})
|
||||
if err != nil {
|
||||
status.Error(err.Error())
|
||||
slog.Error("Failed to initialize project", "error", err)
|
||||
// status.Error(err.Error())
|
||||
}
|
||||
if response != nil && response.StatusCode != 200 {
|
||||
status.Error(fmt.Sprintf("failed to initialize project: %d", response.StatusCode))
|
||||
slog.Error("Failed to initialize project", "error", response.StatusCode)
|
||||
// status.Error(fmt.Sprintf("failed to initialize project: %d", response.StatusCode))
|
||||
}
|
||||
}()
|
||||
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (a *App) CompactSession(ctx context.Context) tea.Cmd {
|
||||
response, err := a.Client.PostSessionSummarizeWithResponse(ctx, client.PostSessionSummarizeJSONRequestBody{
|
||||
SessionID: a.Session.Id,
|
||||
ProviderID: a.Provider.Id,
|
||||
ModelID: a.Model.Id,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("Failed to compact session", "error", err)
|
||||
}
|
||||
if response != nil && response.StatusCode() != 200 {
|
||||
slog.Error("Failed to compact session", "error", response.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) MarkProjectInitialized(ctx context.Context) error {
|
||||
response, err := a.Client.PostAppInitialize(ctx)
|
||||
if err != nil {
|
||||
@@ -200,7 +232,7 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
|
||||
if a.Session.Id == "" {
|
||||
session, err := a.CreateSession(ctx)
|
||||
if err != nil {
|
||||
status.Error(err.Error())
|
||||
// status.Error(err.Error())
|
||||
return nil
|
||||
}
|
||||
a.Session = session
|
||||
@@ -229,11 +261,11 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("Failed to send message", "error", err)
|
||||
status.Error(err.Error())
|
||||
// status.Error(err.Error())
|
||||
}
|
||||
if response != nil && response.StatusCode != 200 {
|
||||
slog.Error("Failed to send message", "error", fmt.Sprintf("failed to send message: %d", response.StatusCode))
|
||||
status.Error(fmt.Sprintf("failed to send message: %d", response.StatusCode))
|
||||
// status.Error(fmt.Sprintf("failed to send message: %d", response.StatusCode))
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -248,12 +280,12 @@ func (a *App) Cancel(ctx context.Context, sessionID string) error {
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("Failed to cancel session", "error", err)
|
||||
status.Error(err.Error())
|
||||
// status.Error(err.Error())
|
||||
return err
|
||||
}
|
||||
if response != nil && response.StatusCode != 200 {
|
||||
slog.Error("Failed to cancel session", "error", fmt.Sprintf("failed to cancel session: %d", response.StatusCode))
|
||||
status.Error(fmt.Sprintf("failed to cancel session: %d", response.StatusCode))
|
||||
// status.Error(fmt.Sprintf("failed to cancel session: %d", response.StatusCode))
|
||||
return fmt.Errorf("failed to cancel session: %d", response.StatusCode)
|
||||
}
|
||||
return nil
|
||||
@@ -309,28 +341,3 @@ func (a *App) ListProviders(ctx context.Context) ([]client.ProviderInfo, error)
|
||||
providers := *resp.JSON200
|
||||
return providers.Providers, nil
|
||||
}
|
||||
|
||||
// IsFilepickerOpen returns whether the filepicker is currently open
|
||||
func (app *App) IsFilepickerOpen() bool {
|
||||
return app.filepickerOpen
|
||||
}
|
||||
|
||||
// SetFilepickerOpen sets the state of the filepicker
|
||||
func (app *App) SetFilepickerOpen(open bool) {
|
||||
app.filepickerOpen = open
|
||||
}
|
||||
|
||||
// IsCompletionDialogOpen returns whether the completion dialog is currently open
|
||||
func (app *App) IsCompletionDialogOpen() bool {
|
||||
return app.completionDialogOpen
|
||||
}
|
||||
|
||||
// SetCompletionDialogOpen sets the state of the completion dialog
|
||||
func (app *App) SetCompletionDialogOpen(open bool) {
|
||||
app.completionDialogOpen = open
|
||||
}
|
||||
|
||||
// Shutdown performs a clean shutdown of the application
|
||||
func (app *App) Shutdown() {
|
||||
// TODO: cleanup?
|
||||
}
|
||||
|
||||
91
packages/tui/internal/commands/command.go
Normal file
@@ -0,0 +1,91 @@
|
||||
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"),
|
||||
),
|
||||
},
|
||||
"share": {
|
||||
Name: "share",
|
||||
Description: "create shareable link",
|
||||
KeyBinding: key.NewBinding(
|
||||
key.WithKeys("f6"),
|
||||
),
|
||||
},
|
||||
"init": {
|
||||
Name: "init",
|
||||
Description: "create or update AGENTS.md",
|
||||
KeyBinding: key.NewBinding(
|
||||
key.WithKeys("f7"),
|
||||
),
|
||||
},
|
||||
// "compact": {
|
||||
// Name: "compact",
|
||||
// Description: "compact the session",
|
||||
// KeyBinding: key.NewBinding(
|
||||
// key.WithKeys("f8"),
|
||||
// ),
|
||||
// },
|
||||
"quit": {
|
||||
Name: "quit",
|
||||
Description: "quit",
|
||||
KeyBinding: key.NewBinding(
|
||||
key.WithKeys("f10", "ctrl+c", "super+q"),
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
93
packages/tui/internal/completions/commands.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package completions
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/lithammer/fuzzysearch/fuzzy"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/components/dialog"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
|
||||
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) GetEmptyMessage() string {
|
||||
return "no matching commands"
|
||||
}
|
||||
|
||||
func getCommandCompletionItem(cmd commands.Command, space int) dialog.CompletionItemI {
|
||||
t := theme.CurrentTheme()
|
||||
spacer := strings.Repeat(" ", space)
|
||||
title := " /" + cmd.Name + lipgloss.NewStyle().Foreground(t.TextMuted()).Render(spacer+cmd.Description)
|
||||
value := "/" + cmd.Name
|
||||
return dialog.NewCompletionItem(dialog.CompletionItem{
|
||||
Title: title,
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
|
||||
space := 1
|
||||
for _, cmd := range c.app.Commands {
|
||||
if lipgloss.Width(cmd.Name) > space {
|
||||
space = lipgloss.Width(cmd.Name)
|
||||
}
|
||||
}
|
||||
space += 2
|
||||
|
||||
if query == "" {
|
||||
// If no query, return all commands
|
||||
items := []dialog.CompletionItemI{}
|
||||
for _, cmd := range c.app.Commands {
|
||||
space := space - lipgloss.Width(cmd.Name)
|
||||
items = append(items, getCommandCompletionItem(cmd, space))
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// Use fuzzy matching for commands
|
||||
var commandNames []string
|
||||
commandMap := make(map[string]dialog.CompletionItemI)
|
||||
|
||||
for _, cmd := range c.app.Commands {
|
||||
space := space - lipgloss.Width(cmd.Name)
|
||||
commandNames = append(commandNames, cmd.Name)
|
||||
commandMap[cmd.Name] = getCommandCompletionItem(cmd, space)
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"context"
|
||||
|
||||
"github.com/lithammer/fuzzysearch/fuzzy"
|
||||
"github.com/sst/opencode/internal/fileutil"
|
||||
"github.com/sst/opencode/internal/status"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/components/dialog"
|
||||
"github.com/sst/opencode/pkg/client"
|
||||
)
|
||||
|
||||
type filesAndFoldersContextGroup struct {
|
||||
app *app.App
|
||||
prefix string
|
||||
}
|
||||
|
||||
@@ -27,143 +24,22 @@ 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) GetEmptyMessage() string {
|
||||
return "no matching files"
|
||||
}
|
||||
|
||||
func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) {
|
||||
cmdRg := fileutil.GetRgCmd("") // No glob pattern for this use case
|
||||
cmdFzf := fileutil.GetFzfCmd(query)
|
||||
|
||||
var matches []string
|
||||
// Case 1: Both rg and fzf available
|
||||
if cmdRg != nil && cmdFzf != nil {
|
||||
rgPipe, err := cmdRg.StdoutPipe()
|
||||
if err != 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)
|
||||
response, err := cg.app.Client.PostFileSearchWithResponse(context.Background(), client.PostFileSearchJSONRequestBody{
|
||||
Query: query,
|
||||
})
|
||||
if err != nil {
|
||||
return []string{}, err
|
||||
}
|
||||
if response.JSON200 == nil {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
return matches, nil
|
||||
return *response.JSON200, nil
|
||||
}
|
||||
|
||||
func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
|
||||
@@ -184,8 +60,9 @@ func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.C
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func NewFileAndFolderContextGroup() dialog.CompletionProvider {
|
||||
func NewFileAndFolderContextGroup(app *app.App) dialog.CompletionProvider {
|
||||
return &filesAndFoldersContextGroup{
|
||||
app: app,
|
||||
prefix: "file",
|
||||
}
|
||||
}
|
||||
|
||||
29
packages/tui/internal/completions/manager.go
Normal 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,10 +13,10 @@ import (
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/components/dialog"
|
||||
"github.com/sst/opencode/internal/image"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/status"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
@@ -43,11 +43,6 @@ type EditorKeyMaps struct {
|
||||
HistoryDown key.Binding
|
||||
}
|
||||
|
||||
type bluredEditorKeyMaps struct {
|
||||
Send key.Binding
|
||||
Focus key.Binding
|
||||
OpenEditor key.Binding
|
||||
}
|
||||
type DeleteAttachmentKeyMaps struct {
|
||||
AttachmentDeleteMode key.Binding
|
||||
Escape key.Binding
|
||||
@@ -97,7 +92,7 @@ const (
|
||||
)
|
||||
|
||||
func (m *editorComponent) Init() tea.Cmd {
|
||||
return tea.Batch(textarea.Blink, m.spinner.Tick)
|
||||
return tea.Batch(textarea.Blink, m.spinner.Tick, tea.EnableReportFocus)
|
||||
}
|
||||
|
||||
func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
@@ -106,17 +101,21 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case dialog.ThemeChangedMsg:
|
||||
m.textarea = createTextArea(&m.textarea)
|
||||
m.spinner = createSpinner()
|
||||
return m, m.spinner.Tick
|
||||
case dialog.CompletionSelectedMsg:
|
||||
existingValue := m.textarea.Value()
|
||||
modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
|
||||
m.textarea.SetValue(modifiedValue)
|
||||
return m, nil
|
||||
case dialog.AttachmentAddedMsg:
|
||||
if len(m.attachments) >= maxAttachments {
|
||||
status.Error(fmt.Sprintf("cannot add more than %d images", maxAttachments))
|
||||
return m, cmd
|
||||
if msg.IsCommand {
|
||||
// Execute the command directly
|
||||
commandName := strings.TrimPrefix(msg.CompletionValue, "/")
|
||||
m.textarea.Reset()
|
||||
return m, util.CmdHandler(commands.ExecuteCommandMsg{Name: commandName})
|
||||
} else {
|
||||
// For files, replace the text in the editor
|
||||
existingValue := m.textarea.Value()
|
||||
modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
|
||||
m.textarea.SetValue(modifiedValue)
|
||||
return m, nil
|
||||
}
|
||||
m.attachments = append(m.attachments, msg.Attachment)
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
@@ -159,7 +158,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
if key.Matches(msg, editorMaps.OpenEditor) {
|
||||
if m.app.IsBusy() {
|
||||
status.Warn("Agent is working, please wait...")
|
||||
// status.Warn("Agent is working, please wait...")
|
||||
return m, nil
|
||||
}
|
||||
value := m.textarea.Value()
|
||||
@@ -189,7 +188,9 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
// Handle history navigation with up/down arrow keys
|
||||
// Only handle history navigation if the filepicker is not open and completion dialog is not open
|
||||
if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryUp) && !m.app.IsFilepickerOpen() && !m.app.IsCompletionDialogOpen() {
|
||||
if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryUp) {
|
||||
// TODO: fix this
|
||||
// && !m.app.IsFilepickerOpen() && !m.app.IsCompletionDialogOpen() {
|
||||
// Get the current line number
|
||||
currentLine := m.textarea.Line()
|
||||
|
||||
@@ -209,7 +210,9 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
|
||||
if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryDown) && !m.app.IsFilepickerOpen() && !m.app.IsCompletionDialogOpen() {
|
||||
if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryDown) {
|
||||
// TODO: fix this
|
||||
// && !m.app.IsFilepickerOpen() && !m.app.IsCompletionDialogOpen() {
|
||||
// Get the current line number and total lines
|
||||
currentLine := m.textarea.Line()
|
||||
value := m.textarea.Value()
|
||||
@@ -256,8 +259,8 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
func (m *editorComponent) View() string {
|
||||
t := theme.CurrentTheme()
|
||||
base := styles.BaseStyle().Render
|
||||
muted := styles.Muted().Render
|
||||
base := styles.BaseStyle().Background(t.Background()).Render
|
||||
muted := styles.Muted().Background(t.Background()).Render
|
||||
promptStyle := lipgloss.NewStyle().
|
||||
Padding(0, 0, 0, 1).
|
||||
Bold(true).
|
||||
@@ -275,33 +278,27 @@ func (m *editorComponent) View() string {
|
||||
PaddingBottom(1).
|
||||
Background(t.BackgroundElement()).
|
||||
Border(lipgloss.ThickBorder(), false, true).
|
||||
BorderForeground(t.BorderActive()).
|
||||
BorderForeground(t.BackgroundSubtle()).
|
||||
BorderBackground(t.Background()).
|
||||
Render(textarea)
|
||||
|
||||
hint := base("enter") + muted(" send ") + base("shift") + muted("+") + base("enter") + muted(" newline")
|
||||
hint := base("enter") + muted(" send ")
|
||||
if m.app.IsBusy() {
|
||||
hint = muted("working") + m.spinner.View() + muted(" ") + base("esc") + muted(" interrupt")
|
||||
}
|
||||
|
||||
model := ""
|
||||
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)
|
||||
spacer := lipgloss.NewStyle().Width(space).Render("")
|
||||
spacer := lipgloss.NewStyle().Background(t.Background()).Width(space).Render("")
|
||||
|
||||
info := lipgloss.JoinHorizontal(lipgloss.Left, hint, spacer, model)
|
||||
info = styles.Padded().Render(info)
|
||||
info := hint + spacer + model
|
||||
info = styles.Padded().Background(t.Background()).Render(info)
|
||||
|
||||
content := lipgloss.JoinVertical(
|
||||
lipgloss.Top,
|
||||
// m.attachmentsContent(),
|
||||
"",
|
||||
textarea,
|
||||
info,
|
||||
)
|
||||
content := strings.Join([]string{"", textarea, info}, "\n")
|
||||
|
||||
return content
|
||||
}
|
||||
@@ -310,7 +307,7 @@ func (m *editorComponent) SetSize(width, height int) tea.Cmd {
|
||||
m.width = width
|
||||
m.height = height
|
||||
m.textarea.SetWidth(width - 5) // account for the prompt and padding right
|
||||
m.textarea.SetHeight(height - 3) // account for info underneath
|
||||
m.textarea.SetHeight(height - 4) // account for info underneath
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -318,13 +315,6 @@ func (m *editorComponent) GetSize() (int, int) {
|
||||
return m.width, m.height
|
||||
}
|
||||
|
||||
func (m *editorComponent) BindingKeys() []key.Binding {
|
||||
bindings := []key.Binding{}
|
||||
bindings = append(bindings, layout.KeyMapToSlice(editorMaps)...)
|
||||
bindings = append(bindings, layout.KeyMapToSlice(DeleteKeyMaps)...)
|
||||
return bindings
|
||||
}
|
||||
|
||||
func (m *editorComponent) openEditor(value string) tea.Cmd {
|
||||
editor := os.Getenv("EDITOR")
|
||||
if editor == "" {
|
||||
@@ -334,7 +324,7 @@ func (m *editorComponent) openEditor(value string) tea.Cmd {
|
||||
tmpfile, err := os.CreateTemp("", "msg_*.md")
|
||||
tmpfile.WriteString(value)
|
||||
if err != nil {
|
||||
status.Error(err.Error())
|
||||
// status.Error(err.Error())
|
||||
return nil
|
||||
}
|
||||
tmpfile.Close()
|
||||
@@ -344,16 +334,16 @@ func (m *editorComponent) openEditor(value string) tea.Cmd {
|
||||
c.Stderr = os.Stderr
|
||||
return tea.ExecProcess(c, func(err error) tea.Msg {
|
||||
if err != nil {
|
||||
status.Error(err.Error())
|
||||
// status.Error(err.Error())
|
||||
return nil
|
||||
}
|
||||
content, err := os.ReadFile(tmpfile.Name())
|
||||
if err != nil {
|
||||
status.Error(err.Error())
|
||||
// status.Error(err.Error())
|
||||
return nil
|
||||
}
|
||||
if len(content) == 0 {
|
||||
status.Warn("Message is empty")
|
||||
// status.Warn("Message is empty")
|
||||
return nil
|
||||
}
|
||||
os.Remove(tmpfile.Name())
|
||||
@@ -367,7 +357,7 @@ func (m *editorComponent) openEditor(value string) tea.Cmd {
|
||||
}
|
||||
|
||||
func (m *editorComponent) send() tea.Cmd {
|
||||
value := m.textarea.Value()
|
||||
value := strings.TrimSpace(m.textarea.Value())
|
||||
m.textarea.Reset()
|
||||
attachments := m.attachments
|
||||
|
||||
@@ -384,6 +374,15 @@ func (m *editorComponent) send() tea.Cmd {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check for slash command
|
||||
// if strings.HasPrefix(value, "/") {
|
||||
// commandName := strings.TrimPrefix(value, "/")
|
||||
// if _, ok := m.app.Commands[commandName]; ok {
|
||||
// return util.CmdHandler(commands.ExecuteCommandMsg{Name: commandName})
|
||||
// }
|
||||
// }
|
||||
|
||||
return tea.Batch(
|
||||
util.CmdHandler(SendMsg{
|
||||
Text: value,
|
||||
@@ -392,33 +391,6 @@ func (m *editorComponent) send() tea.Cmd {
|
||||
)
|
||||
}
|
||||
|
||||
func (m *editorComponent) attachmentsContent() string {
|
||||
if len(m.attachments) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
t := theme.CurrentTheme()
|
||||
var styledAttachments []string
|
||||
attachmentStyles := styles.BaseStyle().
|
||||
MarginLeft(1).
|
||||
Background(t.TextMuted()).
|
||||
Foreground(t.Text())
|
||||
for i, attachment := range m.attachments {
|
||||
var filename string
|
||||
if len(attachment.FileName) > 10 {
|
||||
filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7])
|
||||
} else {
|
||||
filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName)
|
||||
}
|
||||
if m.deleteMode {
|
||||
filename = fmt.Sprintf("%d%s", i, filename)
|
||||
}
|
||||
styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
|
||||
}
|
||||
content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
|
||||
return content
|
||||
}
|
||||
|
||||
func createTextArea(existing *textarea.Model) textarea.Model {
|
||||
t := theme.CurrentTheme()
|
||||
bgColor := t.BackgroundElement()
|
||||
@@ -451,8 +423,23 @@ func createTextArea(existing *textarea.Model) textarea.Model {
|
||||
return ta
|
||||
}
|
||||
|
||||
func createSpinner() spinner.Model {
|
||||
return spinner.New(
|
||||
spinner.WithSpinner(spinner.Ellipsis),
|
||||
spinner.WithStyle(
|
||||
styles.
|
||||
Muted().
|
||||
Background(theme.CurrentTheme().Background()).
|
||||
Width(3)),
|
||||
)
|
||||
}
|
||||
|
||||
func (m *editorComponent) GetValue() string {
|
||||
return m.textarea.Value()
|
||||
}
|
||||
|
||||
func NewEditorComponent(app *app.App) layout.ModelWithView {
|
||||
s := spinner.New(spinner.WithSpinner(spinner.Ellipsis), spinner.WithStyle(styles.Muted().Width(3)))
|
||||
s := createSpinner()
|
||||
ta := createTextArea(nil)
|
||||
|
||||
return &editorComponent{
|
||||
|
||||
@@ -2,7 +2,6 @@ package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -24,7 +23,7 @@ import (
|
||||
|
||||
func toMarkdown(content string, width int, backgroundColor compat.AdaptiveColor) string {
|
||||
r := styles.GetMarkdownRenderer(width, backgroundColor)
|
||||
content = strings.ReplaceAll(content, app.Info.Path.Root+"/", "")
|
||||
content = strings.ReplaceAll(content, app.RootPath+"/", "")
|
||||
rendered, _ := r.Render(content)
|
||||
lines := strings.Split(rendered, "\n")
|
||||
|
||||
@@ -55,6 +54,10 @@ type blockRenderer struct {
|
||||
fullWidth bool
|
||||
paddingTop int
|
||||
paddingBottom int
|
||||
paddingLeft int
|
||||
paddingRight int
|
||||
marginTop int
|
||||
marginBottom int
|
||||
}
|
||||
|
||||
type renderingOption func(*blockRenderer)
|
||||
@@ -77,6 +80,30 @@ func WithBorderColor(color compat.AdaptiveColor) renderingOption {
|
||||
}
|
||||
}
|
||||
|
||||
func WithMarginTop(padding int) renderingOption {
|
||||
return func(c *blockRenderer) {
|
||||
c.marginTop = padding
|
||||
}
|
||||
}
|
||||
|
||||
func WithMarginBottom(padding int) renderingOption {
|
||||
return func(c *blockRenderer) {
|
||||
c.marginBottom = padding
|
||||
}
|
||||
}
|
||||
|
||||
func WithPaddingLeft(padding int) renderingOption {
|
||||
return func(c *blockRenderer) {
|
||||
c.paddingLeft = padding
|
||||
}
|
||||
}
|
||||
|
||||
func WithPaddingRight(padding int) renderingOption {
|
||||
return func(c *blockRenderer) {
|
||||
c.paddingRight = padding
|
||||
}
|
||||
}
|
||||
|
||||
func WithPaddingTop(padding int) renderingOption {
|
||||
return func(c *blockRenderer) {
|
||||
c.paddingTop = padding
|
||||
@@ -92,17 +119,23 @@ func WithPaddingBottom(padding int) renderingOption {
|
||||
func renderContentBlock(content string, options ...renderingOption) string {
|
||||
t := theme.CurrentTheme()
|
||||
renderer := &blockRenderer{
|
||||
fullWidth: false,
|
||||
fullWidth: false,
|
||||
paddingTop: 1,
|
||||
paddingBottom: 1,
|
||||
paddingLeft: 2,
|
||||
paddingRight: 2,
|
||||
}
|
||||
for _, option := range options {
|
||||
option(renderer)
|
||||
}
|
||||
|
||||
style := styles.BaseStyle().
|
||||
PaddingTop(1).
|
||||
PaddingBottom(1).
|
||||
PaddingLeft(2).
|
||||
PaddingRight(2).
|
||||
// MarginTop(renderer.marginTop).
|
||||
// MarginBottom(renderer.marginBottom).
|
||||
PaddingTop(renderer.paddingTop).
|
||||
PaddingBottom(renderer.paddingBottom).
|
||||
PaddingLeft(renderer.paddingLeft).
|
||||
PaddingRight(renderer.paddingRight).
|
||||
Background(t.BackgroundSubtle()).
|
||||
Foreground(t.TextMuted()).
|
||||
BorderStyle(lipgloss.ThickBorder())
|
||||
@@ -142,22 +175,29 @@ func renderContentBlock(content string, options ...renderingOption) string {
|
||||
style = style.Width(layout.Current.Container.Width)
|
||||
}
|
||||
content = style.Render(content)
|
||||
if renderer.paddingTop > 0 {
|
||||
content = strings.Repeat("\n", renderer.paddingTop) + content
|
||||
}
|
||||
if renderer.paddingBottom > 0 {
|
||||
content = content + strings.Repeat("\n", renderer.paddingBottom)
|
||||
}
|
||||
content = lipgloss.PlaceHorizontal(
|
||||
layout.Current.Container.Width,
|
||||
align,
|
||||
content,
|
||||
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
||||
)
|
||||
content = lipgloss.PlaceHorizontal(
|
||||
layout.Current.Viewport.Width,
|
||||
lipgloss.Center,
|
||||
content,
|
||||
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
||||
)
|
||||
if renderer.marginTop > 0 {
|
||||
for range renderer.marginTop {
|
||||
content = "\n" + content
|
||||
}
|
||||
}
|
||||
if renderer.marginBottom > 0 {
|
||||
for range renderer.marginBottom {
|
||||
content = content + "\n"
|
||||
}
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
@@ -165,13 +205,12 @@ func renderText(message client.MessageInfo, text string, author string) string {
|
||||
t := theme.CurrentTheme()
|
||||
width := layout.Current.Container.Width
|
||||
padding := 0
|
||||
switch layout.Current.Size {
|
||||
case layout.LayoutSizeSmall:
|
||||
if layout.Current.Viewport.Width < 80 {
|
||||
padding = 5
|
||||
case layout.LayoutSizeNormal:
|
||||
padding = 10
|
||||
case layout.LayoutSizeLarge:
|
||||
} else if layout.Current.Viewport.Width < 120 {
|
||||
padding = 15
|
||||
} else {
|
||||
padding = 20
|
||||
}
|
||||
|
||||
timestamp := time.UnixMilli(int64(message.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM")
|
||||
@@ -181,18 +220,11 @@ func renderText(message client.MessageInfo, text string, author string) string {
|
||||
}
|
||||
info := fmt.Sprintf("%s (%s)", author, timestamp)
|
||||
|
||||
align := lipgloss.Left
|
||||
switch message.Role {
|
||||
case client.User:
|
||||
align = lipgloss.Right
|
||||
case client.Assistant:
|
||||
align = lipgloss.Left
|
||||
}
|
||||
|
||||
textWidth := lipgloss.Width(text)
|
||||
textWidth := max(lipgloss.Width(text), lipgloss.Width(info))
|
||||
markdownWidth := min(textWidth, width-padding-4) // -4 for the border and padding
|
||||
content := toMarkdown(text, markdownWidth, t.BackgroundSubtle())
|
||||
content = lipgloss.JoinVertical(align, content, info)
|
||||
content = strings.Join([]string{content, info}, "\n")
|
||||
// content = lipgloss.JoinVertical(align, content, info)
|
||||
|
||||
switch message.Role {
|
||||
case client.User:
|
||||
@@ -203,7 +235,7 @@ func renderText(message client.MessageInfo, text string, author string) string {
|
||||
case client.Assistant:
|
||||
return renderContentBlock(content,
|
||||
WithAlign(lipgloss.Left),
|
||||
WithBorderColor(t.Primary()),
|
||||
WithBorderColor(t.Accent()),
|
||||
)
|
||||
}
|
||||
return ""
|
||||
@@ -212,7 +244,7 @@ func renderText(message client.MessageInfo, text string, author string) string {
|
||||
func renderToolInvocation(
|
||||
toolCall client.MessageToolInvocationToolCall,
|
||||
result *string,
|
||||
metadata map[string]any,
|
||||
metadata client.MessageInfo_Metadata_Tool_AdditionalProperties,
|
||||
showResult bool,
|
||||
) string {
|
||||
ignoredTools := []string{"opencode_todoread"}
|
||||
@@ -220,16 +252,29 @@ func renderToolInvocation(
|
||||
return ""
|
||||
}
|
||||
|
||||
padding := 1
|
||||
outerWidth := layout.Current.Container.Width - 1 // subtract 1 for the border
|
||||
innerWidth := outerWidth - padding - 4 // -4 for the border and padding
|
||||
outerWidth := layout.Current.Container.Width
|
||||
innerWidth := outerWidth - 6
|
||||
paddingTop := 0
|
||||
paddingBottom := 0
|
||||
if showResult {
|
||||
paddingTop = 1
|
||||
if result == nil || *result == "" {
|
||||
paddingBottom = 1
|
||||
}
|
||||
}
|
||||
|
||||
t := theme.CurrentTheme()
|
||||
style := styles.Muted().
|
||||
Width(outerWidth).
|
||||
PaddingLeft(padding).
|
||||
Background(t.BackgroundSubtle()).
|
||||
PaddingTop(paddingTop).
|
||||
PaddingBottom(paddingBottom).
|
||||
PaddingLeft(2).
|
||||
PaddingRight(2).
|
||||
BorderLeft(true).
|
||||
BorderForeground(t.BorderSubtle()).
|
||||
BorderRight(true).
|
||||
BorderBackground(t.Background()).
|
||||
BorderForeground(t.BackgroundSubtle()).
|
||||
BorderStyle(lipgloss.ThickBorder())
|
||||
|
||||
if toolCall.State == "partial-call" {
|
||||
@@ -253,95 +298,122 @@ func renderToolInvocation(
|
||||
}
|
||||
}
|
||||
|
||||
if len(toolArgsMap) == 0 {
|
||||
slog.Debug("no args")
|
||||
}
|
||||
|
||||
body := ""
|
||||
error := ""
|
||||
finished := result != nil && *result != ""
|
||||
if finished {
|
||||
body = *result
|
||||
}
|
||||
|
||||
if metadata["error"] != nil && metadata["message"] != nil {
|
||||
body = ""
|
||||
error = styles.BaseStyle().
|
||||
Foreground(t.Error()).
|
||||
Render(metadata["message"].(string))
|
||||
error = renderContentBlock(error, WithBorderColor(t.Error()), WithFullWidth(), WithPaddingTop(1), WithPaddingBottom(1))
|
||||
if e, ok := metadata.Get("error"); ok && e.(bool) == true {
|
||||
if m, ok := metadata.Get("message"); ok {
|
||||
style = style.BorderLeftForeground(t.Error())
|
||||
error = styles.BaseStyle().
|
||||
Background(t.BackgroundSubtle()).
|
||||
Foreground(t.Error()).
|
||||
Render(m.(string))
|
||||
error = renderContentBlock(
|
||||
error,
|
||||
WithFullWidth(),
|
||||
WithBorderColor(t.Error()),
|
||||
WithMarginBottom(1),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
elapsed := ""
|
||||
if metadata["time"] != nil {
|
||||
timeMap := metadata["time"].(map[string]any)
|
||||
start := timeMap["start"].(float64)
|
||||
end := timeMap["end"].(float64)
|
||||
durationMs := end - start
|
||||
duration := time.Duration(durationMs * float64(time.Millisecond))
|
||||
roundedDuration := time.Duration(duration.Round(time.Millisecond))
|
||||
if durationMs > 1000 {
|
||||
roundedDuration = time.Duration(duration.Round(time.Second))
|
||||
}
|
||||
elapsed = styles.Muted().Render(roundedDuration.String())
|
||||
start := metadata.Time.Start
|
||||
end := metadata.Time.End
|
||||
durationMs := end - start
|
||||
duration := time.Duration(durationMs * float32(time.Millisecond))
|
||||
roundedDuration := time.Duration(duration.Round(time.Millisecond))
|
||||
if durationMs > 1000 {
|
||||
roundedDuration = time.Duration(duration.Round(time.Second))
|
||||
}
|
||||
elapsed = styles.Muted().Render(roundedDuration.String())
|
||||
|
||||
title := ""
|
||||
switch toolCall.ToolName {
|
||||
case "opencode_read":
|
||||
toolArgs = renderArgs(&toolArgsMap, "filePath")
|
||||
title = fmt.Sprintf("Read: %s %s", toolArgs, elapsed)
|
||||
body = ""
|
||||
if metadata["preview"] != nil && toolArgsMap["filePath"] != nil {
|
||||
if preview, ok := metadata.Get("preview"); ok && toolArgsMap["filePath"] != nil {
|
||||
filename := toolArgsMap["filePath"].(string)
|
||||
body = metadata["preview"].(string)
|
||||
body = preview.(string)
|
||||
body = renderFile(filename, body, WithTruncate(6))
|
||||
}
|
||||
case "opencode_edit":
|
||||
filename := toolArgsMap["filePath"].(string)
|
||||
title = fmt.Sprintf("Edit: %s %s", relative(filename), elapsed)
|
||||
if metadata["diff"] != nil {
|
||||
patch := metadata["diff"].(string)
|
||||
diffWidth := min(layout.Current.Viewport.Width, 120)
|
||||
formattedDiff, _ := diff.FormatDiff(filename, patch, diff.WithTotalWidth(diffWidth))
|
||||
body = strings.TrimSpace(formattedDiff)
|
||||
body = lipgloss.Place(
|
||||
layout.Current.Viewport.Width,
|
||||
lipgloss.Height(body)+2,
|
||||
lipgloss.Center,
|
||||
lipgloss.Center,
|
||||
body,
|
||||
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
||||
)
|
||||
if filename, ok := toolArgsMap["filePath"].(string); ok {
|
||||
title = fmt.Sprintf("Edit: %s %s", relative(filename), elapsed)
|
||||
if d, ok := metadata.Get("diff"); ok {
|
||||
patch := d.(string)
|
||||
var formattedDiff string
|
||||
if layout.Current.Viewport.Width < 80 {
|
||||
formattedDiff, _ = diff.FormatUnifiedDiff(
|
||||
filename,
|
||||
patch,
|
||||
diff.WithWidth(layout.Current.Container.Width-2),
|
||||
)
|
||||
} else {
|
||||
diffWidth := min(layout.Current.Viewport.Width-2, 120)
|
||||
formattedDiff, _ = diff.FormatDiff(filename, patch, diff.WithTotalWidth(diffWidth))
|
||||
}
|
||||
formattedDiff = strings.TrimSpace(formattedDiff)
|
||||
formattedDiff = lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
BorderBackground(t.Background()).
|
||||
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,
|
||||
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
||||
)
|
||||
}
|
||||
}
|
||||
case "opencode_write":
|
||||
filename := toolArgsMap["filePath"].(string)
|
||||
title = fmt.Sprintf("Write: %s %s", relative(filename), elapsed)
|
||||
content := toolArgsMap["content"].(string)
|
||||
body = renderFile(filename, content)
|
||||
if filename, ok := toolArgsMap["filePath"].(string); ok {
|
||||
title = fmt.Sprintf("Write: %s %s", relative(filename), elapsed)
|
||||
if content, ok := toolArgsMap["content"].(string); ok {
|
||||
body = renderFile(filename, content)
|
||||
}
|
||||
}
|
||||
case "opencode_bash":
|
||||
description := toolArgsMap["description"].(string)
|
||||
title = fmt.Sprintf("Shell: %s %s", description, elapsed)
|
||||
if metadata["stdout"] != nil {
|
||||
if description, ok := toolArgsMap["description"].(string); ok {
|
||||
title = fmt.Sprintf("Shell: %s %s", description, elapsed)
|
||||
}
|
||||
if stdout, ok := metadata.Get("stdout"); ok {
|
||||
command := toolArgsMap["command"].(string)
|
||||
stdout := metadata["stdout"].(string)
|
||||
stdout := stdout.(string)
|
||||
body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
|
||||
body = toMarkdown(body, innerWidth, t.BackgroundSubtle())
|
||||
body = renderContentBlock(body, WithFullWidth(), WithPaddingTop(1), WithPaddingBottom(1))
|
||||
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
|
||||
}
|
||||
case "opencode_webfetch":
|
||||
toolArgs = renderArgs(&toolArgsMap, "url")
|
||||
title = fmt.Sprintf("Fetching: %s %s", toolArgs, elapsed)
|
||||
format := toolArgsMap["format"].(string)
|
||||
body = truncateHeight(body, 10)
|
||||
if format == "html" || format == "markdown" {
|
||||
body = toMarkdown(body, innerWidth, t.BackgroundSubtle())
|
||||
if format, ok := toolArgsMap["format"].(string); ok {
|
||||
body = *result
|
||||
body = truncateHeight(body, 10)
|
||||
if format == "html" || format == "markdown" {
|
||||
body = toMarkdown(body, innerWidth, t.BackgroundSubtle())
|
||||
}
|
||||
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
|
||||
}
|
||||
body = renderContentBlock(body, WithFullWidth(), WithPaddingTop(1), WithPaddingBottom(1))
|
||||
case "opencode_todowrite":
|
||||
title = fmt.Sprintf("Planning... %s", elapsed)
|
||||
if finished && metadata["todos"] != nil {
|
||||
body = ""
|
||||
todos := metadata["todos"].([]any)
|
||||
title = fmt.Sprintf("Planning %s", elapsed)
|
||||
|
||||
if to, ok := metadata.Get("todos"); ok && finished {
|
||||
todos := to.([]any)
|
||||
for _, todo := range todos {
|
||||
t := todo.(map[string]any)
|
||||
content := t["content"].(string)
|
||||
@@ -349,23 +421,35 @@ func renderToolInvocation(
|
||||
case "completed":
|
||||
body += fmt.Sprintf("- [x] %s\n", content)
|
||||
// case "in-progress":
|
||||
// body += fmt.Sprintf("- [ ] _%s_\n", content)
|
||||
// body += fmt.Sprintf("- [ ] %s\n", content)
|
||||
default:
|
||||
body += fmt.Sprintf("- [ ] %s\n", content)
|
||||
}
|
||||
}
|
||||
body = toMarkdown(body, innerWidth, t.BackgroundSubtle())
|
||||
body = renderContentBlock(body, WithFullWidth(), WithPaddingTop(1), WithPaddingBottom(1))
|
||||
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
|
||||
}
|
||||
default:
|
||||
toolName := renderToolName(toolCall.ToolName)
|
||||
title = fmt.Sprintf("%s: %s %s", toolName, toolArgs, elapsed)
|
||||
body = *result
|
||||
body = truncateHeight(body, 10)
|
||||
body = renderContentBlock(body, WithFullWidth(), WithPaddingTop(1), WithPaddingBottom(1))
|
||||
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
|
||||
}
|
||||
|
||||
if body == "" && error == "" {
|
||||
body = *result
|
||||
body = truncateHeight(body, 10)
|
||||
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
|
||||
}
|
||||
|
||||
content := style.Render(title)
|
||||
content = lipgloss.PlaceHorizontal(layout.Current.Viewport.Width, lipgloss.Center, content)
|
||||
content = lipgloss.PlaceHorizontal(
|
||||
layout.Current.Viewport.Width,
|
||||
lipgloss.Center,
|
||||
content,
|
||||
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
||||
)
|
||||
if showResult && body != "" && error == "" {
|
||||
content += "\n" + body
|
||||
}
|
||||
@@ -435,7 +519,7 @@ func renderFile(filename string, content string, options ...fileRenderingOption)
|
||||
content = fmt.Sprintf("```%s\n%s\n```", extension(renderer.filename), content)
|
||||
content = toMarkdown(content, width, t.BackgroundSubtle())
|
||||
|
||||
return renderContentBlock(content, WithFullWidth(), WithPaddingTop(1), WithPaddingBottom(1))
|
||||
return renderContentBlock(content, WithFullWidth(), WithMarginBottom(1))
|
||||
}
|
||||
|
||||
func renderToolAction(name string) string {
|
||||
@@ -500,7 +584,7 @@ func truncateHeight(content string, height int) string {
|
||||
}
|
||||
|
||||
func relative(path string) string {
|
||||
return strings.TrimPrefix(path, app.Info.Path.Root+"/")
|
||||
return strings.TrimPrefix(path, app.RootPath+"/")
|
||||
}
|
||||
|
||||
func extension(path string) string {
|
||||
|
||||
@@ -118,7 +118,6 @@ type blockType int
|
||||
|
||||
const (
|
||||
none blockType = iota
|
||||
systemTextBlock
|
||||
userTextBlock
|
||||
assistantTextBlock
|
||||
toolInvocationBlock
|
||||
@@ -134,17 +133,13 @@ func (m *messagesComponent) renderView() {
|
||||
blocks := make([]string, 0)
|
||||
previousBlockType := none
|
||||
for _, message := range m.app.Messages {
|
||||
if message.Role == client.System {
|
||||
continue // ignoring system messages for now
|
||||
}
|
||||
|
||||
var content string
|
||||
var cached bool
|
||||
|
||||
author := ""
|
||||
switch message.Role {
|
||||
case client.User:
|
||||
author = app.Info.User
|
||||
author = m.app.Info.User
|
||||
case client.Assistant:
|
||||
author = message.Metadata.Assistant.ModelID
|
||||
}
|
||||
@@ -174,15 +169,13 @@ func (m *messagesComponent) renderView() {
|
||||
previousBlockType = userTextBlock
|
||||
} else if message.Role == client.Assistant {
|
||||
previousBlockType = assistantTextBlock
|
||||
} else if message.Role == client.System {
|
||||
previousBlockType = systemTextBlock
|
||||
}
|
||||
case client.MessagePartToolInvocation:
|
||||
toolInvocationPart := part.(client.MessagePartToolInvocation)
|
||||
toolCall, _ := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolCall()
|
||||
metadata := map[string]any{}
|
||||
metadata := client.MessageInfo_Metadata_Tool_AdditionalProperties{}
|
||||
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
|
||||
resultPart, resultError := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolResult()
|
||||
@@ -215,14 +208,16 @@ func (m *messagesComponent) renderView() {
|
||||
}
|
||||
|
||||
error := ""
|
||||
errorValue, _ := message.Metadata.Error.ValueByDiscriminator()
|
||||
switch errorValue.(type) {
|
||||
case client.UnknownError:
|
||||
clientError := errorValue.(client.UnknownError)
|
||||
error = clientError.Data.Message
|
||||
error = renderContentBlock(error, WithBorderColor(t.Error()), WithFullWidth(), WithPaddingTop(1), WithPaddingBottom(1))
|
||||
blocks = append(blocks, error)
|
||||
previousBlockType = errorBlock
|
||||
if message.Metadata.Error != nil {
|
||||
errorValue, _ := message.Metadata.Error.ValueByDiscriminator()
|
||||
switch errorValue.(type) {
|
||||
case client.UnknownError:
|
||||
clientError := errorValue.(client.UnknownError)
|
||||
error = clientError.Data.Message
|
||||
error = renderContentBlock(error, WithBorderColor(t.Error()), WithFullWidth(), WithMarginTop(1), WithMarginBottom(1))
|
||||
blocks = append(blocks, error)
|
||||
previousBlockType = errorBlock
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,10 +242,10 @@ func (m *messagesComponent) header() string {
|
||||
|
||||
t := theme.CurrentTheme()
|
||||
width := layout.Current.Container.Width
|
||||
base := styles.BaseStyle().Render
|
||||
muted := styles.Muted().Render
|
||||
base := styles.BaseStyle().Background(t.Background()).Render
|
||||
muted := styles.Muted().Background(t.Background()).Render
|
||||
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 != "" {
|
||||
headerLines = append(headerLines, muted(m.app.Session.Share.Url))
|
||||
} else {
|
||||
@@ -260,37 +255,45 @@ func (m *messagesComponent) header() string {
|
||||
|
||||
header = styles.BaseStyle().
|
||||
Width(width).
|
||||
PaddingTop(1).
|
||||
BorderBottom(true).
|
||||
BorderForeground(t.BorderSubtle()).
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
PaddingLeft(2).
|
||||
PaddingRight(2).
|
||||
Background(t.Background()).
|
||||
BorderLeft(true).
|
||||
BorderRight(true).
|
||||
BorderBackground(t.Background()).
|
||||
BorderForeground(t.BackgroundSubtle()).
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
Render(header)
|
||||
|
||||
return header
|
||||
return "\n" + header + "\n"
|
||||
}
|
||||
|
||||
func (m *messagesComponent) View() string {
|
||||
if len(m.app.Messages) == 0 || m.rendering {
|
||||
if len(m.app.Messages) == 0 {
|
||||
return m.home()
|
||||
}
|
||||
if m.rendering {
|
||||
return m.viewport.View()
|
||||
}
|
||||
t := theme.CurrentTheme()
|
||||
return lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
lipgloss.PlaceHorizontal(m.width, lipgloss.Center, m.header()),
|
||||
lipgloss.PlaceHorizontal(
|
||||
m.width,
|
||||
lipgloss.Center,
|
||||
m.header(),
|
||||
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
||||
),
|
||||
m.viewport.View(),
|
||||
)
|
||||
}
|
||||
|
||||
func (m *messagesComponent) home() string {
|
||||
// t := theme.CurrentTheme()
|
||||
baseStyle := styles.BaseStyle()
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.BaseStyle().Background(t.Background())
|
||||
base := baseStyle.Render
|
||||
muted := styles.Muted().Render
|
||||
muted := styles.Muted().Background(t.Background()).Render
|
||||
|
||||
// mark := `
|
||||
// ███▀▀█
|
||||
// ███ █
|
||||
// ▀▀▀▀▀▀ `
|
||||
open := `
|
||||
█▀▀█ █▀▀█ █▀▀ █▀▀▄
|
||||
█░░█ █░░█ █▀▀ █░░█
|
||||
@@ -302,31 +305,30 @@ func (m *messagesComponent) home() string {
|
||||
|
||||
logo := lipgloss.JoinHorizontal(
|
||||
lipgloss.Top,
|
||||
// styles.BaseStyle().Foreground(t.Primary()).Render(mark),
|
||||
styles.Muted().Render(open),
|
||||
styles.BaseStyle().Render(code),
|
||||
muted(open),
|
||||
base(code),
|
||||
)
|
||||
cwd := app.Info.Path.Cwd
|
||||
config := app.Info.Path.Config
|
||||
// cwd := app.Info.Path.Cwd
|
||||
// config := app.Info.Path.Config
|
||||
|
||||
commands := [][]string{
|
||||
{"/help", "show help"},
|
||||
{"/sessions", "list sessions"},
|
||||
{"/new", "start a new session"},
|
||||
{"/model", "switch model"},
|
||||
{"/share", "share the current session"},
|
||||
{"/exit", "exit the app"},
|
||||
{"/theme", "switch theme"},
|
||||
{"/quit", "exit the app"},
|
||||
}
|
||||
|
||||
commandLines := []string{}
|
||||
for _, command := range commands {
|
||||
commandLines = append(commandLines, (base(command[0]) + " " + muted(command[1])))
|
||||
commandLines = append(commandLines, (base(command[0]+" ") + muted(command[1])))
|
||||
}
|
||||
|
||||
logoAndVersion := lipgloss.JoinVertical(
|
||||
lipgloss.Right,
|
||||
logo,
|
||||
muted(app.Info.Version),
|
||||
muted(m.app.Version),
|
||||
)
|
||||
|
||||
lines := []string{}
|
||||
@@ -334,24 +336,27 @@ func (m *messagesComponent) home() string {
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, logoAndVersion)
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, base("cwd ")+muted(cwd))
|
||||
lines = append(lines, base("config ")+muted(config))
|
||||
lines = append(lines, "")
|
||||
// lines = append(lines, base("cwd ")+muted(cwd))
|
||||
// lines = append(lines, base("config ")+muted(config))
|
||||
// lines = append(lines, "")
|
||||
lines = append(lines, commandLines...)
|
||||
lines = append(lines, "")
|
||||
if m.rendering {
|
||||
lines = append(lines, styles.Muted().Render("Loading session..."))
|
||||
lines = append(lines, base("Loading session..."))
|
||||
} else {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
|
||||
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
|
||||
return lipgloss.Place(
|
||||
m.width,
|
||||
m.height,
|
||||
lipgloss.Center,
|
||||
lipgloss.Center,
|
||||
baseStyle.Width(lipgloss.Width(logoAndVersion)).Render(
|
||||
lipgloss.JoinVertical(
|
||||
lipgloss.Top,
|
||||
lines...,
|
||||
),
|
||||
))
|
||||
strings.Join(lines, "\n"),
|
||||
),
|
||||
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
||||
)
|
||||
}
|
||||
|
||||
func (m *messagesComponent) SetSize(width, height int) tea.Cmd {
|
||||
@@ -384,15 +389,6 @@ func (m *messagesComponent) Reload() tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *messagesComponent) BindingKeys() []key.Binding {
|
||||
return []key.Binding{
|
||||
m.viewport.KeyMap.PageDown,
|
||||
m.viewport.KeyMap.PageUp,
|
||||
m.viewport.KeyMap.HalfPageUp,
|
||||
m.viewport.KeyMap.HalfPageDown,
|
||||
}
|
||||
}
|
||||
|
||||
func NewMessagesComponent(app *app.App) layout.ModelWithView {
|
||||
customSpinner := spinner.Spinner{
|
||||
Frames: []string{" ", "┃", "┃"},
|
||||
@@ -400,8 +396,8 @@ func NewMessagesComponent(app *app.App) layout.ModelWithView {
|
||||
}
|
||||
s := spinner.New(spinner.WithSpinner(customSpinner))
|
||||
|
||||
vp := viewport.New() //(0, 0)
|
||||
attachments := viewport.New() //(0, 0)
|
||||
vp := viewport.New()
|
||||
attachments := viewport.New()
|
||||
vp.KeyMap.PageUp = messageKeys.PageUp
|
||||
vp.KeyMap.PageDown = messageKeys.PageDown
|
||||
vp.KeyMap.HalfPageUp = messageKeys.HalfPageUp
|
||||
|
||||
@@ -3,14 +3,11 @@ package core
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/pubsub"
|
||||
"github.com/sst/opencode/internal/status"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
@@ -20,27 +17,12 @@ type StatusComponent interface {
|
||||
}
|
||||
|
||||
type statusComponent struct {
|
||||
app *app.App
|
||||
queue []status.StatusMessage
|
||||
width int
|
||||
messageTTL time.Duration
|
||||
activeUntil time.Time
|
||||
}
|
||||
|
||||
// clearMessageCmd is a command that clears status messages after a timeout
|
||||
func (m statusComponent) clearMessageCmd() tea.Cmd {
|
||||
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
|
||||
return statusCleanupMsg{time: t}
|
||||
})
|
||||
}
|
||||
|
||||
// statusCleanupMsg is a message that triggers cleanup of expired status messages
|
||||
type statusCleanupMsg struct {
|
||||
time time.Time
|
||||
app *app.App
|
||||
width int
|
||||
}
|
||||
|
||||
func (m statusComponent) Init() tea.Cmd {
|
||||
return m.clearMessageCmd()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m statusComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
@@ -48,65 +30,18 @@ func (m statusComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
return m, nil
|
||||
case pubsub.Event[status.StatusMessage]:
|
||||
if msg.Type == status.EventStatusPublished {
|
||||
// If this is a critical message, move it to the front of the queue
|
||||
if msg.Payload.Critical {
|
||||
// Insert at the front of the queue
|
||||
m.queue = append([]status.StatusMessage{msg.Payload}, m.queue...)
|
||||
|
||||
// Reset active time to show critical message immediately
|
||||
m.activeUntil = time.Time{}
|
||||
} else {
|
||||
// Otherwise, just add it to the queue
|
||||
m.queue = append(m.queue, msg.Payload)
|
||||
|
||||
// If this is the first message and nothing is active, activate it immediately
|
||||
if len(m.queue) == 1 && m.activeUntil.IsZero() {
|
||||
now := time.Now()
|
||||
duration := m.messageTTL
|
||||
if msg.Payload.Duration > 0 {
|
||||
duration = msg.Payload.Duration
|
||||
}
|
||||
m.activeUntil = now.Add(duration)
|
||||
}
|
||||
}
|
||||
}
|
||||
case statusCleanupMsg:
|
||||
now := msg.time
|
||||
|
||||
// If the active message has expired, remove it and activate the next one
|
||||
if !m.activeUntil.IsZero() && m.activeUntil.Before(now) {
|
||||
// Current message expired, remove it if we have one
|
||||
if len(m.queue) > 0 {
|
||||
m.queue = m.queue[1:]
|
||||
}
|
||||
m.activeUntil = time.Time{}
|
||||
}
|
||||
|
||||
// If we have messages in queue but none are active, activate the first one
|
||||
if len(m.queue) > 0 && m.activeUntil.IsZero() {
|
||||
// Use custom duration if specified, otherwise use default
|
||||
duration := m.messageTTL
|
||||
if m.queue[0].Duration > 0 {
|
||||
duration = m.queue[0].Duration
|
||||
}
|
||||
m.activeUntil = now.Add(duration)
|
||||
}
|
||||
|
||||
return m, m.clearMessageCmd()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func logo() string {
|
||||
func (m statusComponent) logo() string {
|
||||
t := theme.CurrentTheme()
|
||||
base := lipgloss.NewStyle().Background(t.BackgroundElement()).Foreground(t.TextMuted()).Render
|
||||
emphasis := lipgloss.NewStyle().Bold(true).Background(t.BackgroundElement()).Foreground(t.Text()).Render
|
||||
|
||||
open := base("open")
|
||||
code := emphasis("code ")
|
||||
version := base(app.Info.Version)
|
||||
version := base(m.app.Version)
|
||||
return styles.Padded().
|
||||
Background(t.BackgroundElement()).
|
||||
Render(open + code + version)
|
||||
@@ -140,20 +75,21 @@ func formatTokensAndCost(tokens float32, contextWindow float32, cost float32) st
|
||||
}
|
||||
|
||||
func (m statusComponent) View() string {
|
||||
t := theme.CurrentTheme()
|
||||
if m.app.Session.Id == "" {
|
||||
return styles.BaseStyle().
|
||||
Background(t.Background()).
|
||||
Width(m.width).
|
||||
Height(2).
|
||||
Render("")
|
||||
}
|
||||
|
||||
t := theme.CurrentTheme()
|
||||
logo := logo()
|
||||
logo := m.logo()
|
||||
|
||||
cwd := styles.Padded().
|
||||
Foreground(t.TextMuted()).
|
||||
Background(t.BackgroundSubtle()).
|
||||
Render(app.Info.Path.Cwd)
|
||||
Render(m.app.Info.Path.Cwd)
|
||||
|
||||
sessionInfo := ""
|
||||
if m.app.Session.Id != "" {
|
||||
@@ -166,7 +102,11 @@ func (m statusComponent) View() string {
|
||||
cost += message.Metadata.Assistant.Cost
|
||||
usage := message.Metadata.Assistant.Tokens
|
||||
if usage.Output > 0 {
|
||||
tokens = (usage.Input + usage.Output + usage.Reasoning)
|
||||
tokens = (usage.Input +
|
||||
usage.Cache.Write +
|
||||
usage.Cache.Read +
|
||||
usage.Output +
|
||||
usage.Reasoning)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,75 +129,11 @@ func (m statusComponent) View() string {
|
||||
|
||||
blank := styles.BaseStyle().Background(t.Background()).Width(m.width).Render("")
|
||||
return blank + "\n" + status
|
||||
|
||||
// Display the first status message if available
|
||||
// var statusMessage string
|
||||
// if len(m.queue) > 0 {
|
||||
// sm := m.queue[0]
|
||||
// infoStyle := styles.Padded().
|
||||
// Foreground(t.Background())
|
||||
//
|
||||
// switch sm.Level {
|
||||
// case "info":
|
||||
// infoStyle = infoStyle.Background(t.Info())
|
||||
// case "warn":
|
||||
// infoStyle = infoStyle.Background(t.Warning())
|
||||
// case "error":
|
||||
// infoStyle = infoStyle.Background(t.Error())
|
||||
// case "debug":
|
||||
// infoStyle = infoStyle.Background(t.TextMuted())
|
||||
// }
|
||||
//
|
||||
// // Truncate message if it's longer than available width
|
||||
// msg := sm.Message
|
||||
// availWidth := statusWidth - 10
|
||||
//
|
||||
// // If we have enough space, show inline
|
||||
// if availWidth >= minInlineWidth {
|
||||
// if len(msg) > availWidth && availWidth > 0 {
|
||||
// msg = msg[:availWidth] + "..."
|
||||
// }
|
||||
// status += infoStyle.Width(statusWidth).Render(msg)
|
||||
// } else {
|
||||
// // Otherwise, prepare a full-width message to show above
|
||||
// if len(msg) > m.width-10 && m.width > 10 {
|
||||
// msg = msg[:m.width-10] + "..."
|
||||
// }
|
||||
// statusMessage = infoStyle.Width(m.width).Render(msg)
|
||||
//
|
||||
// // Add empty space in the status bar
|
||||
// status += styles.Padded().
|
||||
// Foreground(t.Text()).
|
||||
// Background(t.BackgroundSubtle()).
|
||||
// Width(statusWidth).
|
||||
// Render("")
|
||||
// }
|
||||
// } else {
|
||||
// status += styles.Padded().
|
||||
// Foreground(t.Text()).
|
||||
// Background(t.BackgroundSubtle()).
|
||||
// Width(statusWidth).
|
||||
// Render("")
|
||||
// }
|
||||
|
||||
// status += diagnostics
|
||||
// status += modelName
|
||||
|
||||
// If we have a separate status message, prepend it
|
||||
// if statusMessage != "" {
|
||||
// return statusMessage + "\n" + status
|
||||
// } else {
|
||||
// blank := styles.BaseStyle().Background(t.Background()).Width(m.width).Render("")
|
||||
// return blank + "\n" + status
|
||||
// }
|
||||
}
|
||||
|
||||
func NewStatusCmp(app *app.App) StatusComponent {
|
||||
statusComponent := &statusComponent{
|
||||
app: app,
|
||||
queue: []status.StatusMessage{},
|
||||
messageTTL: 4 * time.Second,
|
||||
activeUntil: time.Time{},
|
||||
app: app,
|
||||
}
|
||||
|
||||
return statusComponent
|
||||
|
||||
@@ -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,22 +5,20 @@ import (
|
||||
"github.com/charmbracelet/bubbles/v2/textarea"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
utilComponents "github.com/sst/opencode/internal/components/util"
|
||||
"github.com/sst/opencode/internal/components/list"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/status"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
type CompletionItem struct {
|
||||
title string
|
||||
Title string
|
||||
Value string
|
||||
}
|
||||
|
||||
type CompletionItemI interface {
|
||||
utilComponents.SimpleListItem
|
||||
list.ListItem
|
||||
GetValue() string
|
||||
DisplayValue() string
|
||||
}
|
||||
@@ -30,18 +28,17 @@ func (ci *CompletionItem) Render(selected bool, width int) string {
|
||||
baseStyle := styles.BaseStyle()
|
||||
|
||||
itemStyle := baseStyle.
|
||||
Background(t.BackgroundElement()).
|
||||
Width(width).
|
||||
Padding(0, 1)
|
||||
|
||||
if selected {
|
||||
itemStyle = itemStyle.
|
||||
Background(t.Background()).
|
||||
Foreground(t.Primary()).
|
||||
Bold(true)
|
||||
Foreground(t.Primary())
|
||||
}
|
||||
|
||||
title := itemStyle.Render(
|
||||
ci.GetValue(),
|
||||
ci.DisplayValue(),
|
||||
)
|
||||
|
||||
return title
|
||||
@@ -63,11 +60,13 @@ type CompletionProvider interface {
|
||||
GetId() string
|
||||
GetEntry() CompletionItemI
|
||||
GetChildEntries(query string) ([]CompletionItemI, error)
|
||||
GetEmptyMessage() string
|
||||
}
|
||||
|
||||
type CompletionSelectedMsg struct {
|
||||
SearchString string
|
||||
CompletionValue string
|
||||
IsCommand bool
|
||||
}
|
||||
|
||||
type CompletionDialogCompleteItemMsg struct {
|
||||
@@ -78,8 +77,9 @@ type CompletionDialogCloseMsg struct{}
|
||||
|
||||
type CompletionDialog interface {
|
||||
layout.ModelWithView
|
||||
layout.Bindings
|
||||
SetWidth(width int)
|
||||
IsEmpty() bool
|
||||
SetProvider(provider CompletionProvider)
|
||||
}
|
||||
|
||||
type completionDialogComponent struct {
|
||||
@@ -88,7 +88,7 @@ type completionDialogComponent struct {
|
||||
width int
|
||||
height int
|
||||
pseudoSearchTextArea textarea.Model
|
||||
listView utilComponents.SimpleList[CompletionItemI]
|
||||
list list.List[CompletionItemI]
|
||||
}
|
||||
|
||||
type completionDialogKeyMap struct {
|
||||
@@ -116,17 +116,21 @@ func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if this is a command completion
|
||||
isCommand := c.completionProvider.GetId() == "commands"
|
||||
|
||||
return tea.Batch(
|
||||
util.CmdHandler(CompletionSelectedMsg{
|
||||
SearchString: value,
|
||||
CompletionValue: item.GetValue(),
|
||||
IsCommand: isCommand,
|
||||
}),
|
||||
c.close(),
|
||||
)
|
||||
}
|
||||
|
||||
func (c *completionDialogComponent) close() tea.Cmd {
|
||||
c.listView.SetItems([]CompletionItemI{})
|
||||
c.list.SetItems([]CompletionItemI{})
|
||||
c.pseudoSearchTextArea.Reset()
|
||||
c.pseudoSearchTextArea.Blur()
|
||||
|
||||
@@ -136,11 +140,11 @@ func (c *completionDialogComponent) close() tea.Cmd {
|
||||
func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
switch msg := msg.(type) {
|
||||
case []CompletionItemI:
|
||||
c.list.SetItems(msg)
|
||||
case tea.KeyMsg:
|
||||
if c.pseudoSearchTextArea.Focused() {
|
||||
|
||||
if !key.Matches(msg, completionDialogKeys.Complete) {
|
||||
|
||||
var cmd tea.Cmd
|
||||
c.pseudoSearchTextArea, cmd = c.pseudoSearchTextArea.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
@@ -152,31 +156,30 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
if query != c.query {
|
||||
items, err := c.completionProvider.GetChildEntries(query)
|
||||
if err != nil {
|
||||
status.Error(err.Error())
|
||||
}
|
||||
|
||||
c.listView.SetItems(items)
|
||||
c.query = query
|
||||
cmd = func() tea.Msg {
|
||||
items, err := c.completionProvider.GetChildEntries(query)
|
||||
if err != nil {
|
||||
// status.Error(err.Error())
|
||||
}
|
||||
// c.list.SetItems(items)
|
||||
return items
|
||||
}
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
u, cmd := c.listView.Update(msg)
|
||||
c.listView = u.(utilComponents.SimpleList[CompletionItemI])
|
||||
|
||||
u, cmd := c.list.Update(msg)
|
||||
c.list = u.(list.List[CompletionItemI])
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
switch {
|
||||
case key.Matches(msg, completionDialogKeys.Complete):
|
||||
item, i := c.listView.GetSelectedItem()
|
||||
item, i := c.list.GetSelectedItem()
|
||||
if i == -1 {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
cmd := c.complete(item)
|
||||
|
||||
return c, cmd
|
||||
return c, c.complete(item)
|
||||
case key.Matches(msg, completionDialogKeys.Cancel):
|
||||
// Only close on backspace when there are no characters left
|
||||
if msg.String() != "backspace" || len(c.pseudoSearchTextArea.Value()) <= 0 {
|
||||
@@ -186,14 +189,17 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
return c, tea.Batch(cmds...)
|
||||
} else {
|
||||
items, err := c.completionProvider.GetChildEntries("")
|
||||
if err != nil {
|
||||
status.Error(err.Error())
|
||||
cmd := func() tea.Msg {
|
||||
items, err := c.completionProvider.GetChildEntries("")
|
||||
if err != nil {
|
||||
// status.Error(err.Error())
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
c.listView.SetItems(items)
|
||||
cmds = append(cmds, cmd)
|
||||
cmds = append(cmds, c.pseudoSearchTextArea.Focus())
|
||||
c.pseudoSearchTextArea.SetValue(msg.String())
|
||||
return c, c.pseudoSearchTextArea.Focus()
|
||||
return c, tea.Batch(cmds...)
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
c.width = msg.Width
|
||||
@@ -208,8 +214,7 @@ func (c *completionDialogComponent) View() string {
|
||||
baseStyle := styles.BaseStyle()
|
||||
|
||||
maxWidth := 40
|
||||
|
||||
completions := c.listView.GetItems()
|
||||
completions := c.list.GetItems()
|
||||
|
||||
for _, cmd := range completions {
|
||||
title := cmd.DisplayValue()
|
||||
@@ -218,46 +223,58 @@ func (c *completionDialogComponent) View() string {
|
||||
}
|
||||
}
|
||||
|
||||
c.listView.SetMaxWidth(maxWidth)
|
||||
c.list.SetMaxWidth(maxWidth)
|
||||
|
||||
return baseStyle.Padding(0, 0).
|
||||
Border(lipgloss.NormalBorder()).
|
||||
Background(t.BackgroundElement()).
|
||||
Border(lipgloss.ThickBorder()).
|
||||
BorderTop(false).
|
||||
BorderBottom(false).
|
||||
BorderRight(false).
|
||||
BorderLeft(false).
|
||||
BorderRight(true).
|
||||
BorderLeft(true).
|
||||
BorderBackground(t.Background()).
|
||||
BorderForeground(t.TextMuted()).
|
||||
BorderForeground(t.BackgroundSubtle()).
|
||||
Width(c.width).
|
||||
Render(c.listView.View())
|
||||
Render(c.list.View())
|
||||
}
|
||||
|
||||
func (c *completionDialogComponent) SetWidth(width int) {
|
||||
c.width = width
|
||||
}
|
||||
|
||||
func (c *completionDialogComponent) BindingKeys() []key.Binding {
|
||||
return layout.KeyMapToSlice(completionDialogKeys)
|
||||
func (c *completionDialogComponent) IsEmpty() bool {
|
||||
return c.list.IsEmpty()
|
||||
}
|
||||
|
||||
func (c *completionDialogComponent) SetProvider(provider CompletionProvider) {
|
||||
if c.completionProvider.GetId() != provider.GetId() {
|
||||
c.completionProvider = provider
|
||||
c.list.SetEmptyMessage(" " + provider.GetEmptyMessage())
|
||||
}
|
||||
}
|
||||
|
||||
func NewCompletionDialogComponent(completionProvider CompletionProvider) CompletionDialog {
|
||||
ti := textarea.New()
|
||||
|
||||
items, err := completionProvider.GetChildEntries("")
|
||||
if err != nil {
|
||||
status.Error(err.Error())
|
||||
}
|
||||
|
||||
li := utilComponents.NewSimpleList(
|
||||
items,
|
||||
li := list.NewListComponent(
|
||||
[]CompletionItemI{},
|
||||
7,
|
||||
"No file matches found",
|
||||
completionProvider.GetEmptyMessage(),
|
||||
false,
|
||||
)
|
||||
|
||||
go func() {
|
||||
items, err := completionProvider.GetChildEntries("")
|
||||
if err != nil {
|
||||
// status.Error(err.Error())
|
||||
}
|
||||
li.SetItems(items)
|
||||
}()
|
||||
|
||||
return &completionDialogComponent{
|
||||
query: "",
|
||||
completionProvider: completionProvider,
|
||||
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
|
||||
}
|
||||
|
||||
// func removeDuplicateBindings(bindings []key.Binding) []key.Binding {
|
||||
// seen := make(map[string]struct{})
|
||||
// result := make([]key.Binding, 0, len(bindings))
|
||||
//
|
||||
// // Process bindings in reverse order
|
||||
// for i := len(bindings) - 1; i >= 0; i-- {
|
||||
// b := bindings[i]
|
||||
// k := strings.Join(b.Keys(), " ")
|
||||
// if _, ok := seen[k]; ok {
|
||||
// // duplicate, skip
|
||||
// continue
|
||||
// }
|
||||
// seen[k] = struct{}{}
|
||||
// // Add to the beginning of result to maintain original order
|
||||
// result = append([]key.Binding{b}, result...)
|
||||
// }
|
||||
//
|
||||
// return result
|
||||
// }
|
||||
|
||||
func (h *helpDialog) View() string {
|
||||
t := theme.CurrentTheme()
|
||||
keyStyle := lipgloss.NewStyle().
|
||||
@@ -79,6 +59,8 @@ func (h *helpDialog) View() string {
|
||||
descStyle := lipgloss.NewStyle().
|
||||
Background(t.BackgroundElement()).
|
||||
Foreground(t.TextMuted())
|
||||
contentStyle := lipgloss.NewStyle().
|
||||
PaddingLeft(1).Background(t.BackgroundElement())
|
||||
|
||||
lines := []string{}
|
||||
for _, b := range h.bindings {
|
||||
@@ -94,7 +76,7 @@ func (h *helpDialog) View() string {
|
||||
}
|
||||
}
|
||||
|
||||
lines = append(lines, content)
|
||||
lines = append(lines, contentStyle.Render(content))
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
|
||||
@@ -173,11 +173,6 @@ func (m *InitDialogCmp) SetSize(width, height int) {
|
||||
m.height = height
|
||||
}
|
||||
|
||||
// Bindings implements layout.Bindings.
|
||||
func (m InitDialogCmp) Bindings() []key.Binding {
|
||||
return m.keys.ShortHelp()
|
||||
}
|
||||
|
||||
// CloseInitDialogMsg is a message that is sent when the init dialog is closed.
|
||||
type CloseInitDialogMsg struct {
|
||||
Initialize bool
|
||||
|
||||
@@ -112,8 +112,14 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
case key.Matches(msg, modelKeys.Enter):
|
||||
models := m.models()
|
||||
cmd := util.CmdHandler(state.ModelSelectedMsg{Provider: m.provider, Model: models[m.selectedIdx]})
|
||||
return m, tea.Batch(cmd, util.CmdHandler(modal.CloseModalMsg{}))
|
||||
return m, tea.Sequence(
|
||||
util.CmdHandler(modal.CloseModalMsg{}),
|
||||
util.CmdHandler(
|
||||
state.ModelSelectedMsg{
|
||||
Provider: m.provider,
|
||||
Model: models[m.selectedIdx],
|
||||
}),
|
||||
)
|
||||
case key.Matches(msg, modelKeys.Escape):
|
||||
return m, util.CmdHandler(modal.CloseModalMsg{})
|
||||
}
|
||||
@@ -125,9 +131,9 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *modelDialog) models() []client.ProviderModel {
|
||||
models := slices.SortedFunc(maps.Values(m.provider.Models), func(a, b client.ProviderModel) int {
|
||||
return strings.Compare(*a.Name, *b.Name)
|
||||
func (m *modelDialog) models() []client.ModelInfo {
|
||||
models := slices.SortedFunc(maps.Values(m.provider.Models), func(a, b client.ModelInfo) int {
|
||||
return strings.Compare(a.Name, b.Name)
|
||||
})
|
||||
return models
|
||||
}
|
||||
@@ -205,7 +211,7 @@ func (m *modelDialog) View() string {
|
||||
Foreground(t.BackgroundElement()).
|
||||
Bold(true)
|
||||
}
|
||||
modelItems = append(modelItems, itemStyle.Render(*models[i].Name))
|
||||
modelItems = append(modelItems, itemStyle.Render(models[i].Name))
|
||||
}
|
||||
|
||||
scrollIndicator := m.getScrollIndicators(maxDialogWidth)
|
||||
|
||||
@@ -31,7 +31,6 @@ type PermissionResponseMsg struct {
|
||||
// PermissionDialogComponent interface for permission dialog component
|
||||
type PermissionDialogComponent interface {
|
||||
layout.ModelWithView
|
||||
layout.Bindings
|
||||
// SetPermissions(permission permission.PermissionRequest) tea.Cmd
|
||||
}
|
||||
|
||||
@@ -424,10 +423,6 @@ func (p *permissionDialogComponent) View() string {
|
||||
return p.render()
|
||||
}
|
||||
|
||||
func (p *permissionDialogComponent) BindingKeys() []key.Binding {
|
||||
return layout.KeyMapToSlice(permissionsKeys)
|
||||
}
|
||||
|
||||
func (p *permissionDialogComponent) SetSize() tea.Cmd {
|
||||
// if p.permission.ID == "" {
|
||||
// return nil
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/sst/opencode/internal/components/modal"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
const question = "Are you sure you want to quit?"
|
||||
|
||||
// QuitDialog interface for the quit confirmation dialog
|
||||
type QuitDialog interface {
|
||||
layout.Modal
|
||||
IsQuitDialog() bool
|
||||
}
|
||||
|
||||
type quitDialog struct {
|
||||
width int
|
||||
height int
|
||||
|
||||
modal *modal.Modal
|
||||
selectedNo bool
|
||||
}
|
||||
|
||||
type helpMapping struct {
|
||||
LeftRight key.Binding
|
||||
EnterSpace key.Binding
|
||||
Yes key.Binding
|
||||
No key.Binding
|
||||
}
|
||||
|
||||
var helpKeys = helpMapping{
|
||||
LeftRight: key.NewBinding(
|
||||
key.WithKeys("left", "right", "h", "l", "tab"),
|
||||
key.WithHelp("←/→", "switch options"),
|
||||
),
|
||||
EnterSpace: key.NewBinding(
|
||||
key.WithKeys("enter", " "),
|
||||
key.WithHelp("enter/space", "confirm"),
|
||||
),
|
||||
Yes: key.NewBinding(
|
||||
key.WithKeys("y", "Y", "ctrl+c"),
|
||||
key.WithHelp("y/Y", "yes"),
|
||||
),
|
||||
No: key.NewBinding(
|
||||
key.WithKeys("n", "N"),
|
||||
key.WithHelp("n/N", "no"),
|
||||
),
|
||||
}
|
||||
|
||||
func (q *quitDialog) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *quitDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
q.width = msg.Width
|
||||
q.height = msg.Height
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case key.Matches(msg, helpKeys.LeftRight):
|
||||
q.selectedNo = !q.selectedNo
|
||||
return q, nil
|
||||
case key.Matches(msg, helpKeys.EnterSpace):
|
||||
if !q.selectedNo {
|
||||
return q, tea.Quit
|
||||
}
|
||||
return q, tea.Batch(
|
||||
util.CmdHandler(modal.CloseModalMsg{}),
|
||||
)
|
||||
case key.Matches(msg, helpKeys.Yes):
|
||||
return q, tea.Quit
|
||||
case key.Matches(msg, helpKeys.No):
|
||||
return q, tea.Batch(
|
||||
util.CmdHandler(modal.CloseModalMsg{}),
|
||||
)
|
||||
}
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
|
||||
func (q *quitDialog) Render(background string) string {
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.BaseStyle()
|
||||
|
||||
yesStyle := baseStyle
|
||||
noStyle := baseStyle
|
||||
spacerStyle := baseStyle.Background(t.BackgroundElement())
|
||||
|
||||
if q.selectedNo {
|
||||
noStyle = noStyle.Background(t.Primary()).Foreground(t.BackgroundElement())
|
||||
yesStyle = yesStyle.Background(t.BackgroundElement()).Foreground(t.Primary())
|
||||
} else {
|
||||
yesStyle = yesStyle.Background(t.Primary()).Foreground(t.BackgroundElement())
|
||||
noStyle = noStyle.Background(t.BackgroundElement()).Foreground(t.Primary())
|
||||
}
|
||||
|
||||
yesButton := yesStyle.Padding(0, 1).Render("Yes")
|
||||
noButton := noStyle.Padding(0, 1).Render("No")
|
||||
|
||||
buttons := lipgloss.JoinHorizontal(lipgloss.Left, yesButton, spacerStyle.Render(" "), noButton)
|
||||
|
||||
width := lipgloss.Width(question)
|
||||
remainingWidth := width - lipgloss.Width(buttons)
|
||||
if remainingWidth > 0 {
|
||||
buttons = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + buttons
|
||||
}
|
||||
|
||||
content := baseStyle.Render(
|
||||
lipgloss.JoinVertical(
|
||||
lipgloss.Center,
|
||||
question,
|
||||
"",
|
||||
buttons,
|
||||
),
|
||||
)
|
||||
|
||||
return q.modal.Render(content, background)
|
||||
}
|
||||
|
||||
func (q *quitDialog) Close() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *quitDialog) IsQuitDialog() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// NewQuitDialog creates a new quit confirmation dialog
|
||||
func NewQuitDialog() QuitDialog {
|
||||
return &quitDialog{
|
||||
selectedNo: true,
|
||||
modal: modal.New(),
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/components/list"
|
||||
"github.com/sst/opencode/internal/components/modal"
|
||||
components "github.com/sst/opencode/internal/components/util"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/state"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
@@ -48,7 +48,7 @@ type sessionDialog struct {
|
||||
height int
|
||||
modal *modal.Modal
|
||||
selectedSessionID string
|
||||
list components.SimpleList[sessionItem]
|
||||
list list.List[sessionItem]
|
||||
}
|
||||
|
||||
func (s *sessionDialog) Init() tea.Cmd {
|
||||
@@ -67,9 +67,9 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if item, idx := s.list.GetSelectedItem(); idx >= 0 {
|
||||
selectedSession := item.session
|
||||
s.selectedSessionID = selectedSession.Id
|
||||
return s, tea.Batch(
|
||||
util.CmdHandler(state.SessionSelectedMsg(&selectedSession)),
|
||||
return s, tea.Sequence(
|
||||
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
|
||||
listModel, cmd := s.list.Update(msg)
|
||||
s.list = listModel.(components.SimpleList[sessionItem])
|
||||
s.list = listModel.(list.List[sessionItem])
|
||||
return s, cmd
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ func NewSessionDialog(app *app.App) SessionDialog {
|
||||
sessionItems = append(sessionItems, sessionItem{session: sess})
|
||||
}
|
||||
|
||||
list := components.NewSimpleList(
|
||||
list := list.NewListComponent(
|
||||
sessionItems,
|
||||
10, // maxVisibleSessions
|
||||
"No sessions available",
|
||||
|
||||
@@ -2,10 +2,9 @@ package dialog
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
list "github.com/sst/opencode/internal/components/list"
|
||||
"github.com/sst/opencode/internal/components/modal"
|
||||
components "github.com/sst/opencode/internal/components/util"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/status"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
@@ -49,7 +48,7 @@ type themeDialog struct {
|
||||
height int
|
||||
|
||||
modal *modal.Modal
|
||||
list components.SimpleList[themeItem]
|
||||
list list.List[themeItem]
|
||||
}
|
||||
|
||||
func (t *themeDialog) Init() tea.Cmd {
|
||||
@@ -71,12 +70,12 @@ func (t *themeDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return t, util.CmdHandler(modal.CloseModalMsg{})
|
||||
}
|
||||
if err := theme.SetTheme(selectedTheme); err != nil {
|
||||
status.Error(err.Error())
|
||||
// status.Error(err.Error())
|
||||
return t, nil
|
||||
}
|
||||
return t, tea.Batch(
|
||||
util.CmdHandler(ThemeChangedMsg{ThemeName: selectedTheme}),
|
||||
return t, tea.Sequence(
|
||||
util.CmdHandler(modal.CloseModalMsg{}),
|
||||
util.CmdHandler(ThemeChangedMsg{ThemeName: selectedTheme}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -84,7 +83,7 @@ func (t *themeDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
var cmd tea.Cmd
|
||||
listModel, cmd := t.list.Update(msg)
|
||||
t.list = listModel.(components.SimpleList[themeItem])
|
||||
t.list = listModel.(list.List[themeItem])
|
||||
return t, cmd
|
||||
}
|
||||
|
||||
@@ -110,7 +109,7 @@ func NewThemeDialog() ThemeDialog {
|
||||
}
|
||||
}
|
||||
|
||||
list := components.NewSimpleList(
|
||||
list := list.NewListComponent(
|
||||
themeItems,
|
||||
10, // maxVisibleThemes
|
||||
"No themes available",
|
||||
@@ -125,4 +124,3 @@ func NewThemeDialog() ThemeDialog {
|
||||
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
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -642,6 +676,101 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// renderLinePrefix renders the line number and marker prefix for a diff line
|
||||
func renderLinePrefix(dl DiffLine, lineNum string, marker string, lineNumberStyle lipgloss.Style, t theme.Theme) string {
|
||||
// Style the marker based on line type
|
||||
var styledMarker string
|
||||
switch dl.Kind {
|
||||
case LineRemoved:
|
||||
styledMarker = lipgloss.NewStyle().Background(t.DiffRemovedBg()).Foreground(t.DiffRemoved()).Render(marker)
|
||||
case LineAdded:
|
||||
styledMarker = lipgloss.NewStyle().Background(t.DiffAddedBg()).Foreground(t.DiffAdded()).Render(marker)
|
||||
case LineContext:
|
||||
styledMarker = lipgloss.NewStyle().Background(t.DiffContextBg()).Foreground(t.TextMuted()).Render(marker)
|
||||
default:
|
||||
styledMarker = marker
|
||||
}
|
||||
|
||||
return lineNumberStyle.Render(lineNum + " " + styledMarker)
|
||||
}
|
||||
|
||||
// renderLineContent renders the content of a diff line with syntax and intra-line highlighting
|
||||
func renderLineContent(fileName string, dl DiffLine, bgStyle lipgloss.Style, highlightColor compat.AdaptiveColor, width int, t theme.Theme) string {
|
||||
// Apply syntax highlighting
|
||||
content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
|
||||
|
||||
// Apply intra-line highlighting if needed
|
||||
if len(dl.Segments) > 0 && (dl.Kind == LineRemoved || dl.Kind == LineAdded) {
|
||||
content = applyHighlighting(content, dl.Segments, dl.Kind, highlightColor)
|
||||
}
|
||||
|
||||
// Add a padding space for added/removed lines
|
||||
if dl.Kind == LineRemoved || dl.Kind == LineAdded {
|
||||
content = bgStyle.Render(" ") + content
|
||||
}
|
||||
|
||||
// Create the final line and truncate if needed
|
||||
return bgStyle.MaxHeight(1).Width(width).Render(
|
||||
ansi.Truncate(
|
||||
content,
|
||||
width,
|
||||
lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.TextMuted()).Render("..."),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// renderUnifiedLine renders a single line in unified diff format
|
||||
func renderUnifiedLine(fileName string, dl DiffLine, width int, t theme.Theme) string {
|
||||
removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(t)
|
||||
|
||||
// Determine line style and marker based on line type
|
||||
var marker string
|
||||
var bgStyle lipgloss.Style
|
||||
var lineNum string
|
||||
var highlightColor compat.AdaptiveColor
|
||||
|
||||
switch dl.Kind {
|
||||
case LineRemoved:
|
||||
marker = "-"
|
||||
bgStyle = removedLineStyle
|
||||
lineNumberStyle = lineNumberStyle.Foreground(t.DiffRemoved()).Background(t.DiffRemovedLineNumberBg())
|
||||
highlightColor = t.DiffHighlightRemoved()
|
||||
if dl.OldLineNo > 0 {
|
||||
lineNum = fmt.Sprintf("%6d ", dl.OldLineNo)
|
||||
} else {
|
||||
lineNum = " "
|
||||
}
|
||||
case LineAdded:
|
||||
marker = "+"
|
||||
bgStyle = addedLineStyle
|
||||
lineNumberStyle = lineNumberStyle.Foreground(t.DiffAdded()).Background(t.DiffAddedLineNumberBg())
|
||||
highlightColor = t.DiffHighlightAdded()
|
||||
if dl.NewLineNo > 0 {
|
||||
lineNum = fmt.Sprintf(" %7d", dl.NewLineNo)
|
||||
} else {
|
||||
lineNum = " "
|
||||
}
|
||||
case LineContext:
|
||||
marker = " "
|
||||
bgStyle = contextLineStyle
|
||||
if dl.OldLineNo > 0 && dl.NewLineNo > 0 {
|
||||
lineNum = fmt.Sprintf("%6d %6d", dl.OldLineNo, dl.NewLineNo)
|
||||
} else {
|
||||
lineNum = " "
|
||||
}
|
||||
}
|
||||
|
||||
// Create the line prefix
|
||||
prefix := renderLinePrefix(dl, lineNum, marker, lineNumberStyle, t)
|
||||
|
||||
// Render the content
|
||||
prefixWidth := ansi.StringWidth(prefix)
|
||||
contentWidth := width - prefixWidth
|
||||
content := renderLineContent(fileName, dl, bgStyle, highlightColor, contentWidth, t)
|
||||
|
||||
return prefix + content
|
||||
}
|
||||
|
||||
// renderDiffColumnLine is a helper function that handles the common logic for rendering diff columns
|
||||
func renderDiffColumnLine(
|
||||
fileName string,
|
||||
@@ -661,7 +790,6 @@ func renderDiffColumnLine(
|
||||
var marker string
|
||||
var bgStyle lipgloss.Style
|
||||
var lineNum string
|
||||
var highlightType LineType
|
||||
var highlightColor compat.AdaptiveColor
|
||||
|
||||
if isLeftColumn {
|
||||
@@ -671,7 +799,6 @@ func renderDiffColumnLine(
|
||||
marker = "-"
|
||||
bgStyle = removedLineStyle
|
||||
lineNumberStyle = lineNumberStyle.Foreground(t.DiffRemoved()).Background(t.DiffRemovedLineNumberBg())
|
||||
highlightType = LineRemoved
|
||||
highlightColor = t.DiffHighlightRemoved()
|
||||
case LineAdded:
|
||||
marker = "?"
|
||||
@@ -692,7 +819,6 @@ func renderDiffColumnLine(
|
||||
marker = "+"
|
||||
bgStyle = addedLineStyle
|
||||
lineNumberStyle = lineNumberStyle.Foreground(t.DiffAdded()).Background(t.DiffAddedLineNumberBg())
|
||||
highlightType = LineAdded
|
||||
highlightColor = t.DiffHighlightAdded()
|
||||
case LineRemoved:
|
||||
marker = "?"
|
||||
@@ -708,44 +834,24 @@ func renderDiffColumnLine(
|
||||
}
|
||||
}
|
||||
|
||||
// Style the marker based on line type
|
||||
var styledMarker string
|
||||
switch dl.Kind {
|
||||
case LineRemoved:
|
||||
styledMarker = removedLineStyle.Foreground(t.DiffRemoved()).Render(marker)
|
||||
case LineAdded:
|
||||
styledMarker = addedLineStyle.Foreground(t.DiffAdded()).Render(marker)
|
||||
case LineContext:
|
||||
styledMarker = contextLineStyle.Foreground(t.TextMuted()).Render(marker)
|
||||
default:
|
||||
styledMarker = marker
|
||||
}
|
||||
|
||||
// Create the line prefix
|
||||
prefix := lineNumberStyle.Render(lineNum + " " + styledMarker)
|
||||
prefix := renderLinePrefix(*dl, lineNum, marker, lineNumberStyle, t)
|
||||
|
||||
// Apply syntax highlighting
|
||||
content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
|
||||
// Determine if we should render content
|
||||
shouldRenderContent := (dl.Kind == LineRemoved && isLeftColumn) ||
|
||||
(dl.Kind == LineAdded && !isLeftColumn) ||
|
||||
dl.Kind == LineContext
|
||||
|
||||
// Apply intra-line highlighting if needed
|
||||
if (dl.Kind == LineRemoved && isLeftColumn || dl.Kind == LineAdded && !isLeftColumn) && len(dl.Segments) > 0 {
|
||||
content = applyHighlighting(content, dl.Segments, highlightType, highlightColor)
|
||||
if !shouldRenderContent {
|
||||
return bgStyle.Width(colWidth).Render("")
|
||||
}
|
||||
|
||||
// Add a padding space for added/removed lines
|
||||
if (dl.Kind == LineRemoved && isLeftColumn) || (dl.Kind == LineAdded && !isLeftColumn) {
|
||||
content = bgStyle.Render(" ") + content
|
||||
}
|
||||
// Render the content
|
||||
prefixWidth := ansi.StringWidth(prefix)
|
||||
contentWidth := colWidth - prefixWidth
|
||||
content := renderLineContent(fileName, *dl, bgStyle, highlightColor, contentWidth, t)
|
||||
|
||||
// Create the final line and truncate if needed
|
||||
lineText := prefix + content
|
||||
return bgStyle.MaxHeight(1).Width(colWidth).Render(
|
||||
ansi.Truncate(
|
||||
lineText,
|
||||
colWidth,
|
||||
lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.TextMuted()).Render("..."),
|
||||
),
|
||||
)
|
||||
return prefix + content
|
||||
}
|
||||
|
||||
// renderLeftColumn formats the left side of a side-by-side diff
|
||||
@@ -762,6 +868,27 @@ func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
|
||||
// Public API
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// RenderUnifiedHunk formats a hunk for unified display
|
||||
func RenderUnifiedHunk(fileName string, h Hunk, opts ...UnifiedOption) string {
|
||||
// Apply options to create the configuration
|
||||
config := NewUnifiedConfig(opts...)
|
||||
|
||||
// Make a copy of the hunk so we don't modify the original
|
||||
hunkCopy := Hunk{Lines: make([]DiffLine, len(h.Lines))}
|
||||
copy(hunkCopy.Lines, h.Lines)
|
||||
|
||||
// Highlight changes within lines
|
||||
HighlightIntralineChanges(&hunkCopy)
|
||||
|
||||
var sb strings.Builder
|
||||
for _, line := range hunkCopy.Lines {
|
||||
sb.WriteString(renderUnifiedLine(fileName, line, config.Width, theme.CurrentTheme()))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// RenderSideBySideHunk formats a hunk for side-by-side display
|
||||
func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) string {
|
||||
// Apply options to create the configuration
|
||||
@@ -792,6 +919,21 @@ func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) str
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// FormatUnifiedDiff creates a unified formatted view of a diff
|
||||
func FormatUnifiedDiff(filename string, diffText string, opts ...UnifiedOption) (string, error) {
|
||||
diffResult, err := ParseUnifiedDiff(diffText)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for _, h := range diffResult.Hunks {
|
||||
sb.WriteString(RenderUnifiedHunk(filename, h, opts...))
|
||||
}
|
||||
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
// FormatDiff creates a side-by-side formatted view of a diff
|
||||
func FormatDiff(filename string, diffText string, opts ...SideBySideOption) (string, error) {
|
||||
// t := theme.CurrentTheme()
|
||||
|
||||
@@ -1,29 +1,28 @@
|
||||
package utilComponents
|
||||
package list
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
|
||||
type SimpleListItem interface {
|
||||
type ListItem interface {
|
||||
Render(selected bool, width int) string
|
||||
}
|
||||
|
||||
type SimpleList[T SimpleListItem] interface {
|
||||
type List[T ListItem] interface {
|
||||
layout.ModelWithView
|
||||
layout.Bindings
|
||||
SetMaxWidth(maxWidth int)
|
||||
GetSelectedItem() (item T, idx int)
|
||||
SetItems(items []T)
|
||||
GetItems() []T
|
||||
SetSelectedIndex(idx int)
|
||||
SetEmptyMessage(msg string)
|
||||
IsEmpty() bool
|
||||
}
|
||||
|
||||
type simpleListComponent[T SimpleListItem] struct {
|
||||
type listComponent[T ListItem] struct {
|
||||
fallbackMsg string
|
||||
items []T
|
||||
selectedIdx int
|
||||
@@ -34,14 +33,14 @@ type simpleListComponent[T SimpleListItem] struct {
|
||||
height int
|
||||
}
|
||||
|
||||
type simpleListKeyMap struct {
|
||||
type listKeyMap struct {
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
UpAlpha key.Binding
|
||||
DownAlpha key.Binding
|
||||
}
|
||||
|
||||
var simpleListKeys = simpleListKeyMap{
|
||||
var simpleListKeys = listKeyMap{
|
||||
Up: key.NewBinding(
|
||||
key.WithKeys("up"),
|
||||
key.WithHelp("↑", "previous list item"),
|
||||
@@ -60,11 +59,11 @@ var simpleListKeys = simpleListKeyMap{
|
||||
),
|
||||
}
|
||||
|
||||
func (c *simpleListComponent[T]) Init() tea.Cmd {
|
||||
func (c *listComponent[T]) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *simpleListComponent[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
func (c *listComponent[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
@@ -84,11 +83,7 @@ func (c *simpleListComponent[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *simpleListComponent[T]) BindingKeys() []key.Binding {
|
||||
return layout.KeyMapToSlice(simpleListKeys)
|
||||
}
|
||||
|
||||
func (c *simpleListComponent[T]) GetSelectedItem() (T, int) {
|
||||
func (c *listComponent[T]) GetSelectedItem() (T, int) {
|
||||
if len(c.items) > 0 {
|
||||
return c.items[c.selectedIdx], c.selectedIdx
|
||||
}
|
||||
@@ -97,40 +92,41 @@ func (c *simpleListComponent[T]) GetSelectedItem() (T, int) {
|
||||
return zero, -1
|
||||
}
|
||||
|
||||
func (c *simpleListComponent[T]) SetItems(items []T) {
|
||||
func (c *listComponent[T]) SetItems(items []T) {
|
||||
c.selectedIdx = 0
|
||||
c.items = items
|
||||
}
|
||||
|
||||
func (c *simpleListComponent[T]) GetItems() []T {
|
||||
func (c *listComponent[T]) GetItems() []T {
|
||||
return c.items
|
||||
}
|
||||
|
||||
func (c *simpleListComponent[T]) SetMaxWidth(width int) {
|
||||
func (c *listComponent[T]) SetEmptyMessage(msg string) {
|
||||
c.fallbackMsg = msg
|
||||
}
|
||||
|
||||
func (c *listComponent[T]) IsEmpty() bool {
|
||||
return len(c.items) == 0
|
||||
}
|
||||
|
||||
func (c *listComponent[T]) SetMaxWidth(width int) {
|
||||
c.maxWidth = width
|
||||
}
|
||||
|
||||
func (c *simpleListComponent[T]) SetSelectedIndex(idx int) {
|
||||
func (c *listComponent[T]) SetSelectedIndex(idx int) {
|
||||
if idx >= 0 && idx < len(c.items) {
|
||||
c.selectedIdx = idx
|
||||
}
|
||||
}
|
||||
|
||||
func (c *simpleListComponent[T]) View() string {
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.BaseStyle()
|
||||
|
||||
func (c *listComponent[T]) View() string {
|
||||
items := c.items
|
||||
maxWidth := c.maxWidth
|
||||
maxVisibleItems := min(c.maxVisibleItems, len(items))
|
||||
startIdx := 0
|
||||
|
||||
if len(items) <= 0 {
|
||||
return baseStyle.
|
||||
Background(t.Background()).
|
||||
Padding(0, 1).
|
||||
Width(maxWidth).
|
||||
Render(c.fallbackMsg)
|
||||
return c.fallbackMsg
|
||||
}
|
||||
|
||||
if len(items) > maxVisibleItems {
|
||||
@@ -155,8 +151,8 @@ func (c *simpleListComponent[T]) View() string {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, listItems...)
|
||||
}
|
||||
|
||||
func NewSimpleList[T SimpleListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) SimpleList[T] {
|
||||
return &simpleListComponent[T]{
|
||||
func NewListComponent[T ListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) List[T] {
|
||||
return &listComponent[T]{
|
||||
fallbackMsg: fallbackMsg,
|
||||
items: items,
|
||||
maxVisibleItems: maxVisibleItems,
|
||||
@@ -10,18 +10,16 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Theme string `toml:"Theme"`
|
||||
Provider string `toml:"Provider"`
|
||||
Model string `toml:"Model"`
|
||||
Theme string `toml:"theme"`
|
||||
Provider string `toml:"provider"`
|
||||
Model string `toml:"model"`
|
||||
}
|
||||
|
||||
// NewConfig creates a new Config instance with default values.
|
||||
// This can be useful for initializing a new configuration file.
|
||||
func NewConfig(theme, provider, model string) *Config {
|
||||
func NewConfig() *Config {
|
||||
return &Config{
|
||||
Theme: theme,
|
||||
Provider: provider,
|
||||
Model: model,
|
||||
Theme: "opencode",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,12 +33,10 @@ func SaveConfig(filePath string, config *Config) error {
|
||||
defer file.Close()
|
||||
|
||||
writer := bufio.NewWriter(file)
|
||||
|
||||
encoder := toml.NewEncoder(writer)
|
||||
if err := encoder.Encode(config); err != nil {
|
||||
return fmt.Errorf("failed to encode config to TOML file %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
if err := writer.Flush(); err != nil {
|
||||
return fmt.Errorf("failed to flush writer for config file %s: %w", filePath, err)
|
||||
}
|
||||
@@ -53,13 +49,11 @@ func SaveConfig(filePath string, config *Config) error {
|
||||
// It returns a pointer to the Config struct and an error if any issues occur.
|
||||
func LoadConfig(filePath string) (*Config, error) {
|
||||
var config Config
|
||||
|
||||
if _, err := toml.DecodeFile(filePath, &config); err != nil {
|
||||
if _, statErr := os.Stat(filePath); os.IsNotExist(statErr) {
|
||||
return nil, fmt.Errorf("config file not found at %s: %w", filePath, statErr)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to decode TOML from file %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
@@ -15,16 +14,19 @@ type ModelWithView interface {
|
||||
type Container interface {
|
||||
ModelWithView
|
||||
Sizeable
|
||||
Bindings
|
||||
Focus()
|
||||
Blur()
|
||||
MaxWidth() int
|
||||
Alignment() lipgloss.Position
|
||||
GetPosition() (x, y int)
|
||||
GetContent() ModelWithView
|
||||
}
|
||||
|
||||
type container struct {
|
||||
width int
|
||||
height int
|
||||
x int
|
||||
y int
|
||||
|
||||
content ModelWithView
|
||||
|
||||
@@ -142,7 +144,7 @@ func (c *container) SetSize(width, height int) tea.Cmd {
|
||||
}
|
||||
|
||||
func (c *container) GetSize() (int, int) {
|
||||
return c.width, c.height
|
||||
return min(c.width, c.maxWidth), c.height
|
||||
}
|
||||
|
||||
func (c *container) MaxWidth() int {
|
||||
@@ -153,13 +155,6 @@ func (c *container) Alignment() lipgloss.Position {
|
||||
return c.align
|
||||
}
|
||||
|
||||
func (c *container) BindingKeys() []key.Binding {
|
||||
if b, ok := c.content.(Bindings); ok {
|
||||
return b.BindingKeys()
|
||||
}
|
||||
return []key.Binding{}
|
||||
}
|
||||
|
||||
// Focus sets the container as focused
|
||||
func (c *container) Focus() {
|
||||
c.focused = true
|
||||
@@ -178,6 +173,16 @@ func (c *container) Blur() {
|
||||
}
|
||||
}
|
||||
|
||||
// GetPosition returns the x, y coordinates of the container
|
||||
func (c *container) GetPosition() (x, y int) {
|
||||
return c.x, c.y
|
||||
}
|
||||
|
||||
// GetContent returns the content of the container
|
||||
func (c *container) GetContent() ModelWithView {
|
||||
return c.content
|
||||
}
|
||||
|
||||
type ContainerOption func(*container)
|
||||
|
||||
func NewContainer(content ModelWithView, options ...ContainerOption) Container {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
|
||||
type FlexDirection int
|
||||
@@ -27,7 +27,6 @@ func FlexPaneSizeFixed(size int) FlexPaneSize {
|
||||
type FlexLayout interface {
|
||||
ModelWithView
|
||||
Sizeable
|
||||
Bindings
|
||||
SetPanes(panes []Container) tea.Cmd
|
||||
SetPaneSizes(sizes []FlexPaneSize) tea.Cmd
|
||||
SetDirection(direction FlexDirection) tea.Cmd
|
||||
@@ -78,6 +77,7 @@ func (f *flexLayout) View() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
t := theme.CurrentTheme()
|
||||
views := make([]string, 0, len(f.panes))
|
||||
for i, pane := range f.panes {
|
||||
if pane == nil {
|
||||
@@ -91,6 +91,7 @@ func (f *flexLayout) View() string {
|
||||
paneWidth,
|
||||
pane.Alignment(),
|
||||
pane.View(),
|
||||
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
||||
)
|
||||
views = append(views, view)
|
||||
} else {
|
||||
@@ -101,6 +102,7 @@ func (f *flexLayout) View() string {
|
||||
lipgloss.Center,
|
||||
pane.Alignment(),
|
||||
pane.View(),
|
||||
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
||||
)
|
||||
views = append(views, view)
|
||||
}
|
||||
@@ -161,11 +163,51 @@ func (f *flexLayout) SetSize(width, height int) tea.Cmd {
|
||||
f.height = height
|
||||
|
||||
var cmds []tea.Cmd
|
||||
currentX, currentY := 0, 0
|
||||
|
||||
for i, pane := range f.panes {
|
||||
if pane != nil {
|
||||
paneWidth, paneHeight := f.calculatePaneSize(i)
|
||||
|
||||
// Calculate actual position based on alignment
|
||||
actualX, actualY := currentX, currentY
|
||||
|
||||
if f.direction == FlexDirectionHorizontal {
|
||||
// In horizontal layout, vertical alignment affects Y position
|
||||
// (lipgloss.Center is used for vertical alignment in JoinHorizontal)
|
||||
actualY = (f.height - paneHeight) / 2
|
||||
} else {
|
||||
// In vertical layout, horizontal alignment affects X position
|
||||
contentWidth := paneWidth
|
||||
if pane.MaxWidth() > 0 && contentWidth > pane.MaxWidth() {
|
||||
contentWidth = pane.MaxWidth()
|
||||
}
|
||||
|
||||
switch pane.Alignment() {
|
||||
case lipgloss.Center:
|
||||
actualX = (f.width - contentWidth) / 2
|
||||
case lipgloss.Right:
|
||||
actualX = f.width - contentWidth
|
||||
case lipgloss.Left:
|
||||
actualX = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Set position if the pane is a *container
|
||||
if c, ok := pane.(*container); ok {
|
||||
c.x = actualX
|
||||
c.y = actualY
|
||||
}
|
||||
|
||||
cmd := pane.SetSize(paneWidth, paneHeight)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
// Update position for next pane
|
||||
if f.direction == FlexDirectionHorizontal {
|
||||
currentX += paneWidth
|
||||
} else {
|
||||
currentY += paneHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
@@ -199,18 +241,6 @@ func (f *flexLayout) SetDirection(direction FlexDirection) tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *flexLayout) BindingKeys() []key.Binding {
|
||||
keys := []key.Binding{}
|
||||
for _, pane := range f.panes {
|
||||
if pane != nil {
|
||||
if b, ok := pane.(Bindings); ok {
|
||||
keys = append(keys, b.BindingKeys()...)
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func NewFlexLayout(options ...FlexLayoutOption) FlexLayout {
|
||||
layout := &flexLayout{
|
||||
direction: FlexDirectionHorizontal,
|
||||
|
||||
@@ -11,7 +11,6 @@ var Current *LayoutInfo
|
||||
|
||||
func init() {
|
||||
Current = &LayoutInfo{
|
||||
Size: LayoutSizeNormal,
|
||||
Viewport: Dimensions{Width: 80, Height: 25},
|
||||
Container: Dimensions{Width: 80, Height: 25},
|
||||
}
|
||||
@@ -19,19 +18,12 @@ func init() {
|
||||
|
||||
type LayoutSize string
|
||||
|
||||
const (
|
||||
LayoutSizeSmall LayoutSize = "small"
|
||||
LayoutSizeNormal LayoutSize = "normal"
|
||||
LayoutSizeLarge LayoutSize = "large"
|
||||
)
|
||||
|
||||
type Dimensions struct {
|
||||
Width int
|
||||
Height int
|
||||
}
|
||||
|
||||
type LayoutInfo struct {
|
||||
Size LayoutSize
|
||||
Viewport Dimensions
|
||||
Container Dimensions
|
||||
}
|
||||
@@ -53,10 +45,6 @@ type Sizeable interface {
|
||||
GetSize() (int, int)
|
||||
}
|
||||
|
||||
type Bindings interface {
|
||||
BindingKeys() []key.Binding
|
||||
}
|
||||
|
||||
func KeyMapToSlice(t any) (bindings []key.Binding) {
|
||||
typ := reflect.TypeOf(t)
|
||||
if typ.Kind() != reflect.Struct {
|
||||
|
||||
@@ -2,7 +2,6 @@ package page
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
@@ -12,7 +11,6 @@ import (
|
||||
"github.com/sst/opencode/internal/components/chat"
|
||||
"github.com/sst/opencode/internal/components/dialog"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/status"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
@@ -24,6 +22,7 @@ type chatPage struct {
|
||||
messages layout.Container
|
||||
layout layout.FlexLayout
|
||||
completionDialog dialog.CompletionDialog
|
||||
completionManager *completions.CompletionManager
|
||||
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)
|
||||
cmds = append(cmds, cmd)
|
||||
case chat.SendMsg:
|
||||
p.showCompletionDialog = false
|
||||
cmd := p.sendMessage(msg.Text, msg.Attachments)
|
||||
if cmd != nil {
|
||||
return p, cmd
|
||||
}
|
||||
case dialog.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:
|
||||
p.showCompletionDialog = false
|
||||
p.app.SetCompletionDialogOpen(false)
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
@@ -105,7 +81,6 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case key.Matches(msg, keyMap.ShowCompletionDialog):
|
||||
p.showCompletionDialog = true
|
||||
p.app.SetCompletionDialogOpen(true)
|
||||
// Continue sending keys to layout->chat
|
||||
case key.Matches(msg, keyMap.Cancel):
|
||||
if p.app.Session.Id != "" {
|
||||
@@ -118,7 +93,14 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return p, util.CmdHandler(chat.ToggleToolMessagesMsg{})
|
||||
}
|
||||
}
|
||||
|
||||
if p.showCompletionDialog {
|
||||
// Get the current text from the editor to determine which provider to use
|
||||
editorModel := p.editor.GetContent().(interface{ GetValue() string })
|
||||
currentInput := editorModel.GetValue()
|
||||
provider := p.completionManager.GetProvider(currentInput)
|
||||
p.completionDialog.SetProvider(provider)
|
||||
|
||||
context, contextCmd := p.completionDialog.Update(msg)
|
||||
p.completionDialog = context.(dialog.CompletionDialog)
|
||||
cmds = append(cmds, contextCmd)
|
||||
@@ -155,17 +137,16 @@ func (p *chatPage) GetSize() (int, int) {
|
||||
func (p *chatPage) View() string {
|
||||
layoutView := p.layout.View()
|
||||
|
||||
// TODO: Fix this with our new layout
|
||||
if p.showCompletionDialog {
|
||||
_, layoutHeight := p.layout.GetSize()
|
||||
editorWidth, editorHeight := p.editor.GetSize()
|
||||
editorWidth, _ := p.editor.GetSize()
|
||||
editorX, editorY := p.editor.GetPosition()
|
||||
|
||||
p.completionDialog.SetWidth(editorWidth)
|
||||
overlay := p.completionDialog.View()
|
||||
|
||||
layoutView = layout.PlaceOverlay(
|
||||
0,
|
||||
layoutHeight-editorHeight-lipgloss.Height(overlay),
|
||||
editorX,
|
||||
editorY-lipgloss.Height(overlay)+2,
|
||||
overlay,
|
||||
layoutView,
|
||||
)
|
||||
@@ -174,16 +155,11 @@ func (p *chatPage) View() string {
|
||||
return layoutView
|
||||
}
|
||||
|
||||
func (p *chatPage) BindingKeys() []key.Binding {
|
||||
bindings := layout.KeyMapToSlice(keyMap)
|
||||
bindings = append(bindings, p.messages.BindingKeys()...)
|
||||
bindings = append(bindings, p.editor.BindingKeys()...)
|
||||
return bindings
|
||||
}
|
||||
|
||||
func NewChatPage(app *app.App) layout.ModelWithView {
|
||||
cg := completions.NewFileAndFolderContextGroup()
|
||||
completionDialog := dialog.NewCompletionDialogComponent(cg)
|
||||
completionManager := completions.NewCompletionManager(app)
|
||||
initialProvider := completionManager.GetProvider("")
|
||||
completionDialog := dialog.NewCompletionDialogComponent(initialProvider)
|
||||
|
||||
messagesContainer := layout.NewContainer(
|
||||
chat.NewMessagesComponent(app),
|
||||
)
|
||||
@@ -192,17 +168,19 @@ func NewChatPage(app *app.App) layout.ModelWithView {
|
||||
layout.WithMaxWidth(layout.Current.Container.Width),
|
||||
layout.WithAlignCenter(),
|
||||
)
|
||||
|
||||
return &chatPage{
|
||||
app: app,
|
||||
editor: editorContainer,
|
||||
messages: messagesContainer,
|
||||
completionDialog: completionDialog,
|
||||
app: app,
|
||||
editor: editorContainer,
|
||||
messages: messagesContainer,
|
||||
completionDialog: completionDialog,
|
||||
completionManager: completionManager,
|
||||
layout: layout.NewFlexLayout(
|
||||
layout.WithPanes(messagesContainer, editorContainer),
|
||||
layout.WithDirection(layout.FlexDirectionVertical),
|
||||
layout.WithPaneSizes(
|
||||
layout.FlexPaneSizeGrow,
|
||||
layout.FlexPaneSizeFixed(5),
|
||||
layout.FlexPaneSizeFixed(6),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
package pubsub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultChannelBufferSize = 100
|
||||
|
||||
type Broker[T any] struct {
|
||||
subs map[chan Event[T]]context.CancelFunc
|
||||
mu sync.RWMutex
|
||||
isClosed bool
|
||||
}
|
||||
|
||||
func NewBroker[T any]() *Broker[T] {
|
||||
return &Broker[T]{
|
||||
subs: make(map[chan Event[T]]context.CancelFunc),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Broker[T]) Shutdown() {
|
||||
b.mu.Lock()
|
||||
if b.isClosed {
|
||||
b.mu.Unlock()
|
||||
return
|
||||
}
|
||||
b.isClosed = true
|
||||
|
||||
for ch, cancel := range b.subs {
|
||||
cancel()
|
||||
close(ch)
|
||||
delete(b.subs, ch)
|
||||
}
|
||||
b.mu.Unlock()
|
||||
slog.Debug("PubSub broker shut down", "type", fmt.Sprintf("%T", *new(T)))
|
||||
}
|
||||
|
||||
func (b *Broker[T]) Subscribe(ctx context.Context) <-chan Event[T] {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
if b.isClosed {
|
||||
closedCh := make(chan Event[T])
|
||||
close(closedCh)
|
||||
return closedCh
|
||||
}
|
||||
|
||||
subCtx, subCancel := context.WithCancel(ctx)
|
||||
subscriberChannel := make(chan Event[T], defaultChannelBufferSize)
|
||||
b.subs[subscriberChannel] = subCancel
|
||||
|
||||
go func() {
|
||||
<-subCtx.Done()
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if _, ok := b.subs[subscriberChannel]; ok {
|
||||
close(subscriberChannel)
|
||||
delete(b.subs, subscriberChannel)
|
||||
}
|
||||
}()
|
||||
|
||||
return subscriberChannel
|
||||
}
|
||||
|
||||
func (b *Broker[T]) Publish(eventType EventType, payload T) {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
|
||||
if b.isClosed {
|
||||
slog.Warn("Attempted to publish on a closed pubsub broker", "type", eventType, "payload_type", fmt.Sprintf("%T", payload))
|
||||
return
|
||||
}
|
||||
|
||||
event := Event[T]{Type: eventType, Payload: payload}
|
||||
|
||||
for ch := range b.subs {
|
||||
// Non-blocking send with a fallback to a goroutine to prevent slow subscribers
|
||||
// from blocking the publisher.
|
||||
select {
|
||||
case ch <- event:
|
||||
// Successfully sent
|
||||
default:
|
||||
// Subscriber channel is full or receiver is slow.
|
||||
// Send in a new goroutine to avoid blocking the publisher.
|
||||
// This might lead to out-of-order delivery for this specific slow subscriber.
|
||||
go func(sChan chan Event[T], ev Event[T]) {
|
||||
// Re-check if broker is closed before attempting send in goroutine
|
||||
b.mu.RLock()
|
||||
isBrokerClosed := b.isClosed
|
||||
b.mu.RUnlock()
|
||||
if isBrokerClosed {
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case sChan <- ev:
|
||||
case <-time.After(2 * time.Second): // Timeout for slow subscriber
|
||||
slog.Warn("PubSub: Dropped event for slow subscriber after timeout", "type", ev.Type)
|
||||
}
|
||||
}(ch, event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Broker[T]) GetSubscriberCount() int {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return len(b.subs)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package pubsub
|
||||
|
||||
import "context"
|
||||
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
EventTypeCreated EventType = "created"
|
||||
EventTypeUpdated EventType = "updated"
|
||||
EventTypeDeleted EventType = "deleted"
|
||||
)
|
||||
|
||||
type Event[T any] struct {
|
||||
Type EventType
|
||||
Payload T
|
||||
}
|
||||
|
||||
type Subscriber[T any] interface {
|
||||
Subscribe(ctx context.Context) <-chan Event[T]
|
||||
}
|
||||
|
||||
type Publisher[T any] interface {
|
||||
Publish(eventType EventType, payload T)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
type SessionSelectedMsg = *client.SessionInfo
|
||||
type ModelSelectedMsg struct {
|
||||
Provider client.ProviderInfo
|
||||
Model client.ProviderModel
|
||||
Model client.ModelInfo
|
||||
}
|
||||
|
||||
type SessionClearedMsg struct{}
|
||||
@@ -17,5 +17,3 @@ type CompactSessionMsg struct{}
|
||||
type StateUpdatedMsg struct {
|
||||
State map[string]any
|
||||
}
|
||||
|
||||
// TODO: store in CONFIG/tui.yaml
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
package status
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sst/opencode/internal/pubsub"
|
||||
)
|
||||
|
||||
type Level string
|
||||
|
||||
const (
|
||||
LevelInfo Level = "info"
|
||||
LevelWarn Level = "warn"
|
||||
LevelError Level = "error"
|
||||
LevelDebug Level = "debug"
|
||||
)
|
||||
|
||||
type StatusMessage struct {
|
||||
Level Level `json:"level"`
|
||||
Message string `json:"message"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Critical bool `json:"critical"`
|
||||
Duration time.Duration `json:"duration"`
|
||||
}
|
||||
|
||||
// StatusOption is a function that configures a status message
|
||||
type StatusOption func(*StatusMessage)
|
||||
|
||||
// WithCritical marks a status message as critical, causing it to be displayed immediately
|
||||
func WithCritical(critical bool) StatusOption {
|
||||
return func(msg *StatusMessage) {
|
||||
msg.Critical = critical
|
||||
}
|
||||
}
|
||||
|
||||
// WithDuration sets a custom display duration for a status message
|
||||
func WithDuration(duration time.Duration) StatusOption {
|
||||
return func(msg *StatusMessage) {
|
||||
msg.Duration = duration
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
EventStatusPublished pubsub.EventType = "status_published"
|
||||
)
|
||||
|
||||
type Service interface {
|
||||
pubsub.Subscriber[StatusMessage]
|
||||
|
||||
Info(message string, opts ...StatusOption)
|
||||
Warn(message string, opts ...StatusOption)
|
||||
Error(message string, opts ...StatusOption)
|
||||
Debug(message string, opts ...StatusOption)
|
||||
}
|
||||
|
||||
type service struct {
|
||||
broker *pubsub.Broker[StatusMessage]
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
var globalStatusService *service
|
||||
|
||||
func InitService() error {
|
||||
if globalStatusService != nil {
|
||||
return fmt.Errorf("status service already initialized")
|
||||
}
|
||||
broker := pubsub.NewBroker[StatusMessage]()
|
||||
globalStatusService = &service{
|
||||
broker: broker,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetService() Service {
|
||||
if globalStatusService == nil {
|
||||
panic("status service not initialized. Call status.InitService() at application startup.")
|
||||
}
|
||||
return globalStatusService
|
||||
}
|
||||
|
||||
func (s *service) Info(message string, opts ...StatusOption) {
|
||||
s.publish(LevelInfo, message, opts...)
|
||||
slog.Info(message)
|
||||
}
|
||||
|
||||
func (s *service) Warn(message string, opts ...StatusOption) {
|
||||
s.publish(LevelWarn, message, opts...)
|
||||
slog.Warn(message)
|
||||
}
|
||||
|
||||
func (s *service) Error(message string, opts ...StatusOption) {
|
||||
s.publish(LevelError, message, opts...)
|
||||
slog.Error(message)
|
||||
}
|
||||
|
||||
func (s *service) Debug(message string, opts ...StatusOption) {
|
||||
s.publish(LevelDebug, message, opts...)
|
||||
slog.Debug(message)
|
||||
}
|
||||
|
||||
func (s *service) publish(level Level, messageText string, opts ...StatusOption) {
|
||||
statusMsg := StatusMessage{
|
||||
Level: level,
|
||||
Message: messageText,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
// Apply all options
|
||||
for _, opt := range opts {
|
||||
opt(&statusMsg)
|
||||
}
|
||||
|
||||
s.broker.Publish(EventStatusPublished, statusMsg)
|
||||
}
|
||||
|
||||
func (s *service) Subscribe(ctx context.Context) <-chan pubsub.Event[StatusMessage] {
|
||||
return s.broker.Subscribe(ctx)
|
||||
}
|
||||
|
||||
func Info(message string, opts ...StatusOption) {
|
||||
GetService().Info(message, opts...)
|
||||
}
|
||||
|
||||
func Warn(message string, opts ...StatusOption) {
|
||||
GetService().Warn(message, opts...)
|
||||
}
|
||||
|
||||
func Error(message string, opts ...StatusOption) {
|
||||
GetService().Error(message, opts...)
|
||||
}
|
||||
|
||||
func Debug(message string, opts ...StatusOption) {
|
||||
GetService().Debug(message, opts...)
|
||||
}
|
||||
|
||||
func Subscribe(ctx context.Context) <-chan pubsub.Event[StatusMessage] {
|
||||
return GetService().Subscribe(ctx)
|
||||
}
|
||||
276
packages/tui/internal/theme/ayu.go
Normal file
@@ -0,0 +1,276 @@
|
||||
package theme
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2/compat"
|
||||
)
|
||||
|
||||
// AyuTheme implements the Theme interface with Ayu Dark colors.
|
||||
// It provides a modern dark theme inspired by the Ayu color scheme.
|
||||
type AyuTheme struct {
|
||||
BaseTheme
|
||||
}
|
||||
|
||||
// NewAyuTheme creates a new instance of the Ayu Dark theme.
|
||||
func NewAyuTheme() *AyuTheme {
|
||||
// Ayu Dark color palette
|
||||
// Base background colors
|
||||
darkBg := "#0B0E14" // App background
|
||||
darkBgAlt := "#0D1017" // Editor background
|
||||
darkLine := "#11151C" // UI line separators
|
||||
darkPanel := "#0F131A" // UI panel background
|
||||
|
||||
// Text colors
|
||||
darkFg := "#BFBDB6" // Primary text
|
||||
darkFgMuted := "#565B66" // Muted text
|
||||
darkGutter := "#6C7380" // Gutter text
|
||||
|
||||
// Syntax highlighting colors
|
||||
darkTag := "#39BAE6" // Tags and attributes
|
||||
darkFunc := "#FFB454" // Functions
|
||||
darkEntity := "#59C2FF" // Entities and variables
|
||||
darkString := "#AAD94C" // Strings
|
||||
darkRegexp := "#95E6CB" // Regular expressions
|
||||
darkMarkup := "#F07178" // Markup elements
|
||||
darkKeyword := "#FF8F40" // Keywords
|
||||
darkSpecial := "#E6B673" // Special characters
|
||||
darkComment := "#ACB6BF" // Comments
|
||||
darkConstant := "#D2A6FF" // Constants
|
||||
darkOperator := "#F29668" // Operators
|
||||
|
||||
// Version control colors
|
||||
darkAdded := "#7FD962" // Added lines
|
||||
darkRemoved := "#F26D78" // Removed lines
|
||||
|
||||
// Accent colors
|
||||
darkAccent := "#E6B450" // Primary accent
|
||||
darkError := "#D95757" // Error color
|
||||
|
||||
// Active state colors
|
||||
darkIndentActive := "#6C7380" // Active indent guides
|
||||
|
||||
theme := &AyuTheme{}
|
||||
|
||||
// Base colors
|
||||
theme.PrimaryColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkEntity),
|
||||
Light: lipgloss.Color(darkEntity),
|
||||
}
|
||||
theme.SecondaryColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkConstant),
|
||||
Light: lipgloss.Color(darkConstant),
|
||||
}
|
||||
theme.AccentColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkAccent),
|
||||
Light: lipgloss.Color(darkAccent),
|
||||
}
|
||||
|
||||
// Status colors
|
||||
theme.ErrorColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkError),
|
||||
Light: lipgloss.Color(darkError),
|
||||
}
|
||||
theme.WarningColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkSpecial),
|
||||
Light: lipgloss.Color(darkSpecial),
|
||||
}
|
||||
theme.SuccessColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkAdded),
|
||||
Light: lipgloss.Color(darkAdded),
|
||||
}
|
||||
theme.InfoColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkTag),
|
||||
Light: lipgloss.Color(darkTag),
|
||||
}
|
||||
|
||||
// Text colors
|
||||
theme.TextColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkFg),
|
||||
Light: lipgloss.Color(darkFg),
|
||||
}
|
||||
theme.TextMutedColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkFgMuted),
|
||||
Light: lipgloss.Color(darkFgMuted),
|
||||
}
|
||||
|
||||
// Background colors
|
||||
theme.BackgroundColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkBg),
|
||||
Light: lipgloss.Color(darkBg),
|
||||
}
|
||||
theme.BackgroundSubtleColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkBgAlt),
|
||||
Light: lipgloss.Color(darkBgAlt),
|
||||
}
|
||||
theme.BackgroundElementColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkPanel),
|
||||
Light: lipgloss.Color(darkPanel),
|
||||
}
|
||||
|
||||
// Border colors
|
||||
theme.BorderColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkGutter),
|
||||
Light: lipgloss.Color(darkGutter),
|
||||
}
|
||||
theme.BorderActiveColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkIndentActive),
|
||||
Light: lipgloss.Color(darkIndentActive),
|
||||
}
|
||||
theme.BorderSubtleColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkLine),
|
||||
Light: lipgloss.Color(darkLine),
|
||||
}
|
||||
|
||||
// Diff view colors
|
||||
theme.DiffAddedColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkAdded),
|
||||
Light: lipgloss.Color(darkAdded),
|
||||
}
|
||||
theme.DiffRemovedColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkRemoved),
|
||||
Light: lipgloss.Color(darkRemoved),
|
||||
}
|
||||
theme.DiffContextColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkFgMuted),
|
||||
Light: lipgloss.Color(darkFgMuted),
|
||||
}
|
||||
theme.DiffHunkHeaderColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkGutter),
|
||||
Light: lipgloss.Color(darkGutter),
|
||||
}
|
||||
theme.DiffHighlightAddedColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkAdded),
|
||||
Light: lipgloss.Color(darkAdded),
|
||||
}
|
||||
theme.DiffHighlightRemovedColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkRemoved),
|
||||
Light: lipgloss.Color(darkRemoved),
|
||||
}
|
||||
theme.DiffAddedBgColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color("#1a2b1a"),
|
||||
Light: lipgloss.Color("#1a2b1a"),
|
||||
}
|
||||
theme.DiffRemovedBgColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color("#2b1a1a"),
|
||||
Light: lipgloss.Color("#2b1a1a"),
|
||||
}
|
||||
theme.DiffContextBgColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkBgAlt),
|
||||
Light: lipgloss.Color(darkBgAlt),
|
||||
}
|
||||
theme.DiffLineNumberColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkGutter),
|
||||
Light: lipgloss.Color(darkGutter),
|
||||
}
|
||||
theme.DiffAddedLineNumberBgColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color("#152b15"),
|
||||
Light: lipgloss.Color("#152b15"),
|
||||
}
|
||||
theme.DiffRemovedLineNumberBgColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color("#2b1515"),
|
||||
Light: lipgloss.Color("#2b1515"),
|
||||
}
|
||||
|
||||
// Markdown colors
|
||||
theme.MarkdownTextColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkFg),
|
||||
Light: lipgloss.Color(darkFg),
|
||||
}
|
||||
theme.MarkdownHeadingColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkFunc),
|
||||
Light: lipgloss.Color(darkFunc),
|
||||
}
|
||||
theme.MarkdownLinkColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkTag),
|
||||
Light: lipgloss.Color(darkTag),
|
||||
}
|
||||
theme.MarkdownLinkTextColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkEntity),
|
||||
Light: lipgloss.Color(darkEntity),
|
||||
}
|
||||
theme.MarkdownCodeColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkString),
|
||||
Light: lipgloss.Color(darkString),
|
||||
}
|
||||
theme.MarkdownBlockQuoteColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkSpecial),
|
||||
Light: lipgloss.Color(darkSpecial),
|
||||
}
|
||||
theme.MarkdownEmphColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkKeyword),
|
||||
Light: lipgloss.Color(darkKeyword),
|
||||
}
|
||||
theme.MarkdownStrongColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkMarkup),
|
||||
Light: lipgloss.Color(darkMarkup),
|
||||
}
|
||||
theme.MarkdownHorizontalRuleColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkGutter),
|
||||
Light: lipgloss.Color(darkGutter),
|
||||
}
|
||||
theme.MarkdownListItemColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkOperator),
|
||||
Light: lipgloss.Color(darkOperator),
|
||||
}
|
||||
theme.MarkdownListEnumerationColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkConstant),
|
||||
Light: lipgloss.Color(darkConstant),
|
||||
}
|
||||
theme.MarkdownImageColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkRegexp),
|
||||
Light: lipgloss.Color(darkRegexp),
|
||||
}
|
||||
theme.MarkdownImageTextColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkEntity),
|
||||
Light: lipgloss.Color(darkEntity),
|
||||
}
|
||||
theme.MarkdownCodeBlockColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkString),
|
||||
Light: lipgloss.Color(darkString),
|
||||
}
|
||||
|
||||
// Syntax highlighting colors
|
||||
theme.SyntaxCommentColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkComment),
|
||||
Light: lipgloss.Color(darkComment),
|
||||
}
|
||||
theme.SyntaxKeywordColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkKeyword),
|
||||
Light: lipgloss.Color(darkKeyword),
|
||||
}
|
||||
theme.SyntaxFunctionColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkFunc),
|
||||
Light: lipgloss.Color(darkFunc),
|
||||
}
|
||||
theme.SyntaxVariableColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkEntity),
|
||||
Light: lipgloss.Color(darkEntity),
|
||||
}
|
||||
theme.SyntaxStringColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkString),
|
||||
Light: lipgloss.Color(darkString),
|
||||
}
|
||||
theme.SyntaxNumberColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkConstant),
|
||||
Light: lipgloss.Color(darkConstant),
|
||||
}
|
||||
theme.SyntaxTypeColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkSpecial),
|
||||
Light: lipgloss.Color(darkSpecial),
|
||||
}
|
||||
theme.SyntaxOperatorColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkOperator),
|
||||
Light: lipgloss.Color(darkOperator),
|
||||
}
|
||||
theme.SyntaxPunctuationColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkFg),
|
||||
Light: lipgloss.Color(darkFg),
|
||||
}
|
||||
|
||||
return theme
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Register the Ayu theme with the theme manager
|
||||
RegisterTheme("ayu", NewAyuTheme())
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"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 {
|
||||
globalManager.mu.Lock()
|
||||
defer globalManager.mu.Unlock()
|
||||
// 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
|
||||
delete(styles.Registry, "charm")
|
||||
|
||||
if _, exists := globalManager.themes[name]; !exists {
|
||||
return fmt.Errorf("theme '%s' not found", name)
|
||||
|
||||
@@ -11,55 +11,19 @@ import (
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/components/core"
|
||||
"github.com/sst/opencode/internal/components/dialog"
|
||||
"github.com/sst/opencode/internal/components/modal"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/page"
|
||||
"github.com/sst/opencode/internal/state"
|
||||
"github.com/sst/opencode/internal/status"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
"github.com/sst/opencode/pkg/client"
|
||||
)
|
||||
|
||||
type keyMap struct {
|
||||
Help key.Binding
|
||||
NewSession key.Binding
|
||||
SwitchSession key.Binding
|
||||
SwitchModel key.Binding
|
||||
SwitchTheme key.Binding
|
||||
Quit key.Binding
|
||||
}
|
||||
|
||||
var keys = keyMap{
|
||||
Help: key.NewBinding(
|
||||
key.WithKeys("f1", "super+/", "super+h"),
|
||||
key.WithHelp("/help", "show help"),
|
||||
),
|
||||
NewSession: key.NewBinding(
|
||||
key.WithKeys("f2", "super+n"),
|
||||
key.WithHelp("/new", "new session"),
|
||||
),
|
||||
SwitchSession: key.NewBinding(
|
||||
key.WithKeys("f3", "super+s"),
|
||||
key.WithHelp("/sessions", "switch session"),
|
||||
),
|
||||
SwitchModel: key.NewBinding(
|
||||
key.WithKeys("f4", "super+m"),
|
||||
key.WithHelp("/model", "switch model"),
|
||||
),
|
||||
SwitchTheme: key.NewBinding(
|
||||
key.WithKeys("f5", "super+t"),
|
||||
key.WithHelp("/theme", "switch theme"),
|
||||
),
|
||||
Quit: key.NewBinding(
|
||||
key.WithKeys("f10", "ctrl+c", "super+q"),
|
||||
key.WithHelp("/quit", "quit"),
|
||||
),
|
||||
}
|
||||
|
||||
type appModel struct {
|
||||
width, height int
|
||||
currentPage page.PageID
|
||||
@@ -69,12 +33,13 @@ type appModel struct {
|
||||
status core.StatusComponent
|
||||
app *app.App
|
||||
modal layout.Modal
|
||||
commands []dialog.Command
|
||||
}
|
||||
|
||||
func (a appModel) Init() tea.Cmd {
|
||||
t := theme.CurrentTheme()
|
||||
var cmds []tea.Cmd
|
||||
cmds = append(cmds, a.app.InitializeProvider())
|
||||
|
||||
cmds = append(cmds, tea.SetBackgroundColor(t.Background()))
|
||||
cmds = append(cmds, tea.RequestBackgroundColor)
|
||||
|
||||
@@ -87,7 +52,7 @@ func (a appModel) Init() tea.Cmd {
|
||||
|
||||
// Check if we should show the init dialog
|
||||
cmds = append(cmds, func() tea.Msg {
|
||||
shouldShow := app.Info.Git && app.Info.Time.Initialized == nil
|
||||
shouldShow := a.app.Info.Git && a.app.Info.Time.Initialized == nil
|
||||
return dialog.ShowInitDialogMsg{Show: shouldShow}
|
||||
})
|
||||
|
||||
@@ -116,33 +81,46 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
if a.modal != nil {
|
||||
isModalTrigger := false
|
||||
bypassModal := false
|
||||
|
||||
if _, ok := msg.(modal.CloseModalMsg); ok {
|
||||
a.modal = nil
|
||||
return a, nil
|
||||
}
|
||||
|
||||
if msg, ok := msg.(tea.KeyMsg); ok {
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
a.modal = nil
|
||||
return a, nil
|
||||
case "ctrl+c":
|
||||
if _, ok := a.modal.(dialog.QuitDialog); !ok {
|
||||
quitDialog := dialog.NewQuitDialog()
|
||||
a.modal = quitDialog
|
||||
return a, nil
|
||||
}
|
||||
return a, tea.Quit
|
||||
}
|
||||
|
||||
isModalTrigger = key.Matches(msg, keys.NewSession) ||
|
||||
key.Matches(msg, keys.SwitchSession) ||
|
||||
key.Matches(msg, keys.SwitchModel) ||
|
||||
key.Matches(msg, keys.SwitchTheme) ||
|
||||
key.Matches(msg, keys.Help) ||
|
||||
key.Matches(msg, keys.Quit)
|
||||
// don't send commands to the modal
|
||||
for _, cmdDef := range a.app.Commands {
|
||||
if key.Matches(msg, cmdDef.KeyBinding) {
|
||||
bypassModal = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !isModalTrigger {
|
||||
// thanks i hate this
|
||||
switch msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
bypassModal = true
|
||||
case client.EventSessionUpdated:
|
||||
bypassModal = true
|
||||
case client.EventMessageUpdated:
|
||||
bypassModal = true
|
||||
case cursor.BlinkMsg:
|
||||
bypassModal = true
|
||||
case spinner.TickMsg:
|
||||
bypassModal = true
|
||||
}
|
||||
|
||||
if !bypassModal {
|
||||
updatedModal, cmd := a.modal.Update(msg)
|
||||
a.modal = updatedModal.(layout.Modal)
|
||||
return a, cmd
|
||||
@@ -150,6 +128,45 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case commands.ExecuteCommandMsg:
|
||||
switch msg.Name {
|
||||
case "quit":
|
||||
return a, tea.Quit
|
||||
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 "share":
|
||||
a.app.Client.PostSessionShareWithResponse(context.Background(), client.PostSessionShareJSONRequestBody{
|
||||
SessionID: a.app.Session.Id,
|
||||
})
|
||||
case "init":
|
||||
return a, a.app.InitializeProject(context.Background())
|
||||
// case "compact":
|
||||
// return a, a.app.CompactSession(context.Background())
|
||||
case "help":
|
||||
var helpBindings []key.Binding
|
||||
for _, cmd := range a.app.Commands {
|
||||
// Create a new binding for help display
|
||||
helpBindings = append(helpBindings, key.NewBinding(
|
||||
key.WithKeys(cmd.KeyBinding.Keys()...),
|
||||
key.WithHelp("/"+cmd.Name, cmd.Description),
|
||||
))
|
||||
}
|
||||
helpDialog := dialog.NewHelpDialog(helpBindings...)
|
||||
a.modal = helpDialog
|
||||
}
|
||||
slog.Info("Execute command", "cmds", cmds)
|
||||
return a, tea.Batch(cmds...)
|
||||
|
||||
case tea.BackgroundColorMsg:
|
||||
styles.Terminal = &styles.TerminalInfo{
|
||||
@@ -184,18 +201,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
msg.Height -= 2 // Make space for the status bar
|
||||
a.width, a.height = msg.Width, msg.Height
|
||||
|
||||
size := layout.LayoutSizeNormal
|
||||
if a.width < 40 {
|
||||
size = layout.LayoutSizeSmall
|
||||
} else if a.width < 80 {
|
||||
size = layout.LayoutSizeNormal
|
||||
} else {
|
||||
size = layout.LayoutSizeLarge
|
||||
}
|
||||
|
||||
// TODO: move away from global state
|
||||
layout.Current = &layout.LayoutInfo{
|
||||
Size: size,
|
||||
Viewport: layout.Dimensions{
|
||||
Width: a.width,
|
||||
Height: a.height,
|
||||
@@ -227,24 +234,6 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
return a, tea.Batch(cmds...)
|
||||
|
||||
// case pubsub.Event[permission.PermissionRequest]:
|
||||
// a.showPermissions = true
|
||||
// return a, a.permissions.SetPermissions(msg.Payload)
|
||||
|
||||
case dialog.PermissionResponseMsg:
|
||||
// TODO: Permissions service not implemented in API yet
|
||||
// var cmd tea.Cmd
|
||||
// switch msg.Action {
|
||||
// case dialog.PermissionAllow:
|
||||
// a.app.Permissions.Grant(context.Background(), msg.Permission)
|
||||
// case dialog.PermissionAllowForSession:
|
||||
// a.app.Permissions.GrantPersistant(context.Background(), msg.Permission)
|
||||
// case dialog.PermissionDeny:
|
||||
// a.app.Permissions.Deny(context.Background(), msg.Permission)
|
||||
// }
|
||||
// a.showPermissions = false
|
||||
return a, nil
|
||||
|
||||
case page.PageChangeMsg:
|
||||
return a, a.moveToPage(msg.ID)
|
||||
|
||||
@@ -271,12 +260,17 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
s, cmd := a.status.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
a.status = s.(core.StatusComponent)
|
||||
|
||||
t := theme.CurrentTheme()
|
||||
cmds = append(cmds, tea.SetBackgroundColor(t.Background()))
|
||||
return a, tea.Batch(cmds...)
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
// give the editor a chance to clear input
|
||||
case "ctrl+c":
|
||||
updated, cmd := a.pages[a.currentPage].Update(msg)
|
||||
a.pages[a.currentPage] = updated.(layout.ModelWithView)
|
||||
@@ -285,51 +279,16 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case key.Matches(msg, keys.Help):
|
||||
helpDialog := dialog.NewHelpDialog(
|
||||
keys.Help,
|
||||
keys.NewSession,
|
||||
keys.SwitchSession,
|
||||
keys.SwitchModel,
|
||||
keys.SwitchTheme,
|
||||
keys.Quit,
|
||||
)
|
||||
a.modal = helpDialog
|
||||
return a, nil
|
||||
|
||||
case key.Matches(msg, keys.NewSession):
|
||||
a.app.Session = &client.SessionInfo{}
|
||||
a.app.Messages = []client.MessageInfo{}
|
||||
return a, tea.Batch(
|
||||
util.CmdHandler(state.SessionClearedMsg{}),
|
||||
)
|
||||
|
||||
case key.Matches(msg, keys.SwitchModel):
|
||||
modelDialog := dialog.NewModelDialog(a.app)
|
||||
a.modal = modelDialog
|
||||
return a, nil
|
||||
|
||||
case key.Matches(msg, keys.SwitchSession):
|
||||
sessionDialog := dialog.NewSessionDialog(a.app)
|
||||
a.modal = sessionDialog
|
||||
return a, nil
|
||||
|
||||
case key.Matches(msg, keys.SwitchTheme):
|
||||
themeDialog := dialog.NewThemeDialog()
|
||||
a.modal = themeDialog
|
||||
return a, nil
|
||||
|
||||
case key.Matches(msg, keys.Quit):
|
||||
quitDialog := dialog.NewQuitDialog()
|
||||
a.modal = quitDialog
|
||||
return a, nil
|
||||
// First, check for modal triggers from the command registry
|
||||
if a.modal == nil {
|
||||
for _, cmdDef := range a.app.Commands {
|
||||
if key.Matches(msg, cmdDef.KeyBinding) {
|
||||
// If a key matches, send an ExecuteCommandMsg to self.
|
||||
// This unifies keybinding and slash command handling.
|
||||
return a, util.CmdHandler(commands.ExecuteCommandMsg{Name: cmdDef.Name})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
// f, filepickerCmd := a.filepicker.Update(msg)
|
||||
// a.filepicker = f.(dialog.FilepickerComponent)
|
||||
// cmds = append(cmds, filepickerCmd)
|
||||
}
|
||||
|
||||
// update status bar
|
||||
@@ -344,11 +303,6 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
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 {
|
||||
var cmds []tea.Cmd
|
||||
if _, ok := a.loadedPages[pageID]; !ok {
|
||||
@@ -387,47 +341,10 @@ func NewModel(app *app.App) tea.Model {
|
||||
loadedPages: make(map[page.PageID]bool),
|
||||
status: core.NewStatusCmp(app),
|
||||
app: app,
|
||||
commands: []dialog.Command{},
|
||||
pages: map[page.PageID]layout.ModelWithView{
|
||||
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
|
||||
}
|
||||
|
||||
@@ -439,6 +439,45 @@
|
||||
"parameters": [],
|
||||
"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": {
|
||||
@@ -618,7 +657,6 @@
|
||||
"role": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"system",
|
||||
"user",
|
||||
"assistant"
|
||||
]
|
||||
@@ -668,17 +706,65 @@
|
||||
},
|
||||
"tool": {
|
||||
"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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"system": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"modelID": {
|
||||
"type": "string"
|
||||
},
|
||||
"providerID": {
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cwd": {
|
||||
"type": "string"
|
||||
},
|
||||
"root": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"cwd",
|
||||
"root"
|
||||
]
|
||||
},
|
||||
"cost": {
|
||||
"type": "number"
|
||||
},
|
||||
@@ -696,18 +782,36 @@
|
||||
},
|
||||
"reasoning": {
|
||||
"type": "number"
|
||||
},
|
||||
"cache": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"read": {
|
||||
"type": "number"
|
||||
},
|
||||
"write": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"read",
|
||||
"write"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"input",
|
||||
"output",
|
||||
"reasoning"
|
||||
"reasoning",
|
||||
"cache"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"system",
|
||||
"modelID",
|
||||
"providerID",
|
||||
"path",
|
||||
"cost",
|
||||
"tokens"
|
||||
]
|
||||
@@ -715,7 +819,6 @@
|
||||
},
|
||||
"required": [
|
||||
"time",
|
||||
"error",
|
||||
"sessionID",
|
||||
"tool"
|
||||
]
|
||||
@@ -1036,10 +1139,18 @@
|
||||
"properties": {
|
||||
"part": {
|
||||
"$ref": "#/components/schemas/Message.Part"
|
||||
},
|
||||
"sessionID": {
|
||||
"type": "string"
|
||||
},
|
||||
"messageID": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"part"
|
||||
"part",
|
||||
"sessionID",
|
||||
"messageID"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -1079,6 +1190,10 @@
|
||||
"type": "string",
|
||||
"pattern": "^ses"
|
||||
},
|
||||
"parentID": {
|
||||
"type": "string",
|
||||
"pattern": "^ses"
|
||||
},
|
||||
"share": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1146,10 +1261,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"error"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -1220,31 +1332,35 @@
|
||||
"Provider.Info": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"env": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"models": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/components/schemas/Provider.Model"
|
||||
"$ref": "#/components/schemas/Model.Info"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"env",
|
||||
"id",
|
||||
"models"
|
||||
]
|
||||
},
|
||||
"Provider.Model": {
|
||||
"Model.Info": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1254,16 +1370,19 @@
|
||||
"reasoning": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"temperature": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"cost": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"input": {
|
||||
"type": "number"
|
||||
},
|
||||
"inputCached": {
|
||||
"output": {
|
||||
"type": "number"
|
||||
},
|
||||
"output": {
|
||||
"inputCached": {
|
||||
"type": "number"
|
||||
},
|
||||
"outputCached": {
|
||||
@@ -1272,8 +1391,8 @@
|
||||
},
|
||||
"required": [
|
||||
"input",
|
||||
"inputCached",
|
||||
"output",
|
||||
"inputCached",
|
||||
"outputCached"
|
||||
]
|
||||
},
|
||||
@@ -1291,13 +1410,19 @@
|
||||
"context",
|
||||
"output"
|
||||
]
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"attachment",
|
||||
"reasoning",
|
||||
"temperature",
|
||||
"cost",
|
||||
"limit"
|
||||
"limit",
|
||||
"id"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
// Defines values for MessageInfoRole.
|
||||
const (
|
||||
Assistant MessageInfoRole = "assistant"
|
||||
System MessageInfoRole = "system"
|
||||
User MessageInfoRole = "user"
|
||||
)
|
||||
|
||||
@@ -61,7 +60,9 @@ type EventLspClientDiagnostics struct {
|
||||
// EventMessagePartUpdated defines model for Event.message.part.updated.
|
||||
type EventMessagePartUpdated struct {
|
||||
Properties struct {
|
||||
Part MessagePart `json:"part"`
|
||||
MessageID string `json:"messageID"`
|
||||
Part MessagePart `json:"part"`
|
||||
SessionID string `json:"sessionID"`
|
||||
} `json:"properties"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
@@ -83,7 +84,7 @@ type EventPermissionUpdated struct {
|
||||
// EventSessionError defines model for Event.session.error.
|
||||
type EventSessionError struct {
|
||||
Properties struct {
|
||||
Error EventSessionError_Properties_Error `json:"error"`
|
||||
Error *EventSessionError_Properties_Error `json:"error,omitempty"`
|
||||
} `json:"properties"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
@@ -115,23 +116,32 @@ type MessageInfo struct {
|
||||
Id string `json:"id"`
|
||||
Metadata struct {
|
||||
Assistant *struct {
|
||||
Cost float32 `json:"cost"`
|
||||
ModelID string `json:"modelID"`
|
||||
ProviderID string `json:"providerID"`
|
||||
Summary *bool `json:"summary,omitempty"`
|
||||
Cost float32 `json:"cost"`
|
||||
ModelID string `json:"modelID"`
|
||||
Path struct {
|
||||
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 {
|
||||
Cache struct {
|
||||
Read float32 `json:"read"`
|
||||
Write float32 `json:"write"`
|
||||
} `json:"cache"`
|
||||
Input float32 `json:"input"`
|
||||
Output float32 `json:"output"`
|
||||
Reasoning float32 `json:"reasoning"`
|
||||
} `json:"tokens"`
|
||||
} `json:"assistant,omitempty"`
|
||||
Error MessageInfo_Metadata_Error `json:"error"`
|
||||
SessionID string `json:"sessionID"`
|
||||
Error *MessageInfo_Metadata_Error `json:"error,omitempty"`
|
||||
SessionID string `json:"sessionID"`
|
||||
Time struct {
|
||||
Completed *float32 `json:"completed,omitempty"`
|
||||
Created float32 `json:"created"`
|
||||
} `json:"time"`
|
||||
Tool map[string]interface{} `json:"tool"`
|
||||
Tool map[string]MessageInfo_Metadata_Tool_AdditionalProperties `json:"tool"`
|
||||
} `json:"metadata"`
|
||||
Parts []MessagePart `json:"parts"`
|
||||
Role MessageInfoRole `json:"role"`
|
||||
@@ -142,6 +152,16 @@ type MessageInfo_Metadata_Error struct {
|
||||
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.
|
||||
type MessageInfoRole string
|
||||
|
||||
@@ -224,15 +244,8 @@ type MessageToolInvocationToolResult struct {
|
||||
ToolName string `json:"toolName"`
|
||||
}
|
||||
|
||||
// ProviderInfo defines model for Provider.Info.
|
||||
type ProviderInfo struct {
|
||||
Id string `json:"id"`
|
||||
Models map[string]ProviderModel `json:"models"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// ProviderModel defines model for Provider.Model.
|
||||
type ProviderModel struct {
|
||||
// ModelInfo defines model for Model.Info.
|
||||
type ModelInfo struct {
|
||||
Attachment bool `json:"attachment"`
|
||||
Cost struct {
|
||||
Input float32 `json:"input"`
|
||||
@@ -245,8 +258,17 @@ type ProviderModel struct {
|
||||
Context float32 `json:"context"`
|
||||
Output float32 `json:"output"`
|
||||
} `json:"limit"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Reasoning *bool `json:"reasoning,omitempty"`
|
||||
Name string `json:"name"`
|
||||
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.
|
||||
@@ -279,8 +301,9 @@ type PermissionInfo struct {
|
||||
|
||||
// SessionInfo defines model for session.info.
|
||||
type SessionInfo struct {
|
||||
Id string `json:"id"`
|
||||
Share *struct {
|
||||
Id string `json:"id"`
|
||||
ParentID *string `json:"parentID,omitempty"`
|
||||
Share *struct {
|
||||
Secret string `json:"secret"`
|
||||
Url string `json:"url"`
|
||||
} `json:"share,omitempty"`
|
||||
@@ -291,6 +314,11 @@ type SessionInfo struct {
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// PostFileSearchJSONBody defines parameters for PostFileSearch.
|
||||
type PostFileSearchJSONBody struct {
|
||||
Query string `json:"query"`
|
||||
}
|
||||
|
||||
// PostSessionAbortJSONBody defines parameters for PostSessionAbort.
|
||||
type PostSessionAbortJSONBody struct {
|
||||
SessionID string `json:"sessionID"`
|
||||
@@ -328,6 +356,9 @@ type PostSessionSummarizeJSONBody struct {
|
||||
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.
|
||||
type PostSessionAbortJSONRequestBody PostSessionAbortJSONBody
|
||||
|
||||
@@ -346,6 +377,85 @@ type PostSessionShareJSONRequestBody PostSessionShareJSONBody
|
||||
// PostSessionSummarizeJSONRequestBody defines body for PostSessionSummarize for application/json ContentType.
|
||||
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
|
||||
func (t Event) AsEventStorageWrite() (EventStorageWrite, error) {
|
||||
var body EventStorageWrite
|
||||
@@ -1173,6 +1283,11 @@ type ClientInterface interface {
|
||||
// GetEvent request
|
||||
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(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
@@ -1252,6 +1367,30 @@ func (c *Client) GetEvent(ctx context.Context, reqEditors ...RequestEditorFn) (*
|
||||
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) {
|
||||
req, err := NewPostPathGetRequest(c.Server)
|
||||
if err != nil {
|
||||
@@ -1525,6 +1664,46 @@ func NewGetEventRequest(server string) (*http.Request, error) {
|
||||
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
|
||||
func NewPostPathGetRequest(server string) (*http.Request, error) {
|
||||
var err error
|
||||
@@ -1925,6 +2104,11 @@ type ClientWithResponsesInterface interface {
|
||||
// GetEventWithResponse request
|
||||
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(ctx context.Context, reqEditors ...RequestEditorFn) (*PostPathGetResponse, error)
|
||||
|
||||
@@ -2034,6 +2218,28 @@ func (r GetEventResponse) StatusCode() int {
|
||||
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 {
|
||||
Body []byte
|
||||
HTTPResponse *http.Response
|
||||
@@ -2290,6 +2496,23 @@ func (c *ClientWithResponses) GetEventWithResponse(ctx context.Context, reqEdito
|
||||
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
|
||||
func (c *ClientWithResponses) PostPathGetWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*PostPathGetResponse, error) {
|
||||
rsp, err := c.PostPathGet(ctx, reqEditors...)
|
||||
@@ -2506,6 +2729,32 @@ func ParseGetEventResponse(rsp *http.Response) (*GetEventResponse, error) {
|
||||
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
|
||||
func ParsePostPathGetResponse(rsp *http.Response) (*PostPathGetResponse, error) {
|
||||
bodyBytes, err := io.ReadAll(rsp.Body)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<svg width="29" height="29" viewBox="0 0 29 29" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.184 28.512C12.144 28.512 10.26 28.152 8.532 27.432C6.804 26.736 5.304 25.752 4.032 24.48C2.76 23.184 1.764 21.672 1.044 19.944C0.348 18.216 0 16.332 0 14.292C0 12.228 0.36 10.332 1.08 8.604C1.8 6.852 2.796 5.34 4.068 4.068C5.364 2.772 6.876 1.776 8.604 1.08C10.332 0.36 12.192 0 14.184 0C16.224 0 18.108 0.36 19.836 1.08C21.588 1.8 23.112 2.808 24.408 4.104C25.704 5.4 26.712 6.924 27.432 8.676C28.152 10.404 28.512 12.276 28.512 14.292C28.512 16.284 28.152 18.144 27.432 19.872C26.736 21.6 25.74 23.112 24.444 24.408C23.172 25.704 21.66 26.712 19.908 27.432C18.18 28.152 16.272 28.512 14.184 28.512ZM13.104 25.74V2.772L11.016 3.168V25.416L13.104 25.74ZM15.3 25.74L17.388 25.416V3.168L15.3 2.772V25.74ZM8.82 24.516V3.996C8.076 4.404 7.392 4.884 6.768 5.436V23.148C7.104 23.364 7.44 23.592 7.776 23.832C8.136 24.048 8.484 24.276 8.82 24.516ZM19.548 24.552L21.636 23.148V5.364L19.548 3.996V24.552ZM23.868 20.736C25.14 18.888 25.776 16.74 25.776 14.292C25.776 11.964 25.14 9.816 23.868 7.848V20.736ZM4.608 20.736V7.884C3.384 9.756 2.772 11.892 2.772 14.292C2.772 15.468 2.916 16.596 3.204 17.676C3.516 18.732 3.984 19.752 4.608 20.736Z" fill="black"/>
|
||||
<svg width="33" height="42" viewBox="0 0 33 42" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.5 0H32.5V41.5955H0.5V0ZM24.5 8.5H8.5V33H24.5V8.5Z" fill="black"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 221 B |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 17 KiB |
@@ -1,11 +1,12 @@
|
||||
<svg width="187" height="32" viewBox="0 0 187 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.184 28.512C12.144 28.512 10.26 28.152 8.532 27.432C6.804 26.736 5.304 25.752 4.032 24.48C2.76 23.184 1.764 21.672 1.044 19.944C0.348 18.216 0 16.332 0 14.292C0 12.228 0.36 10.332 1.08 8.604C1.8 6.852 2.796 5.34 4.068 4.068C5.364 2.772 6.876 1.776 8.604 1.08C10.332 0.36 12.192 0 14.184 0C16.224 0 18.108 0.36 19.836 1.08C21.588 1.8 23.112 2.808 24.408 4.104C25.704 5.4 26.712 6.924 27.432 8.676C28.152 10.404 28.512 12.276 28.512 14.292C28.512 16.284 28.152 18.144 27.432 19.872C26.736 21.6 25.74 23.112 24.444 24.408C23.172 25.704 21.66 26.712 19.908 27.432C18.18 28.152 16.272 28.512 14.184 28.512ZM13.104 25.74V2.772L11.016 3.168V25.416L13.104 25.74ZM15.3 25.74L17.388 25.416V3.168L15.3 2.772V25.74ZM8.82 24.516V3.996C8.076 4.404 7.392 4.884 6.768 5.436V23.148C7.104 23.364 7.44 23.592 7.776 23.832C8.136 24.048 8.484 24.276 8.82 24.516ZM19.548 24.552L21.636 23.148V5.364L19.548 3.996V24.552ZM23.868 20.736C25.14 18.888 25.776 16.74 25.776 14.292C25.776 11.964 25.14 9.816 23.868 7.848V20.736ZM4.608 20.736V7.884C3.384 9.756 2.772 11.892 2.772 14.292C2.772 15.468 2.916 16.596 3.204 17.676C3.516 18.732 3.984 19.752 4.608 20.736Z" fill="white"/>
|
||||
<path d="M45.628 25.432C42.556 25.432 40.252 24.688 38.716 23.2C37.204 21.688 36.448 19.552 36.448 16.792V9.62799C36.448 6.84399 37.204 4.70799 38.716 3.21999C40.252 1.73199 42.556 0.987986 45.628 0.987986C48.7 0.987986 50.992 1.73199 52.504 3.21999C54.04 4.70799 54.808 6.84399 54.808 9.62799V16.792C54.808 19.552 54.04 21.688 52.504 23.2C50.992 24.688 48.7 25.432 45.628 25.432ZM45.628 22.228C47.452 22.228 48.832 21.76 49.768 20.824C50.704 19.864 51.172 18.484 51.172 16.684V9.73599C51.172 7.91199 50.704 6.53199 49.768 5.59599C48.832 4.65999 47.452 4.19199 45.628 4.19199C43.828 4.19199 42.448 4.65999 41.488 5.59599C40.552 6.53199 40.084 7.91199 40.084 9.73599V16.684C40.084 18.484 40.552 19.864 41.488 20.824C42.448 21.76 43.828 22.228 45.628 22.228Z" fill="white"/>
|
||||
<path d="M67.0294 25.432C66.1654 25.432 65.2414 25.312 64.2574 25.072C63.2734 24.856 62.4694 24.544 61.8454 24.136L61.7734 21.184C62.4214 21.544 63.1414 21.832 63.9334 22.048C64.7254 22.24 65.4574 22.336 66.1294 22.336C67.4254 22.336 68.3614 21.964 68.9374 21.22C69.5374 20.452 69.8374 19.312 69.8374 17.8V13.912C69.8374 12.592 69.6094 11.632 69.1534 11.032C68.6974 10.408 68.0014 10.096 67.0654 10.096C66.2734 10.096 65.4694 10.336 64.6534 10.816C63.8374 11.272 62.8174 12.064 61.5934 13.192L61.4854 10.168C62.2534 9.44799 62.9854 8.84799 63.6814 8.36799C64.3774 7.88799 65.0854 7.52799 65.8054 7.28799C66.5254 7.04799 67.2934 6.92799 68.1094 6.92799C69.8614 6.92799 71.1814 7.46799 72.0694 8.54799C72.9814 9.62799 73.4374 11.272 73.4374 13.48V18.196C73.4374 20.572 72.8854 22.372 71.7814 23.596C70.7014 24.82 69.1174 25.432 67.0294 25.432ZM59.1094 31.66C58.8454 31.66 58.7134 31.528 58.7134 31.264V11.536C58.7134 10.936 58.6894 10.3 58.6414 9.62799C58.5934 8.95599 58.5334 8.35599 58.4614 7.82799C58.4374 7.51599 58.5694 7.35999 58.8574 7.35999H61.4134C61.6774 7.35999 61.8214 7.47999 61.8454 7.71999C61.8934 7.91199 61.9414 8.16399 61.9894 8.47599C62.0374 8.78799 62.0734 9.09999 62.0974 9.41199C62.1214 9.69999 62.1334 9.91599 62.1334 10.06L62.3134 11.968V31.264C62.3134 31.528 62.1814 31.66 61.9174 31.66H59.1094Z" fill="white"/>
|
||||
<path d="M84.0237 25.432C81.5757 25.432 79.7157 24.88 78.4437 23.776C77.1957 22.672 76.5717 21.028 76.5717 18.844V14.056C76.5717 11.728 77.1957 9.96399 78.4437 8.76399C79.6917 7.53999 81.5397 6.92799 83.9877 6.92799C86.4117 6.92799 88.2477 7.51599 89.4957 8.69199C90.7437 9.86799 91.3677 11.584 91.3677 13.84V16.648C91.3677 16.912 91.2477 17.044 91.0077 17.044H80.1717V18.196C80.1717 19.66 80.4837 20.728 81.1077 21.4C81.7317 22.072 82.7397 22.408 84.1317 22.408C85.1877 22.408 86.0037 22.24 86.5797 21.904C87.1557 21.568 87.4437 21.052 87.4437 20.356C87.4437 20.092 87.5877 19.96 87.8757 19.96H90.6477C90.8637 19.96 90.9957 20.08 91.0437 20.32C91.1397 21.928 90.5637 23.188 89.3157 24.1C88.0677 24.988 86.3037 25.432 84.0237 25.432ZM80.1717 14.308H87.8037V14.128C87.8037 12.688 87.4917 11.632 86.8677 10.96C86.2437 10.288 85.2957 9.95199 84.0237 9.95199C82.7277 9.95199 81.7557 10.3 81.1077 10.996C80.4837 11.692 80.1717 12.736 80.1717 14.128V14.308Z" fill="white"/>
|
||||
<path d="M106.647 25C106.383 25 106.251 24.868 106.251 24.604V13.3C106.251 12.196 106.023 11.392 105.567 10.888C105.135 10.36 104.451 10.096 103.515 10.096C102.627 10.096 101.751 10.36 100.887 10.888C100.023 11.392 98.9906 12.244 97.7906 13.444L97.7186 10.528C98.4866 9.71199 99.2306 9.03999 99.9506 8.51199C100.671 7.98399 101.403 7.58799 102.147 7.32399C102.915 7.05999 103.719 6.92799 104.559 6.92799C106.287 6.92799 107.595 7.43199 108.483 8.43999C109.395 9.42399 109.851 10.912 109.851 12.904V24.604C109.851 24.868 109.731 25 109.491 25H106.647ZM95.2346 25C94.9706 25 94.8386 24.868 94.8386 24.604V11.824C94.8386 11.152 94.8026 10.444 94.7306 9.69999C94.6826 8.93199 94.6346 8.31999 94.5866 7.86399C94.5386 7.52799 94.6706 7.35999 94.9826 7.35999H97.5386C97.7786 7.35999 97.9226 7.47999 97.9706 7.71999C98.0186 7.93599 98.0666 8.23599 98.1146 8.61999C98.1626 9.00399 98.2106 9.41199 98.2586 9.84399C98.3066 10.276 98.3306 10.648 98.3306 10.96L98.4386 12.112V24.604C98.4386 24.868 98.3066 25 98.0426 25H95.2346Z" fill="white"/>
|
||||
<path d="M122.87 25.432C119.894 25.432 117.626 24.7 116.066 23.236C114.506 21.748 113.726 19.6 113.726 16.792V9.66399C113.726 6.83199 114.506 4.68399 116.066 3.21999C117.65 1.73199 119.93 0.987986 122.906 0.987986C124.85 0.987986 126.542 1.31199 127.982 1.95999C129.422 2.60799 130.49 3.54399 131.186 4.76799C131.906 5.96799 132.146 7.40799 131.906 9.08799C131.882 9.23199 131.846 9.35199 131.798 9.44799C131.75 9.54399 131.654 9.59199 131.51 9.59199H128.63C128.366 9.59199 128.246 9.45999 128.27 9.19599C128.342 7.61199 127.922 6.37599 127.01 5.48799C126.098 4.59999 124.742 4.15599 122.942 4.15599C121.142 4.15599 119.762 4.62399 118.802 5.55999C117.842 6.49599 117.362 7.86399 117.362 9.66399V16.756C117.362 18.556 117.842 19.924 118.802 20.86C119.762 21.796 121.142 22.264 122.942 22.264C124.766 22.264 126.146 21.82 127.082 20.932C128.042 20.02 128.438 18.784 128.27 17.224C128.246 16.96 128.366 16.828 128.63 16.828H131.474C131.714 16.828 131.858 16.996 131.906 17.332C132.05 18.964 131.762 20.392 131.042 21.616C130.346 22.816 129.29 23.752 127.874 24.424C126.458 25.096 124.79 25.432 122.87 25.432Z" fill="white"/>
|
||||
<path d="M142.558 25.432C140.086 25.432 138.19 24.832 136.87 23.632C135.55 22.432 134.89 20.62 134.89 18.196V14.164C134.89 11.74 135.538 9.92799 136.834 8.72799C138.154 7.52799 140.062 6.92799 142.558 6.92799C145.03 6.92799 146.914 7.52799 148.21 8.72799C149.53 9.92799 150.19 11.74 150.19 14.164V18.196C150.19 20.62 149.542 22.432 148.246 23.632C146.95 24.832 145.054 25.432 142.558 25.432ZM142.558 22.3C143.974 22.3 144.994 21.94 145.618 21.22C146.266 20.5 146.59 19.384 146.59 17.872V14.488C146.59 13 146.266 11.896 145.618 11.176C144.994 10.432 143.974 10.06 142.558 10.06C141.118 10.06 140.074 10.432 139.426 11.176C138.802 11.896 138.49 13 138.49 14.488V17.872C138.49 19.384 138.802 20.5 139.426 21.22C140.074 21.94 141.118 22.3 142.558 22.3Z" fill="white"/>
|
||||
<path d="M158.473 25.432C156.793 25.432 155.509 24.892 154.621 23.812C153.733 22.708 153.289 21.064 153.289 18.88V14.2C153.289 11.848 153.853 10.048 154.981 8.79999C156.133 7.55199 157.729 6.92799 159.769 6.92799C160.657 6.92799 161.605 7.04799 162.613 7.28799C163.645 7.52799 164.449 7.83999 165.025 8.22399L165.169 11.212C164.449 10.828 163.657 10.54 162.793 10.348C161.953 10.132 161.209 10.024 160.561 10.024C159.337 10.024 158.413 10.408 157.789 11.176C157.189 11.92 156.889 13.048 156.889 14.56V18.772C156.889 19.948 157.117 20.836 157.573 21.436C158.029 22.012 158.713 22.3 159.625 22.3C160.201 22.3 160.765 22.192 161.317 21.976C161.869 21.736 162.469 21.376 163.117 20.896C163.765 20.392 164.497 19.72 165.313 18.88L165.349 21.832C164.581 22.6 163.837 23.26 163.117 23.812C162.397 24.34 161.665 24.736 160.921 25C160.177 25.288 159.361 25.432 158.473 25.432ZM165.601 25C165.313 25 165.157 24.868 165.133 24.604C165.085 24.244 165.025 23.8 164.953 23.272C164.881 22.72 164.857 22.252 164.881 21.868L164.629 20.104V0.807986C164.629 0.543987 164.761 0.411987 165.025 0.411987H167.833C168.097 0.411987 168.229 0.543987 168.229 0.807986V20.824C168.229 21.184 168.241 21.604 168.265 22.084C168.289 22.54 168.313 22.984 168.337 23.416C168.385 23.848 168.421 24.208 168.445 24.496C168.517 24.832 168.397 25 168.085 25H165.601Z" fill="white"/>
|
||||
<path d="M179.171 25.432C176.723 25.432 174.863 24.88 173.591 23.776C172.343 22.672 171.719 21.028 171.719 18.844V14.056C171.719 11.728 172.343 9.96399 173.591 8.76399C174.839 7.53999 176.687 6.92799 179.135 6.92799C181.559 6.92799 183.395 7.51599 184.643 8.69199C185.891 9.86799 186.515 11.584 186.515 13.84V16.648C186.515 16.912 186.395 17.044 186.155 17.044H175.319V18.196C175.319 19.66 175.631 20.728 176.255 21.4C176.879 22.072 177.887 22.408 179.279 22.408C180.335 22.408 181.151 22.24 181.727 21.904C182.303 21.568 182.591 21.052 182.591 20.356C182.591 20.092 182.735 19.96 183.023 19.96H185.795C186.011 19.96 186.143 20.08 186.191 20.32C186.287 21.928 185.711 23.188 184.463 24.1C183.215 24.988 181.451 25.432 179.171 25.432ZM175.319 14.308H182.951V14.128C182.951 12.688 182.639 11.632 182.015 10.96C181.391 10.288 180.443 9.95199 179.171 9.95199C177.875 9.95199 176.903 10.3 176.255 10.996C175.631 11.692 175.319 12.736 175.319 14.128V14.308Z" fill="white"/>
|
||||
<svg width="289" height="50" viewBox="0 0 289 50" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M264.5 0H288.5V8.5H272.5V16.5H288.5V25H272.5V33H288.5V41.5H264.5V0Z" fill="white"/>
|
||||
<path d="M248.5 0H224.5V41.5H248.5V33H232.5V8.5H248.5V0Z" fill="white"/>
|
||||
<path d="M256.5 8.5H248.5V33H256.5V8.5Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M184.5 0H216.5V41.5H184.5V0ZM208.5 8.5H192.5V33H208.5V8.5Z" fill="white"/>
|
||||
<path d="M144.5 8.5H136.5V41.5H144.5V8.5Z" fill="white"/>
|
||||
<path d="M136.5 0H112.5V41.5H120.5V8.5H136.5V0Z" fill="white"/>
|
||||
<path d="M80.5 0H104.5V8.5H88.5V16.5H104.5V25H88.5V33H104.5V41.5H80.5V0Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M40.5 0H72.5V41.5H48.5V49.5H40.5V0ZM64.5 8.5H48.5V33H64.5V8.5Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.5 0H32.5V41.5955H0.5V0ZM24.5 8.5H8.5V33H24.5V8.5Z" fill="white"/>
|
||||
<path d="M152.5 0H176.5V8.5H160.5V33H176.5V41.5H152.5V0Z" fill="white"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 981 B |
@@ -1,11 +1,12 @@
|
||||
<svg width="187" height="32" viewBox="0 0 187 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.184 28.512C12.144 28.512 10.26 28.152 8.532 27.432C6.804 26.736 5.304 25.752 4.032 24.48C2.76 23.184 1.764 21.672 1.044 19.944C0.348 18.216 0 16.332 0 14.292C0 12.228 0.36 10.332 1.08 8.604C1.8 6.852 2.796 5.34 4.068 4.068C5.364 2.772 6.876 1.776 8.604 1.08C10.332 0.36 12.192 0 14.184 0C16.224 0 18.108 0.36 19.836 1.08C21.588 1.8 23.112 2.808 24.408 4.104C25.704 5.4 26.712 6.924 27.432 8.676C28.152 10.404 28.512 12.276 28.512 14.292C28.512 16.284 28.152 18.144 27.432 19.872C26.736 21.6 25.74 23.112 24.444 24.408C23.172 25.704 21.66 26.712 19.908 27.432C18.18 28.152 16.272 28.512 14.184 28.512ZM13.104 25.74V2.772L11.016 3.168V25.416L13.104 25.74ZM15.3 25.74L17.388 25.416V3.168L15.3 2.772V25.74ZM8.82 24.516V3.996C8.076 4.404 7.392 4.884 6.768 5.436V23.148C7.104 23.364 7.44 23.592 7.776 23.832C8.136 24.048 8.484 24.276 8.82 24.516ZM19.548 24.552L21.636 23.148V5.364L19.548 3.996V24.552ZM23.868 20.736C25.14 18.888 25.776 16.74 25.776 14.292C25.776 11.964 25.14 9.816 23.868 7.848V20.736ZM4.608 20.736V7.884C3.384 9.756 2.772 11.892 2.772 14.292C2.772 15.468 2.916 16.596 3.204 17.676C3.516 18.732 3.984 19.752 4.608 20.736Z" fill="black"/>
|
||||
<path d="M45.628 25.432C42.556 25.432 40.252 24.688 38.716 23.2C37.204 21.688 36.448 19.552 36.448 16.792V9.62799C36.448 6.84399 37.204 4.70799 38.716 3.21999C40.252 1.73199 42.556 0.987986 45.628 0.987986C48.7 0.987986 50.992 1.73199 52.504 3.21999C54.04 4.70799 54.808 6.84399 54.808 9.62799V16.792C54.808 19.552 54.04 21.688 52.504 23.2C50.992 24.688 48.7 25.432 45.628 25.432ZM45.628 22.228C47.452 22.228 48.832 21.76 49.768 20.824C50.704 19.864 51.172 18.484 51.172 16.684V9.73599C51.172 7.91199 50.704 6.53199 49.768 5.59599C48.832 4.65999 47.452 4.19199 45.628 4.19199C43.828 4.19199 42.448 4.65999 41.488 5.59599C40.552 6.53199 40.084 7.91199 40.084 9.73599V16.684C40.084 18.484 40.552 19.864 41.488 20.824C42.448 21.76 43.828 22.228 45.628 22.228Z" fill="black"/>
|
||||
<path d="M67.0294 25.432C66.1654 25.432 65.2414 25.312 64.2574 25.072C63.2734 24.856 62.4694 24.544 61.8454 24.136L61.7734 21.184C62.4214 21.544 63.1414 21.832 63.9334 22.048C64.7254 22.24 65.4574 22.336 66.1294 22.336C67.4254 22.336 68.3614 21.964 68.9374 21.22C69.5374 20.452 69.8374 19.312 69.8374 17.8V13.912C69.8374 12.592 69.6094 11.632 69.1534 11.032C68.6974 10.408 68.0014 10.096 67.0654 10.096C66.2734 10.096 65.4694 10.336 64.6534 10.816C63.8374 11.272 62.8174 12.064 61.5934 13.192L61.4854 10.168C62.2534 9.44799 62.9854 8.84799 63.6814 8.36799C64.3774 7.88799 65.0854 7.52799 65.8054 7.28799C66.5254 7.04799 67.2934 6.92799 68.1094 6.92799C69.8614 6.92799 71.1814 7.46799 72.0694 8.54799C72.9814 9.62799 73.4374 11.272 73.4374 13.48V18.196C73.4374 20.572 72.8854 22.372 71.7814 23.596C70.7014 24.82 69.1174 25.432 67.0294 25.432ZM59.1094 31.66C58.8454 31.66 58.7134 31.528 58.7134 31.264V11.536C58.7134 10.936 58.6894 10.3 58.6414 9.62799C58.5934 8.95599 58.5334 8.35599 58.4614 7.82799C58.4374 7.51599 58.5694 7.35999 58.8574 7.35999H61.4134C61.6774 7.35999 61.8214 7.47999 61.8454 7.71999C61.8934 7.91199 61.9414 8.16399 61.9894 8.47599C62.0374 8.78799 62.0734 9.09999 62.0974 9.41199C62.1214 9.69999 62.1334 9.91599 62.1334 10.06L62.3134 11.968V31.264C62.3134 31.528 62.1814 31.66 61.9174 31.66H59.1094Z" fill="black"/>
|
||||
<path d="M84.0237 25.432C81.5757 25.432 79.7157 24.88 78.4437 23.776C77.1957 22.672 76.5717 21.028 76.5717 18.844V14.056C76.5717 11.728 77.1957 9.96399 78.4437 8.76399C79.6917 7.53999 81.5397 6.92799 83.9877 6.92799C86.4117 6.92799 88.2477 7.51599 89.4957 8.69199C90.7437 9.86799 91.3677 11.584 91.3677 13.84V16.648C91.3677 16.912 91.2477 17.044 91.0077 17.044H80.1717V18.196C80.1717 19.66 80.4837 20.728 81.1077 21.4C81.7317 22.072 82.7397 22.408 84.1317 22.408C85.1877 22.408 86.0037 22.24 86.5797 21.904C87.1557 21.568 87.4437 21.052 87.4437 20.356C87.4437 20.092 87.5877 19.96 87.8757 19.96H90.6477C90.8637 19.96 90.9957 20.08 91.0437 20.32C91.1397 21.928 90.5637 23.188 89.3157 24.1C88.0677 24.988 86.3037 25.432 84.0237 25.432ZM80.1717 14.308H87.8037V14.128C87.8037 12.688 87.4917 11.632 86.8677 10.96C86.2437 10.288 85.2957 9.95199 84.0237 9.95199C82.7277 9.95199 81.7557 10.3 81.1077 10.996C80.4837 11.692 80.1717 12.736 80.1717 14.128V14.308Z" fill="black"/>
|
||||
<path d="M106.647 25C106.383 25 106.251 24.868 106.251 24.604V13.3C106.251 12.196 106.023 11.392 105.567 10.888C105.135 10.36 104.451 10.096 103.515 10.096C102.627 10.096 101.751 10.36 100.887 10.888C100.023 11.392 98.9906 12.244 97.7906 13.444L97.7186 10.528C98.4866 9.71199 99.2306 9.03999 99.9506 8.51199C100.671 7.98399 101.403 7.58799 102.147 7.32399C102.915 7.05999 103.719 6.92799 104.559 6.92799C106.287 6.92799 107.595 7.43199 108.483 8.43999C109.395 9.42399 109.851 10.912 109.851 12.904V24.604C109.851 24.868 109.731 25 109.491 25H106.647ZM95.2346 25C94.9706 25 94.8386 24.868 94.8386 24.604V11.824C94.8386 11.152 94.8026 10.444 94.7306 9.69999C94.6826 8.93199 94.6346 8.31999 94.5866 7.86399C94.5386 7.52799 94.6706 7.35999 94.9826 7.35999H97.5386C97.7786 7.35999 97.9226 7.47999 97.9706 7.71999C98.0186 7.93599 98.0666 8.23599 98.1146 8.61999C98.1626 9.00399 98.2106 9.41199 98.2586 9.84399C98.3066 10.276 98.3306 10.648 98.3306 10.96L98.4386 12.112V24.604C98.4386 24.868 98.3066 25 98.0426 25H95.2346Z" fill="black"/>
|
||||
<path d="M122.87 25.432C119.894 25.432 117.626 24.7 116.066 23.236C114.506 21.748 113.726 19.6 113.726 16.792V9.66399C113.726 6.83199 114.506 4.68399 116.066 3.21999C117.65 1.73199 119.93 0.987986 122.906 0.987986C124.85 0.987986 126.542 1.31199 127.982 1.95999C129.422 2.60799 130.49 3.54399 131.186 4.76799C131.906 5.96799 132.146 7.40799 131.906 9.08799C131.882 9.23199 131.846 9.35199 131.798 9.44799C131.75 9.54399 131.654 9.59199 131.51 9.59199H128.63C128.366 9.59199 128.246 9.45999 128.27 9.19599C128.342 7.61199 127.922 6.37599 127.01 5.48799C126.098 4.59999 124.742 4.15599 122.942 4.15599C121.142 4.15599 119.762 4.62399 118.802 5.55999C117.842 6.49599 117.362 7.86399 117.362 9.66399V16.756C117.362 18.556 117.842 19.924 118.802 20.86C119.762 21.796 121.142 22.264 122.942 22.264C124.766 22.264 126.146 21.82 127.082 20.932C128.042 20.02 128.438 18.784 128.27 17.224C128.246 16.96 128.366 16.828 128.63 16.828H131.474C131.714 16.828 131.858 16.996 131.906 17.332C132.05 18.964 131.762 20.392 131.042 21.616C130.346 22.816 129.29 23.752 127.874 24.424C126.458 25.096 124.79 25.432 122.87 25.432Z" fill="black"/>
|
||||
<path d="M142.558 25.432C140.086 25.432 138.19 24.832 136.87 23.632C135.55 22.432 134.89 20.62 134.89 18.196V14.164C134.89 11.74 135.538 9.92799 136.834 8.72799C138.154 7.52799 140.062 6.92799 142.558 6.92799C145.03 6.92799 146.914 7.52799 148.21 8.72799C149.53 9.92799 150.19 11.74 150.19 14.164V18.196C150.19 20.62 149.542 22.432 148.246 23.632C146.95 24.832 145.054 25.432 142.558 25.432ZM142.558 22.3C143.974 22.3 144.994 21.94 145.618 21.22C146.266 20.5 146.59 19.384 146.59 17.872V14.488C146.59 13 146.266 11.896 145.618 11.176C144.994 10.432 143.974 10.06 142.558 10.06C141.118 10.06 140.074 10.432 139.426 11.176C138.802 11.896 138.49 13 138.49 14.488V17.872C138.49 19.384 138.802 20.5 139.426 21.22C140.074 21.94 141.118 22.3 142.558 22.3Z" fill="black"/>
|
||||
<path d="M158.473 25.432C156.793 25.432 155.509 24.892 154.621 23.812C153.733 22.708 153.289 21.064 153.289 18.88V14.2C153.289 11.848 153.853 10.048 154.981 8.79999C156.133 7.55199 157.729 6.92799 159.769 6.92799C160.657 6.92799 161.605 7.04799 162.613 7.28799C163.645 7.52799 164.449 7.83999 165.025 8.22399L165.169 11.212C164.449 10.828 163.657 10.54 162.793 10.348C161.953 10.132 161.209 10.024 160.561 10.024C159.337 10.024 158.413 10.408 157.789 11.176C157.189 11.92 156.889 13.048 156.889 14.56V18.772C156.889 19.948 157.117 20.836 157.573 21.436C158.029 22.012 158.713 22.3 159.625 22.3C160.201 22.3 160.765 22.192 161.317 21.976C161.869 21.736 162.469 21.376 163.117 20.896C163.765 20.392 164.497 19.72 165.313 18.88L165.349 21.832C164.581 22.6 163.837 23.26 163.117 23.812C162.397 24.34 161.665 24.736 160.921 25C160.177 25.288 159.361 25.432 158.473 25.432ZM165.601 25C165.313 25 165.157 24.868 165.133 24.604C165.085 24.244 165.025 23.8 164.953 23.272C164.881 22.72 164.857 22.252 164.881 21.868L164.629 20.104V0.807986C164.629 0.543987 164.761 0.411987 165.025 0.411987H167.833C168.097 0.411987 168.229 0.543987 168.229 0.807986V20.824C168.229 21.184 168.241 21.604 168.265 22.084C168.289 22.54 168.313 22.984 168.337 23.416C168.385 23.848 168.421 24.208 168.445 24.496C168.517 24.832 168.397 25 168.085 25H165.601Z" fill="black"/>
|
||||
<path d="M179.171 25.432C176.723 25.432 174.863 24.88 173.591 23.776C172.343 22.672 171.719 21.028 171.719 18.844V14.056C171.719 11.728 172.343 9.96399 173.591 8.76399C174.839 7.53999 176.687 6.92799 179.135 6.92799C181.559 6.92799 183.395 7.51599 184.643 8.69199C185.891 9.86799 186.515 11.584 186.515 13.84V16.648C186.515 16.912 186.395 17.044 186.155 17.044H175.319V18.196C175.319 19.66 175.631 20.728 176.255 21.4C176.879 22.072 177.887 22.408 179.279 22.408C180.335 22.408 181.151 22.24 181.727 21.904C182.303 21.568 182.591 21.052 182.591 20.356C182.591 20.092 182.735 19.96 183.023 19.96H185.795C186.011 19.96 186.143 20.08 186.191 20.32C186.287 21.928 185.711 23.188 184.463 24.1C183.215 24.988 181.451 25.432 179.171 25.432ZM175.319 14.308H182.951V14.128C182.951 12.688 182.639 11.632 182.015 10.96C181.391 10.288 180.443 9.95199 179.171 9.95199C177.875 9.95199 176.903 10.3 176.255 10.996C175.631 11.692 175.319 12.736 175.319 14.128V14.308Z" fill="black"/>
|
||||
<svg width="289" height="50" viewBox="0 0 289 50" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M264.5 0H288.5V8.5H272.5V16.5H288.5V25H272.5V33H288.5V41.5H264.5V0Z" fill="black"/>
|
||||
<path d="M248.5 0H224.5V41.5H248.5V33H232.5V8.5H248.5V0Z" fill="black"/>
|
||||
<path d="M256.5 8.5H248.5V33H256.5V8.5Z" fill="black"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M184.5 0H216.5V41.5H184.5V0ZM208.5 8.5H192.5V33H208.5V8.5Z" fill="black"/>
|
||||
<path d="M144.5 8.5H136.5V41.5H144.5V8.5Z" fill="black"/>
|
||||
<path d="M136.5 0H112.5V41.5H120.5V8.5H136.5V0Z" fill="black"/>
|
||||
<path d="M80.5 0H104.5V8.5H88.5V16.5H104.5V25H88.5V33H104.5V41.5H80.5V0Z" fill="black"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M40.5 0H72.5V41.5H48.5V49.5H40.5V0ZM64.5 8.5H48.5V33H64.5V8.5Z" fill="black"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.5 0H32.5V41.5955H0.5V0ZM24.5 8.5H8.5V33H24.5V8.5Z" fill="black"/>
|
||||
<path d="M152.5 0H176.5V8.5H160.5V33H176.5V41.5H152.5V0Z" fill="black"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 981 B |
@@ -1518,7 +1518,7 @@ export default function Share(props: {
|
||||
desc={desc}
|
||||
data-size="sm"
|
||||
text={
|
||||
command + (result() ? `\n${result}` : "")
|
||||
command + (result() ? `\n${result()}` : "")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||