mirror of
https://github.com/sst/opencode.git
synced 2025-08-04 13:30:52 +00:00
implement patch, update ui, improve rendering
This commit is contained in:
parent
05d0e86f10
commit
333ea6ec4b
38 changed files with 3312 additions and 2262 deletions
301
README.md
301
README.md
|
@ -4,8 +4,6 @@
|
|||
|
||||
A powerful terminal-based AI assistant for developers, providing intelligent coding assistance directly in your terminal.
|
||||
|
||||
[](https://asciinema.org/a/dtc4nJyGSZX79HRUmFLY3gmoy)
|
||||
|
||||
## Overview
|
||||
|
||||
OpenCode is a Go-based CLI application that brings AI assistance to your terminal. It provides a TUI (Terminal User Interface) for interacting with various AI models to help with coding tasks, debugging, and more.
|
||||
|
@ -13,11 +11,13 @@ OpenCode is a Go-based CLI application that brings AI assistance to your termina
|
|||
## 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, and Google Gemini models
|
||||
- **Multiple AI Providers**: Support for OpenAI, Anthropic Claude, Google Gemini, AWS Bedrock, and Groq
|
||||
- **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 Vim keybindings for text input
|
||||
- **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
|
||||
|
||||
## Installation
|
||||
|
||||
|
@ -34,11 +34,107 @@ OpenCode looks for configuration in the following locations:
|
|||
- `$XDG_CONFIG_HOME/opencode/.opencode.json`
|
||||
- `./.opencode.json` (local directory)
|
||||
|
||||
You can also use environment variables:
|
||||
### Environment Variables
|
||||
|
||||
- `ANTHROPIC_API_KEY`: For Claude models
|
||||
- `OPENAI_API_KEY`: For OpenAI models
|
||||
- `GEMINI_API_KEY`: For Google Gemini models
|
||||
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 |
|
||||
| `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) |
|
||||
|
||||
### Configuration File Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"directory": ".opencode"
|
||||
},
|
||||
"providers": {
|
||||
"openai": {
|
||||
"apiKey": "your-api-key",
|
||||
"disabled": false
|
||||
},
|
||||
"anthropic": {
|
||||
"apiKey": "your-api-key",
|
||||
"disabled": false
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"coder": {
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"debug": false,
|
||||
"debugLSP": false
|
||||
}
|
||||
```
|
||||
|
||||
## Supported AI Models
|
||||
|
||||
### OpenAI Models
|
||||
|
||||
| Model ID | Name | Context Window |
|
||||
| ----------------- | --------------- | ---------------- |
|
||||
| `gpt-4.1` | GPT 4.1 | 1,047,576 tokens |
|
||||
| `gpt-4.1-mini` | GPT 4.1 Mini | 200,000 tokens |
|
||||
| `gpt-4.1-nano` | GPT 4.1 Nano | 1,047,576 tokens |
|
||||
| `gpt-4.5-preview` | GPT 4.5 Preview | 128,000 tokens |
|
||||
| `gpt-4o` | GPT-4o | 128,000 tokens |
|
||||
| `gpt-4o-mini` | GPT-4o Mini | 128,000 tokens |
|
||||
| `o1` | O1 | 200,000 tokens |
|
||||
| `o1-pro` | O1 Pro | 200,000 tokens |
|
||||
| `o1-mini` | O1 Mini | 128,000 tokens |
|
||||
| `o3` | O3 | 200,000 tokens |
|
||||
| `o3-mini` | O3 Mini | 200,000 tokens |
|
||||
| `o4-mini` | O4 Mini | 128,000 tokens |
|
||||
|
||||
### Anthropic Models
|
||||
|
||||
| Model ID | Name | Context Window |
|
||||
| ------------------- | ----------------- | -------------- |
|
||||
| `claude-3.5-sonnet` | Claude 3.5 Sonnet | 200,000 tokens |
|
||||
| `claude-3-haiku` | Claude 3 Haiku | 200,000 tokens |
|
||||
| `claude-3.7-sonnet` | Claude 3.7 Sonnet | 200,000 tokens |
|
||||
| `claude-3.5-haiku` | Claude 3.5 Haiku | 200,000 tokens |
|
||||
| `claude-3-opus` | Claude 3 Opus | 200,000 tokens |
|
||||
|
||||
### Other Models
|
||||
|
||||
| Model ID | Provider | Name | Context Window |
|
||||
| --------------------------- | ----------- | ----------------- | -------------- |
|
||||
| `gemini-2.5` | Google | Gemini 2.5 Pro | - |
|
||||
| `gemini-2.0-flash` | Google | Gemini 2.0 Flash | - |
|
||||
| `qwen-qwq` | Groq | Qwen Qwq | - |
|
||||
| `bedrock.claude-3.7-sonnet` | AWS Bedrock | Claude 3.7 Sonnet | - |
|
||||
|
||||
## Usage
|
||||
|
||||
|
@ -48,36 +144,78 @@ opencode
|
|||
|
||||
# Start with debug logging
|
||||
opencode -d
|
||||
|
||||
# Start with a specific working directory
|
||||
opencode -c /path/to/project
|
||||
```
|
||||
|
||||
### Keyboard Shortcuts
|
||||
## Command-line Flags
|
||||
|
||||
#### Global Shortcuts
|
||||
| Flag | Short | Description |
|
||||
| --------- | ----- | ----------------------------- |
|
||||
| `--help` | `-h` | Display help information |
|
||||
| `--debug` | `-d` | Enable debug mode |
|
||||
| `--cwd` | `-c` | Set current working directory |
|
||||
|
||||
- `?`: Toggle help panel
|
||||
- `Ctrl+C` or `q`: Quit application
|
||||
- `L`: View logs
|
||||
- `Backspace`: Go back to previous page
|
||||
- `Esc`: Close current view/dialog or return to normal mode
|
||||
## Keyboard Shortcuts
|
||||
|
||||
#### Session Management
|
||||
### Global Shortcuts
|
||||
|
||||
- `N`: Create new session
|
||||
- `Enter` or `Space`: Select session (in sessions list)
|
||||
| Shortcut | Action |
|
||||
| -------- | ------------------------------------------------------- |
|
||||
| `Ctrl+C` | Quit application |
|
||||
| `Ctrl+?` | Toggle help dialog |
|
||||
| `Ctrl+L` | View logs |
|
||||
| `Esc` | Close current overlay/dialog or return to previous mode |
|
||||
|
||||
#### Editor Shortcuts (Vim-like)
|
||||
### Chat Page Shortcuts
|
||||
|
||||
- `i`: Enter insert mode
|
||||
- `Esc`: Enter normal mode
|
||||
- `v`: Enter visual mode
|
||||
- `V`: Enter visual line mode
|
||||
- `Enter`: Send message (in normal mode)
|
||||
- `Ctrl+S`: Send message (in insert mode)
|
||||
| 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 |
|
||||
|
||||
#### Navigation
|
||||
### Editor Shortcuts
|
||||
|
||||
- Arrow keys: Navigate through lists and content
|
||||
- Page Up/Down: Scroll through content
|
||||
| Shortcut | Action |
|
||||
| ------------------- | ----------------------------------------- |
|
||||
| `Ctrl+S` | Send message (when editor is focused) |
|
||||
| `Enter` or `Ctrl+S` | Send message (when editor is not focused) |
|
||||
| `Esc` | Blur editor and focus messages |
|
||||
|
||||
### Logs Page Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
| ----------- | ------------------- |
|
||||
| `Backspace` | 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) |
|
||||
| `sourcegraph` | Search code across public repositories | `query` (required), `count` (optional), `context_window` (optional), `timeout` (optional) |
|
||||
| `agent` | Run sub-tasks with the AI agent | `prompt` (required) |
|
||||
|
||||
## Architecture
|
||||
|
||||
|
@ -92,6 +230,101 @@ OpenCode is built with a modular architecture:
|
|||
- **internal/logging**: Logging infrastructure
|
||||
- **internal/message**: Message handling
|
||||
- **internal/session**: Session management
|
||||
- **internal/lsp**: Language Server Protocol integration
|
||||
|
||||
## MCP (Model Context Protocol)
|
||||
|
||||
OpenCode implements the Model Context Protocol (MCP) to extend its capabilities through external tools. MCP provides a standardized way for the AI assistant to interact with external services and tools.
|
||||
|
||||
### MCP Features
|
||||
|
||||
- **External Tool Integration**: Connect to external tools and services via a standardized protocol
|
||||
- **Tool Discovery**: Automatically discover available tools from MCP servers
|
||||
- **Multiple Connection Types**:
|
||||
- **Stdio**: Communicate with tools via standard input/output
|
||||
- **SSE**: Communicate with tools via Server-Sent Events
|
||||
- **Security**: Permission system for controlling access to MCP tools
|
||||
|
||||
### Configuring MCP Servers
|
||||
|
||||
MCP servers are defined in the configuration file under the `mcpServers` section:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"example": {
|
||||
"type": "stdio",
|
||||
"command": "path/to/mcp-server",
|
||||
"env": [],
|
||||
"args": []
|
||||
},
|
||||
"web-example": {
|
||||
"type": "sse",
|
||||
"url": "https://example.com/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### MCP Tool Usage
|
||||
|
||||
Once configured, MCP tools are automatically available to the AI assistant alongside built-in tools. They follow the same permission model as other tools, requiring user approval before execution.
|
||||
|
||||
## LSP (Language Server Protocol)
|
||||
|
||||
OpenCode integrates with Language Server Protocol to provide rich code intelligence features across multiple programming languages.
|
||||
|
||||
### LSP Features
|
||||
|
||||
- **Multi-language Support**: Connect to language servers for different programming languages
|
||||
- **Code Intelligence**: Get diagnostics, completions, and navigation assistance
|
||||
- **File Watching**: Automatically notify language servers of file changes
|
||||
- **Diagnostics**: Display errors, warnings, and hints in your code
|
||||
|
||||
### Supported LSP Features
|
||||
|
||||
| Feature | Description |
|
||||
| ----------------- | ----------------------------------- |
|
||||
| Diagnostics | Error checking and linting |
|
||||
| Completions | Code suggestions and autocompletion |
|
||||
| Hover | Documentation on hover |
|
||||
| Definition | Go to definition |
|
||||
| References | Find all references |
|
||||
| Document Symbols | Navigate symbols in current file |
|
||||
| Workspace Symbols | Search symbols across workspace |
|
||||
| Formatting | Code formatting |
|
||||
| Code Actions | Quick fixes and refactorings |
|
||||
|
||||
### Configuring LSP
|
||||
|
||||
Language servers are configured in the configuration file under the `lsp` section:
|
||||
|
||||
```json
|
||||
{
|
||||
"lsp": {
|
||||
"go": {
|
||||
"disabled": false,
|
||||
"command": "gopls"
|
||||
},
|
||||
"typescript": {
|
||||
"disabled": false,
|
||||
"command": "typescript-language-server",
|
||||
"args": ["--stdio"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### LSP Integration with AI
|
||||
|
||||
The AI assistant can access LSP features through the `diagnostics` tool, allowing it to:
|
||||
|
||||
- Check for errors in your code
|
||||
- Suggest fixes based on diagnostics
|
||||
- Provide intelligent code assistance
|
||||
|
||||
## Development
|
||||
|
||||
|
@ -124,8 +357,16 @@ OpenCode builds upon the work of several open source projects and developers:
|
|||
|
||||
## License
|
||||
|
||||
[License information coming soon]
|
||||
OpenCode is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## Contributing
|
||||
|
||||
[Contribution guidelines coming soon]
|
||||
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.
|
||||
|
|
|
@ -41,8 +41,9 @@ const (
|
|||
|
||||
// Agent defines configuration for different LLM models and their token limits.
|
||||
type Agent struct {
|
||||
Model models.ModelID `json:"model"`
|
||||
MaxTokens int64 `json:"maxTokens"`
|
||||
Model models.ModelID `json:"model"`
|
||||
MaxTokens int64 `json:"maxTokens"`
|
||||
ReasoningEffort string `json:"reasoningEffort"` // For openai models low,medium,heigh
|
||||
}
|
||||
|
||||
// Provider defines configuration for an LLM provider.
|
||||
|
@ -80,7 +81,6 @@ type Config struct {
|
|||
const (
|
||||
defaultDataDirectory = ".opencode"
|
||||
defaultLogLevel = "info"
|
||||
defaultMaxTokens = int64(5000)
|
||||
appName = "opencode"
|
||||
)
|
||||
|
||||
|
@ -202,9 +202,7 @@ func setProviderDefaults() {
|
|||
if apiKey := os.Getenv("GROQ_API_KEY"); apiKey != "" {
|
||||
viper.SetDefault("providers.groq.apiKey", apiKey)
|
||||
viper.SetDefault("agents.coder.model", models.QWENQwq)
|
||||
viper.SetDefault("agents.coder.maxTokens", defaultMaxTokens)
|
||||
viper.SetDefault("agents.task.model", models.QWENQwq)
|
||||
viper.SetDefault("agents.task.maxTokens", defaultMaxTokens)
|
||||
viper.SetDefault("agents.title.model", models.QWENQwq)
|
||||
}
|
||||
|
||||
|
@ -212,9 +210,7 @@ func setProviderDefaults() {
|
|||
if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
|
||||
viper.SetDefault("providers.gemini.apiKey", apiKey)
|
||||
viper.SetDefault("agents.coder.model", models.GRMINI20Flash)
|
||||
viper.SetDefault("agents.coder.maxTokens", defaultMaxTokens)
|
||||
viper.SetDefault("agents.task.model", models.GRMINI20Flash)
|
||||
viper.SetDefault("agents.task.maxTokens", defaultMaxTokens)
|
||||
viper.SetDefault("agents.title.model", models.GRMINI20Flash)
|
||||
}
|
||||
|
||||
|
@ -222,9 +218,7 @@ func setProviderDefaults() {
|
|||
if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
|
||||
viper.SetDefault("providers.openai.apiKey", apiKey)
|
||||
viper.SetDefault("agents.coder.model", models.GPT4o)
|
||||
viper.SetDefault("agents.coder.maxTokens", defaultMaxTokens)
|
||||
viper.SetDefault("agents.task.model", models.GPT4o)
|
||||
viper.SetDefault("agents.task.maxTokens", defaultMaxTokens)
|
||||
viper.SetDefault("agents.title.model", models.GPT4o)
|
||||
|
||||
}
|
||||
|
@ -233,17 +227,13 @@ func setProviderDefaults() {
|
|||
if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
|
||||
viper.SetDefault("providers.anthropic.apiKey", apiKey)
|
||||
viper.SetDefault("agents.coder.model", models.Claude37Sonnet)
|
||||
viper.SetDefault("agents.coder.maxTokens", defaultMaxTokens)
|
||||
viper.SetDefault("agents.task.model", models.Claude37Sonnet)
|
||||
viper.SetDefault("agents.task.maxTokens", defaultMaxTokens)
|
||||
viper.SetDefault("agents.title.model", models.Claude37Sonnet)
|
||||
}
|
||||
|
||||
if hasAWSCredentials() {
|
||||
viper.SetDefault("agents.coder.model", models.BedrockClaude37Sonnet)
|
||||
viper.SetDefault("agents.coder.maxTokens", defaultMaxTokens)
|
||||
viper.SetDefault("agents.task.model", models.BedrockClaude37Sonnet)
|
||||
viper.SetDefault("agents.task.maxTokens", defaultMaxTokens)
|
||||
viper.SetDefault("agents.title.model", models.BedrockClaude37Sonnet)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -79,8 +79,9 @@ type linePair struct {
|
|||
|
||||
// StyleConfig defines styling for diff rendering
|
||||
type StyleConfig struct {
|
||||
ShowHeader bool
|
||||
FileNameFg lipgloss.Color
|
||||
ShowHeader bool
|
||||
ShowHunkHeader bool
|
||||
FileNameFg lipgloss.Color
|
||||
// Background colors
|
||||
RemovedLineBg lipgloss.Color
|
||||
AddedLineBg lipgloss.Color
|
||||
|
@ -111,7 +112,8 @@ func NewStyleConfig(opts ...StyleOption) StyleConfig {
|
|||
// Default color scheme
|
||||
config := StyleConfig{
|
||||
ShowHeader: true,
|
||||
FileNameFg: lipgloss.Color("#fab283"),
|
||||
ShowHunkHeader: true,
|
||||
FileNameFg: lipgloss.Color("#a0a0a0"),
|
||||
RemovedLineBg: lipgloss.Color("#3A3030"),
|
||||
AddedLineBg: lipgloss.Color("#303A30"),
|
||||
ContextLineBg: lipgloss.Color("#212121"),
|
||||
|
@ -204,6 +206,10 @@ func WithShowHeader(show bool) StyleOption {
|
|||
return func(s *StyleConfig) { s.ShowHeader = show }
|
||||
}
|
||||
|
||||
func WithShowHunkHeader(show bool) StyleOption {
|
||||
return func(s *StyleConfig) { s.ShowHunkHeader = show }
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Parse Configuration
|
||||
// -------------------------------------------------------------------------
|
||||
|
@ -914,13 +920,15 @@ func FormatDiff(diffText string, opts ...SideBySideOption) (string, error) {
|
|||
|
||||
for _, h := range diffResult.Hunks {
|
||||
// Render hunk header
|
||||
sb.WriteString(
|
||||
lipgloss.NewStyle().
|
||||
Background(config.Style.HunkLineBg).
|
||||
Foreground(config.Style.HunkLineFg).
|
||||
Width(config.TotalWidth).
|
||||
Render(h.Header) + "\n",
|
||||
)
|
||||
if config.Style.ShowHunkHeader {
|
||||
sb.WriteString(
|
||||
lipgloss.NewStyle().
|
||||
Background(config.Style.HunkLineBg).
|
||||
Foreground(config.Style.HunkLineFg).
|
||||
Width(config.TotalWidth).
|
||||
Render(h.Header) + "\n",
|
||||
)
|
||||
}
|
||||
sb.WriteString(RenderSideBySideHunk(diffResult.OldFile, h, opts...))
|
||||
}
|
||||
|
||||
|
|
739
internal/diff/patch.go
Normal file
739
internal/diff/patch.go
Normal file
|
@ -0,0 +1,739 @@
|
|||
package diff
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ActionType string
|
||||
|
||||
const (
|
||||
ActionAdd ActionType = "add"
|
||||
ActionDelete ActionType = "delete"
|
||||
ActionUpdate ActionType = "update"
|
||||
)
|
||||
|
||||
type FileChange struct {
|
||||
Type ActionType
|
||||
OldContent *string
|
||||
NewContent *string
|
||||
MovePath *string
|
||||
}
|
||||
|
||||
type Commit struct {
|
||||
Changes map[string]FileChange
|
||||
}
|
||||
|
||||
type Chunk struct {
|
||||
OrigIndex int // line index of the first line in the original file
|
||||
DelLines []string // lines to delete
|
||||
InsLines []string // lines to insert
|
||||
}
|
||||
|
||||
type PatchAction struct {
|
||||
Type ActionType
|
||||
NewFile *string
|
||||
Chunks []Chunk
|
||||
MovePath *string
|
||||
}
|
||||
|
||||
type Patch struct {
|
||||
Actions map[string]PatchAction
|
||||
}
|
||||
|
||||
type DiffError struct {
|
||||
message string
|
||||
}
|
||||
|
||||
func (e DiffError) Error() string {
|
||||
return e.message
|
||||
}
|
||||
|
||||
// Helper functions for error handling
|
||||
func NewDiffError(message string) DiffError {
|
||||
return DiffError{message: message}
|
||||
}
|
||||
|
||||
func fileError(action, reason, path string) DiffError {
|
||||
return NewDiffError(fmt.Sprintf("%s File Error: %s: %s", action, reason, path))
|
||||
}
|
||||
|
||||
func contextError(index int, context string, isEOF bool) DiffError {
|
||||
prefix := "Invalid Context"
|
||||
if isEOF {
|
||||
prefix = "Invalid EOF Context"
|
||||
}
|
||||
return NewDiffError(fmt.Sprintf("%s %d:\n%s", prefix, index, context))
|
||||
}
|
||||
|
||||
type Parser struct {
|
||||
currentFiles map[string]string
|
||||
lines []string
|
||||
index int
|
||||
patch Patch
|
||||
fuzz int
|
||||
}
|
||||
|
||||
func NewParser(currentFiles map[string]string, lines []string) *Parser {
|
||||
return &Parser{
|
||||
currentFiles: currentFiles,
|
||||
lines: lines,
|
||||
index: 0,
|
||||
patch: Patch{Actions: make(map[string]PatchAction, len(currentFiles))},
|
||||
fuzz: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Parser) isDone(prefixes []string) bool {
|
||||
if p.index >= len(p.lines) {
|
||||
return true
|
||||
}
|
||||
if prefixes != nil {
|
||||
for _, prefix := range prefixes {
|
||||
if strings.HasPrefix(p.lines[p.index], prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *Parser) startsWith(prefix any) bool {
|
||||
var prefixes []string
|
||||
switch v := prefix.(type) {
|
||||
case string:
|
||||
prefixes = []string{v}
|
||||
case []string:
|
||||
prefixes = v
|
||||
}
|
||||
|
||||
for _, pfx := range prefixes {
|
||||
if strings.HasPrefix(p.lines[p.index], pfx) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *Parser) readStr(prefix string, returnEverything bool) string {
|
||||
if p.index >= len(p.lines) {
|
||||
return "" // Changed from panic to return empty string for safer operation
|
||||
}
|
||||
if strings.HasPrefix(p.lines[p.index], prefix) {
|
||||
var text string
|
||||
if returnEverything {
|
||||
text = p.lines[p.index]
|
||||
} else {
|
||||
text = p.lines[p.index][len(prefix):]
|
||||
}
|
||||
p.index++
|
||||
return text
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (p *Parser) Parse() error {
|
||||
endPatchPrefixes := []string{"*** End Patch"}
|
||||
|
||||
for !p.isDone(endPatchPrefixes) {
|
||||
path := p.readStr("*** Update File: ", false)
|
||||
if path != "" {
|
||||
if _, exists := p.patch.Actions[path]; exists {
|
||||
return fileError("Update", "Duplicate Path", path)
|
||||
}
|
||||
moveTo := p.readStr("*** Move to: ", false)
|
||||
if _, exists := p.currentFiles[path]; !exists {
|
||||
return fileError("Update", "Missing File", path)
|
||||
}
|
||||
text := p.currentFiles[path]
|
||||
action, err := p.parseUpdateFile(text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if moveTo != "" {
|
||||
action.MovePath = &moveTo
|
||||
}
|
||||
p.patch.Actions[path] = action
|
||||
continue
|
||||
}
|
||||
|
||||
path = p.readStr("*** Delete File: ", false)
|
||||
if path != "" {
|
||||
if _, exists := p.patch.Actions[path]; exists {
|
||||
return fileError("Delete", "Duplicate Path", path)
|
||||
}
|
||||
if _, exists := p.currentFiles[path]; !exists {
|
||||
return fileError("Delete", "Missing File", path)
|
||||
}
|
||||
p.patch.Actions[path] = PatchAction{Type: ActionDelete, Chunks: []Chunk{}}
|
||||
continue
|
||||
}
|
||||
|
||||
path = p.readStr("*** Add File: ", false)
|
||||
if path != "" {
|
||||
if _, exists := p.patch.Actions[path]; exists {
|
||||
return fileError("Add", "Duplicate Path", path)
|
||||
}
|
||||
if _, exists := p.currentFiles[path]; exists {
|
||||
return fileError("Add", "File already exists", path)
|
||||
}
|
||||
action, err := p.parseAddFile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.patch.Actions[path] = action
|
||||
continue
|
||||
}
|
||||
|
||||
return NewDiffError(fmt.Sprintf("Unknown Line: %s", p.lines[p.index]))
|
||||
}
|
||||
|
||||
if !p.startsWith("*** End Patch") {
|
||||
return NewDiffError("Missing End Patch")
|
||||
}
|
||||
p.index++
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Parser) parseUpdateFile(text string) (PatchAction, error) {
|
||||
action := PatchAction{Type: ActionUpdate, Chunks: []Chunk{}}
|
||||
fileLines := strings.Split(text, "\n")
|
||||
index := 0
|
||||
|
||||
endPrefixes := []string{
|
||||
"*** End Patch",
|
||||
"*** Update File:",
|
||||
"*** Delete File:",
|
||||
"*** Add File:",
|
||||
"*** End of File",
|
||||
}
|
||||
|
||||
for !p.isDone(endPrefixes) {
|
||||
defStr := p.readStr("@@ ", false)
|
||||
sectionStr := ""
|
||||
if defStr == "" && p.index < len(p.lines) && p.lines[p.index] == "@@" {
|
||||
sectionStr = p.lines[p.index]
|
||||
p.index++
|
||||
}
|
||||
if !(defStr != "" || sectionStr != "" || index == 0) {
|
||||
return action, NewDiffError(fmt.Sprintf("Invalid Line:\n%s", p.lines[p.index]))
|
||||
}
|
||||
if strings.TrimSpace(defStr) != "" {
|
||||
found := false
|
||||
for i := range fileLines[:index] {
|
||||
if fileLines[i] == defStr {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
for i := index; i < len(fileLines); i++ {
|
||||
if fileLines[i] == defStr {
|
||||
index = i + 1
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
for i := range fileLines[:index] {
|
||||
if strings.TrimSpace(fileLines[i]) == strings.TrimSpace(defStr) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
for i := index; i < len(fileLines); i++ {
|
||||
if strings.TrimSpace(fileLines[i]) == strings.TrimSpace(defStr) {
|
||||
index = i + 1
|
||||
p.fuzz++
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nextChunkContext, chunks, endPatchIndex, eof := peekNextSection(p.lines, p.index)
|
||||
newIndex, fuzz := findContext(fileLines, nextChunkContext, index, eof)
|
||||
if newIndex == -1 {
|
||||
ctxText := strings.Join(nextChunkContext, "\n")
|
||||
return action, contextError(index, ctxText, eof)
|
||||
}
|
||||
p.fuzz += fuzz
|
||||
|
||||
for _, ch := range chunks {
|
||||
ch.OrigIndex += newIndex
|
||||
action.Chunks = append(action.Chunks, ch)
|
||||
}
|
||||
index = newIndex + len(nextChunkContext)
|
||||
p.index = endPatchIndex
|
||||
}
|
||||
return action, nil
|
||||
}
|
||||
|
||||
func (p *Parser) parseAddFile() (PatchAction, error) {
|
||||
lines := make([]string, 0, 16) // Preallocate space for better performance
|
||||
endPrefixes := []string{
|
||||
"*** End Patch",
|
||||
"*** Update File:",
|
||||
"*** Delete File:",
|
||||
"*** Add File:",
|
||||
}
|
||||
|
||||
for !p.isDone(endPrefixes) {
|
||||
s := p.readStr("", true)
|
||||
if !strings.HasPrefix(s, "+") {
|
||||
return PatchAction{}, NewDiffError(fmt.Sprintf("Invalid Add File Line: %s", s))
|
||||
}
|
||||
lines = append(lines, s[1:])
|
||||
}
|
||||
|
||||
newFile := strings.Join(lines, "\n")
|
||||
return PatchAction{
|
||||
Type: ActionAdd,
|
||||
NewFile: &newFile,
|
||||
Chunks: []Chunk{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Refactored to use a matcher function for each comparison type
|
||||
func findContextCore(lines []string, context []string, start int) (int, int) {
|
||||
if len(context) == 0 {
|
||||
return start, 0
|
||||
}
|
||||
|
||||
// Try exact match
|
||||
if idx, fuzz := tryFindMatch(lines, context, start, func(a, b string) bool {
|
||||
return a == b
|
||||
}); idx >= 0 {
|
||||
return idx, fuzz
|
||||
}
|
||||
|
||||
// Try trimming right whitespace
|
||||
if idx, fuzz := tryFindMatch(lines, context, start, func(a, b string) bool {
|
||||
return strings.TrimRight(a, " \t") == strings.TrimRight(b, " \t")
|
||||
}); idx >= 0 {
|
||||
return idx, fuzz
|
||||
}
|
||||
|
||||
// Try trimming all whitespace
|
||||
if idx, fuzz := tryFindMatch(lines, context, start, func(a, b string) bool {
|
||||
return strings.TrimSpace(a) == strings.TrimSpace(b)
|
||||
}); idx >= 0 {
|
||||
return idx, fuzz
|
||||
}
|
||||
|
||||
return -1, 0
|
||||
}
|
||||
|
||||
// Helper function to DRY up the match logic
|
||||
func tryFindMatch(lines []string, context []string, start int,
|
||||
compareFunc func(string, string) bool,
|
||||
) (int, int) {
|
||||
for i := start; i < len(lines); i++ {
|
||||
if i+len(context) <= len(lines) {
|
||||
match := true
|
||||
for j := range context {
|
||||
if !compareFunc(lines[i+j], context[j]) {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if match {
|
||||
// Return fuzz level: 0 for exact, 1 for trimRight, 100 for trimSpace
|
||||
var fuzz int
|
||||
if compareFunc("a ", "a") && !compareFunc("a", "b") {
|
||||
fuzz = 1
|
||||
} else if compareFunc("a ", "a") {
|
||||
fuzz = 100
|
||||
}
|
||||
return i, fuzz
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1, 0
|
||||
}
|
||||
|
||||
func findContext(lines []string, context []string, start int, eof bool) (int, int) {
|
||||
if eof {
|
||||
newIndex, fuzz := findContextCore(lines, context, len(lines)-len(context))
|
||||
if newIndex != -1 {
|
||||
return newIndex, fuzz
|
||||
}
|
||||
newIndex, fuzz = findContextCore(lines, context, start)
|
||||
return newIndex, fuzz + 10000
|
||||
}
|
||||
return findContextCore(lines, context, start)
|
||||
}
|
||||
|
||||
func peekNextSection(lines []string, initialIndex int) ([]string, []Chunk, int, bool) {
|
||||
index := initialIndex
|
||||
old := make([]string, 0, 32) // Preallocate for better performance
|
||||
delLines := make([]string, 0, 8)
|
||||
insLines := make([]string, 0, 8)
|
||||
chunks := make([]Chunk, 0, 4)
|
||||
mode := "keep"
|
||||
|
||||
// End conditions for the section
|
||||
endSectionConditions := func(s string) bool {
|
||||
return strings.HasPrefix(s, "@@") ||
|
||||
strings.HasPrefix(s, "*** End Patch") ||
|
||||
strings.HasPrefix(s, "*** Update File:") ||
|
||||
strings.HasPrefix(s, "*** Delete File:") ||
|
||||
strings.HasPrefix(s, "*** Add File:") ||
|
||||
strings.HasPrefix(s, "*** End of File") ||
|
||||
s == "***" ||
|
||||
strings.HasPrefix(s, "***")
|
||||
}
|
||||
|
||||
for index < len(lines) {
|
||||
s := lines[index]
|
||||
if endSectionConditions(s) {
|
||||
break
|
||||
}
|
||||
index++
|
||||
lastMode := mode
|
||||
line := s
|
||||
|
||||
if len(line) > 0 {
|
||||
switch line[0] {
|
||||
case '+':
|
||||
mode = "add"
|
||||
case '-':
|
||||
mode = "delete"
|
||||
case ' ':
|
||||
mode = "keep"
|
||||
default:
|
||||
mode = "keep"
|
||||
line = " " + line
|
||||
}
|
||||
} else {
|
||||
mode = "keep"
|
||||
line = " "
|
||||
}
|
||||
|
||||
line = line[1:]
|
||||
if mode == "keep" && lastMode != mode {
|
||||
if len(insLines) > 0 || len(delLines) > 0 {
|
||||
chunks = append(chunks, Chunk{
|
||||
OrigIndex: len(old) - len(delLines),
|
||||
DelLines: delLines,
|
||||
InsLines: insLines,
|
||||
})
|
||||
}
|
||||
delLines = make([]string, 0, 8)
|
||||
insLines = make([]string, 0, 8)
|
||||
}
|
||||
if mode == "delete" {
|
||||
delLines = append(delLines, line)
|
||||
old = append(old, line)
|
||||
} else if mode == "add" {
|
||||
insLines = append(insLines, line)
|
||||
} else {
|
||||
old = append(old, line)
|
||||
}
|
||||
}
|
||||
|
||||
if len(insLines) > 0 || len(delLines) > 0 {
|
||||
chunks = append(chunks, Chunk{
|
||||
OrigIndex: len(old) - len(delLines),
|
||||
DelLines: delLines,
|
||||
InsLines: insLines,
|
||||
})
|
||||
}
|
||||
|
||||
if index < len(lines) && lines[index] == "*** End of File" {
|
||||
index++
|
||||
return old, chunks, index, true
|
||||
}
|
||||
return old, chunks, index, false
|
||||
}
|
||||
|
||||
func TextToPatch(text string, orig map[string]string) (Patch, int, error) {
|
||||
text = strings.TrimSpace(text)
|
||||
lines := strings.Split(text, "\n")
|
||||
if len(lines) < 2 || !strings.HasPrefix(lines[0], "*** Begin Patch") || lines[len(lines)-1] != "*** End Patch" {
|
||||
return Patch{}, 0, NewDiffError("Invalid patch text")
|
||||
}
|
||||
parser := NewParser(orig, lines)
|
||||
parser.index = 1
|
||||
if err := parser.Parse(); err != nil {
|
||||
return Patch{}, 0, err
|
||||
}
|
||||
return parser.patch, parser.fuzz, nil
|
||||
}
|
||||
|
||||
func IdentifyFilesNeeded(text string) []string {
|
||||
text = strings.TrimSpace(text)
|
||||
lines := strings.Split(text, "\n")
|
||||
result := make(map[string]bool)
|
||||
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "*** Update File: ") {
|
||||
result[line[len("*** Update File: "):]] = true
|
||||
}
|
||||
if strings.HasPrefix(line, "*** Delete File: ") {
|
||||
result[line[len("*** Delete File: "):]] = true
|
||||
}
|
||||
}
|
||||
|
||||
files := make([]string, 0, len(result))
|
||||
for file := range result {
|
||||
files = append(files, file)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func IdentifyFilesAdded(text string) []string {
|
||||
text = strings.TrimSpace(text)
|
||||
lines := strings.Split(text, "\n")
|
||||
result := make(map[string]bool)
|
||||
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "*** Add File: ") {
|
||||
result[line[len("*** Add File: "):]] = true
|
||||
}
|
||||
}
|
||||
|
||||
files := make([]string, 0, len(result))
|
||||
for file := range result {
|
||||
files = append(files, file)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func getUpdatedFile(text string, action PatchAction, path string) (string, error) {
|
||||
if action.Type != ActionUpdate {
|
||||
return "", errors.New("Expected UPDATE action")
|
||||
}
|
||||
origLines := strings.Split(text, "\n")
|
||||
destLines := make([]string, 0, len(origLines)) // Preallocate with capacity
|
||||
origIndex := 0
|
||||
|
||||
for _, chunk := range action.Chunks {
|
||||
if chunk.OrigIndex > len(origLines) {
|
||||
return "", NewDiffError(fmt.Sprintf("%s: chunk.orig_index %d > len(lines) %d", path, chunk.OrigIndex, len(origLines)))
|
||||
}
|
||||
if origIndex > chunk.OrigIndex {
|
||||
return "", NewDiffError(fmt.Sprintf("%s: orig_index %d > chunk.orig_index %d", path, origIndex, chunk.OrigIndex))
|
||||
}
|
||||
destLines = append(destLines, origLines[origIndex:chunk.OrigIndex]...)
|
||||
delta := chunk.OrigIndex - origIndex
|
||||
origIndex += delta
|
||||
|
||||
if len(chunk.InsLines) > 0 {
|
||||
destLines = append(destLines, chunk.InsLines...)
|
||||
}
|
||||
origIndex += len(chunk.DelLines)
|
||||
}
|
||||
|
||||
destLines = append(destLines, origLines[origIndex:]...)
|
||||
return strings.Join(destLines, "\n"), nil
|
||||
}
|
||||
|
||||
func PatchToCommit(patch Patch, orig map[string]string) (Commit, error) {
|
||||
commit := Commit{Changes: make(map[string]FileChange, len(patch.Actions))}
|
||||
for pathKey, action := range patch.Actions {
|
||||
if action.Type == ActionDelete {
|
||||
oldContent := orig[pathKey]
|
||||
commit.Changes[pathKey] = FileChange{
|
||||
Type: ActionDelete,
|
||||
OldContent: &oldContent,
|
||||
}
|
||||
} else if action.Type == ActionAdd {
|
||||
commit.Changes[pathKey] = FileChange{
|
||||
Type: ActionAdd,
|
||||
NewContent: action.NewFile,
|
||||
}
|
||||
} else if action.Type == ActionUpdate {
|
||||
newContent, err := getUpdatedFile(orig[pathKey], action, pathKey)
|
||||
if err != nil {
|
||||
return Commit{}, err
|
||||
}
|
||||
oldContent := orig[pathKey]
|
||||
fileChange := FileChange{
|
||||
Type: ActionUpdate,
|
||||
OldContent: &oldContent,
|
||||
NewContent: &newContent,
|
||||
}
|
||||
if action.MovePath != nil {
|
||||
fileChange.MovePath = action.MovePath
|
||||
}
|
||||
commit.Changes[pathKey] = fileChange
|
||||
}
|
||||
}
|
||||
return commit, nil
|
||||
}
|
||||
|
||||
func AssembleChanges(orig map[string]string, updatedFiles map[string]string) Commit {
|
||||
commit := Commit{Changes: make(map[string]FileChange, len(updatedFiles))}
|
||||
for p, newContent := range updatedFiles {
|
||||
oldContent, exists := orig[p]
|
||||
if exists && oldContent == newContent {
|
||||
continue
|
||||
}
|
||||
|
||||
if exists && newContent != "" {
|
||||
commit.Changes[p] = FileChange{
|
||||
Type: ActionUpdate,
|
||||
OldContent: &oldContent,
|
||||
NewContent: &newContent,
|
||||
}
|
||||
} else if newContent != "" {
|
||||
commit.Changes[p] = FileChange{
|
||||
Type: ActionAdd,
|
||||
NewContent: &newContent,
|
||||
}
|
||||
} else if exists {
|
||||
commit.Changes[p] = FileChange{
|
||||
Type: ActionDelete,
|
||||
OldContent: &oldContent,
|
||||
}
|
||||
} else {
|
||||
return commit // Changed from panic to simply return current commit
|
||||
}
|
||||
}
|
||||
return commit
|
||||
}
|
||||
|
||||
func LoadFiles(paths []string, openFn func(string) (string, error)) (map[string]string, error) {
|
||||
orig := make(map[string]string, len(paths))
|
||||
for _, p := range paths {
|
||||
content, err := openFn(p)
|
||||
if err != nil {
|
||||
return nil, fileError("Open", "File not found", p)
|
||||
}
|
||||
orig[p] = content
|
||||
}
|
||||
return orig, nil
|
||||
}
|
||||
|
||||
func ApplyCommit(commit Commit, writeFn func(string, string) error, removeFn func(string) error) error {
|
||||
for p, change := range commit.Changes {
|
||||
if change.Type == ActionDelete {
|
||||
if err := removeFn(p); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if change.Type == ActionAdd {
|
||||
if change.NewContent == nil {
|
||||
return NewDiffError(fmt.Sprintf("Add action for %s has nil new_content", p))
|
||||
}
|
||||
if err := writeFn(p, *change.NewContent); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if change.Type == ActionUpdate {
|
||||
if change.NewContent == nil {
|
||||
return NewDiffError(fmt.Sprintf("Update action for %s has nil new_content", p))
|
||||
}
|
||||
if change.MovePath != nil {
|
||||
if err := writeFn(*change.MovePath, *change.NewContent); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := removeFn(p); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := writeFn(p, *change.NewContent); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ProcessPatch(text string, openFn func(string) (string, error), writeFn func(string, string) error, removeFn func(string) error) (string, error) {
|
||||
if !strings.HasPrefix(text, "*** Begin Patch") {
|
||||
return "", NewDiffError("Patch must start with *** Begin Patch")
|
||||
}
|
||||
paths := IdentifyFilesNeeded(text)
|
||||
orig, err := LoadFiles(paths, openFn)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
patch, fuzz, err := TextToPatch(text, orig)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if fuzz > 0 {
|
||||
return "", NewDiffError(fmt.Sprintf("Patch contains fuzzy matches (fuzz level: %d)", fuzz))
|
||||
}
|
||||
|
||||
commit, err := PatchToCommit(patch, orig)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := ApplyCommit(commit, writeFn, removeFn); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return "Patch applied successfully", nil
|
||||
}
|
||||
|
||||
func OpenFile(p string) (string, error) {
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func WriteFile(p string, content string) error {
|
||||
if filepath.IsAbs(p) {
|
||||
return NewDiffError("We do not support absolute paths.")
|
||||
}
|
||||
|
||||
dir := filepath.Dir(p)
|
||||
if dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return os.WriteFile(p, []byte(content), 0o644)
|
||||
}
|
||||
|
||||
func RemoveFile(p string) error {
|
||||
return os.Remove(p)
|
||||
}
|
||||
|
||||
func ValidatePatch(patchText string, files map[string]string) (bool, string, error) {
|
||||
if !strings.HasPrefix(patchText, "*** Begin Patch") {
|
||||
return false, "Patch must start with *** Begin Patch", nil
|
||||
}
|
||||
|
||||
neededFiles := IdentifyFilesNeeded(patchText)
|
||||
for _, filePath := range neededFiles {
|
||||
if _, exists := files[filePath]; !exists {
|
||||
return false, fmt.Sprintf("File not found: %s", filePath), nil
|
||||
}
|
||||
}
|
||||
|
||||
patch, fuzz, err := TextToPatch(patchText, files)
|
||||
if err != nil {
|
||||
return false, err.Error(), nil
|
||||
}
|
||||
|
||||
if fuzz > 0 {
|
||||
return false, fmt.Sprintf("Patch contains fuzzy matches (fuzz level: %d)", fuzz), nil
|
||||
}
|
||||
|
||||
_, err = PatchToCommit(patch, files)
|
||||
if err != nil {
|
||||
return false, err.Error(), nil
|
||||
}
|
||||
|
||||
return true, "Patch is valid", nil
|
||||
}
|
|
@ -50,6 +50,7 @@ func NewService(q *db.Queries, db *sql.DB) Service {
|
|||
return &service{
|
||||
Broker: pubsub.NewBroker[File](),
|
||||
q: q,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -100,30 +101,30 @@ func (s *service) createWithVersion(ctx context.Context, sessionID, path, conten
|
|||
var err error
|
||||
|
||||
// Retry loop for transaction conflicts
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
for attempt := range maxRetries {
|
||||
// Start a transaction
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return File{}, fmt.Errorf("failed to begin transaction: %w", err)
|
||||
tx, txErr := s.db.Begin()
|
||||
if txErr != nil {
|
||||
return File{}, fmt.Errorf("failed to begin transaction: %w", txErr)
|
||||
}
|
||||
|
||||
// Create a new queries instance with the transaction
|
||||
qtx := s.q.WithTx(tx)
|
||||
|
||||
// Try to create the file within the transaction
|
||||
dbFile, err := qtx.CreateFile(ctx, db.CreateFileParams{
|
||||
dbFile, txErr := qtx.CreateFile(ctx, db.CreateFileParams{
|
||||
ID: uuid.New().String(),
|
||||
SessionID: sessionID,
|
||||
Path: path,
|
||||
Content: content,
|
||||
Version: version,
|
||||
})
|
||||
if err != nil {
|
||||
if txErr != nil {
|
||||
// Rollback the transaction
|
||||
tx.Rollback()
|
||||
|
||||
// Check if this is a uniqueness constraint violation
|
||||
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
if strings.Contains(txErr.Error(), "UNIQUE constraint failed") {
|
||||
if attempt < maxRetries-1 {
|
||||
// If we have retries left, generate a new version and try again
|
||||
if strings.HasPrefix(version, "v") {
|
||||
|
@ -138,12 +139,12 @@ func (s *service) createWithVersion(ctx context.Context, sessionID, path, conten
|
|||
continue
|
||||
}
|
||||
}
|
||||
return File{}, err
|
||||
return File{}, txErr
|
||||
}
|
||||
|
||||
// Commit the transaction
|
||||
if err = tx.Commit(); err != nil {
|
||||
return File{}, fmt.Errorf("failed to commit transaction: %w", err)
|
||||
if txErr = tx.Commit(); txErr != nil {
|
||||
return File{}, fmt.Errorf("failed to commit transaction: %w", txErr)
|
||||
}
|
||||
|
||||
file = s.fromDBItem(dbFile)
|
||||
|
|
|
@ -41,6 +41,7 @@ type Service interface {
|
|||
Run(ctx context.Context, sessionID string, content string) (<-chan AgentEvent, error)
|
||||
Cancel(sessionID string)
|
||||
IsSessionBusy(sessionID string) bool
|
||||
IsBusy() bool
|
||||
}
|
||||
|
||||
type agent struct {
|
||||
|
@ -95,6 +96,20 @@ func (a *agent) Cancel(sessionID string) {
|
|||
}
|
||||
}
|
||||
|
||||
func (a *agent) IsBusy() bool {
|
||||
busy := false
|
||||
a.activeRequests.Range(func(key, value interface{}) bool {
|
||||
if cancelFunc, ok := value.(context.CancelFunc); ok {
|
||||
if cancelFunc != nil {
|
||||
busy = true
|
||||
return false // Stop iterating
|
||||
}
|
||||
}
|
||||
return true // Continue iterating
|
||||
})
|
||||
return busy
|
||||
}
|
||||
|
||||
func (a *agent) IsSessionBusy(sessionID string) bool {
|
||||
_, busy := a.activeRequests.Load(sessionID)
|
||||
return busy
|
||||
|
@ -313,23 +328,8 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg
|
|||
}
|
||||
}
|
||||
a.finishMessage(ctx, &assistantMsg, message.FinishReasonPermissionDenied)
|
||||
} else {
|
||||
toolResults[i] = message.ToolResult{
|
||||
ToolCallID: toolCall.ID,
|
||||
Content: toolErr.Error(),
|
||||
IsError: true,
|
||||
}
|
||||
for j := i; j < len(toolCalls); j++ {
|
||||
toolResults[j] = message.ToolResult{
|
||||
ToolCallID: toolCalls[j].ID,
|
||||
Content: "Previous tool failed",
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
a.finishMessage(ctx, &assistantMsg, message.FinishReasonError)
|
||||
break
|
||||
}
|
||||
// If permission is denied or an error happens we cancel all the following tools
|
||||
break
|
||||
}
|
||||
toolResults[i] = message.ToolResult{
|
||||
ToolCallID: toolCall.ID,
|
||||
|
@ -437,12 +437,27 @@ func createAgentProvider(agentName config.AgentName) (provider.Provider, error)
|
|||
if providerCfg.Disabled {
|
||||
return nil, fmt.Errorf("provider %s is not enabled", model.Provider)
|
||||
}
|
||||
agentProvider, err := provider.NewProvider(
|
||||
model.Provider,
|
||||
maxTokens := model.DefaultMaxTokens
|
||||
if agentConfig.MaxTokens > 0 {
|
||||
maxTokens = agentConfig.MaxTokens
|
||||
}
|
||||
opts := []provider.ProviderClientOption{
|
||||
provider.WithAPIKey(providerCfg.APIKey),
|
||||
provider.WithModel(model),
|
||||
provider.WithSystemMessage(prompt.GetAgentPrompt(agentName, model.Provider)),
|
||||
provider.WithMaxTokens(agentConfig.MaxTokens),
|
||||
provider.WithMaxTokens(maxTokens),
|
||||
}
|
||||
if model.Provider == models.ProviderOpenAI && model.CanReason {
|
||||
opts = append(
|
||||
opts,
|
||||
provider.WithOpenAIOptions(
|
||||
provider.WithReasoningEffort(agentConfig.ReasoningEffort),
|
||||
),
|
||||
)
|
||||
}
|
||||
agentProvider, err := provider.NewProvider(
|
||||
model.Provider,
|
||||
opts...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not create provider: %v", err)
|
||||
|
|
|
@ -31,10 +31,9 @@ func CoderAgentTools(
|
|||
tools.NewGlobTool(),
|
||||
tools.NewGrepTool(),
|
||||
tools.NewLsTool(),
|
||||
// TODO: see if we want to use this tool
|
||||
// tools.NewPatchTool(lspClients, permissions, history),
|
||||
tools.NewSourcegraphTool(),
|
||||
tools.NewViewTool(lspClients),
|
||||
tools.NewPatchTool(lspClients, permissions, history),
|
||||
tools.NewWriteTool(lspClients, permissions, history),
|
||||
NewAgentTool(sessions, messages, lspClients),
|
||||
}, otherTools...,
|
||||
|
|
|
@ -23,6 +23,7 @@ var AnthropicModels = map[ModelID]Model{
|
|||
CostPer1MOutCached: 0.30,
|
||||
CostPer1MOut: 15.0,
|
||||
ContextWindow: 200000,
|
||||
DefaultMaxTokens: 5000,
|
||||
},
|
||||
Claude3Haiku: {
|
||||
ID: Claude3Haiku,
|
||||
|
@ -34,6 +35,7 @@ var AnthropicModels = map[ModelID]Model{
|
|||
CostPer1MOutCached: 0.03,
|
||||
CostPer1MOut: 1.25,
|
||||
ContextWindow: 200000,
|
||||
DefaultMaxTokens: 5000,
|
||||
},
|
||||
Claude37Sonnet: {
|
||||
ID: Claude37Sonnet,
|
||||
|
@ -45,6 +47,8 @@ var AnthropicModels = map[ModelID]Model{
|
|||
CostPer1MOutCached: 0.30,
|
||||
CostPer1MOut: 15.0,
|
||||
ContextWindow: 200000,
|
||||
DefaultMaxTokens: 50000,
|
||||
CanReason: true,
|
||||
},
|
||||
Claude35Haiku: {
|
||||
ID: Claude35Haiku,
|
||||
|
@ -56,6 +60,7 @@ var AnthropicModels = map[ModelID]Model{
|
|||
CostPer1MOutCached: 0.08,
|
||||
CostPer1MOut: 4.0,
|
||||
ContextWindow: 200000,
|
||||
DefaultMaxTokens: 4096,
|
||||
},
|
||||
Claude3Opus: {
|
||||
ID: Claude3Opus,
|
||||
|
@ -67,5 +72,6 @@ var AnthropicModels = map[ModelID]Model{
|
|||
CostPer1MOutCached: 1.50,
|
||||
CostPer1MOut: 75.0,
|
||||
ContextWindow: 200000,
|
||||
DefaultMaxTokens: 4096,
|
||||
},
|
||||
}
|
||||
|
|
|
@ -17,15 +17,12 @@ type Model struct {
|
|||
CostPer1MInCached float64 `json:"cost_per_1m_in_cached"`
|
||||
CostPer1MOutCached float64 `json:"cost_per_1m_out_cached"`
|
||||
ContextWindow int64 `json:"context_window"`
|
||||
DefaultMaxTokens int64 `json:"default_max_tokens"`
|
||||
CanReason bool `json:"can_reason"`
|
||||
}
|
||||
|
||||
// Model IDs
|
||||
const (
|
||||
// OpenAI
|
||||
GPT4o ModelID = "gpt-4o"
|
||||
GPT41 ModelID = "gpt-4.1"
|
||||
|
||||
// GEMINI
|
||||
const ( // GEMINI
|
||||
GEMINI25 ModelID = "gemini-2.5"
|
||||
GRMINI20Flash ModelID = "gemini-2.0-flash"
|
||||
|
||||
|
@ -37,7 +34,6 @@ const (
|
|||
)
|
||||
|
||||
const (
|
||||
ProviderOpenAI ModelProvider = "openai"
|
||||
ProviderBedrock ModelProvider = "bedrock"
|
||||
ProviderGemini ModelProvider = "gemini"
|
||||
ProviderGROQ ModelProvider = "groq"
|
||||
|
@ -47,59 +43,6 @@ const (
|
|||
)
|
||||
|
||||
var SupportedModels = map[ModelID]Model{
|
||||
// // Anthropic
|
||||
// Claude35Sonnet: {
|
||||
// ID: Claude35Sonnet,
|
||||
// Name: "Claude 3.5 Sonnet",
|
||||
// Provider: ProviderAnthropic,
|
||||
// APIModel: "claude-3-5-sonnet-latest",
|
||||
// CostPer1MIn: 3.0,
|
||||
// CostPer1MInCached: 3.75,
|
||||
// CostPer1MOutCached: 0.30,
|
||||
// CostPer1MOut: 15.0,
|
||||
// },
|
||||
// Claude3Haiku: {
|
||||
// ID: Claude3Haiku,
|
||||
// Name: "Claude 3 Haiku",
|
||||
// Provider: ProviderAnthropic,
|
||||
// APIModel: "claude-3-haiku-latest",
|
||||
// CostPer1MIn: 0.80,
|
||||
// CostPer1MInCached: 1,
|
||||
// CostPer1MOutCached: 0.08,
|
||||
// CostPer1MOut: 4,
|
||||
// },
|
||||
// Claude37Sonnet: {
|
||||
// ID: Claude37Sonnet,
|
||||
// Name: "Claude 3.7 Sonnet",
|
||||
// Provider: ProviderAnthropic,
|
||||
// APIModel: "claude-3-7-sonnet-latest",
|
||||
// CostPer1MIn: 3.0,
|
||||
// CostPer1MInCached: 3.75,
|
||||
// CostPer1MOutCached: 0.30,
|
||||
// CostPer1MOut: 15.0,
|
||||
// },
|
||||
//
|
||||
// // OpenAI
|
||||
GPT4o: {
|
||||
ID: GPT4o,
|
||||
Name: "GPT-4o",
|
||||
Provider: ProviderOpenAI,
|
||||
APIModel: "gpt-4.1",
|
||||
CostPer1MIn: 2.00,
|
||||
CostPer1MInCached: 0.50,
|
||||
CostPer1MOutCached: 0,
|
||||
CostPer1MOut: 8.00,
|
||||
},
|
||||
GPT41: {
|
||||
ID: GPT41,
|
||||
Name: "GPT-4.1",
|
||||
Provider: ProviderOpenAI,
|
||||
APIModel: "gpt-4.1",
|
||||
CostPer1MIn: 2.00,
|
||||
CostPer1MInCached: 0.50,
|
||||
CostPer1MOutCached: 0,
|
||||
CostPer1MOut: 8.00,
|
||||
},
|
||||
//
|
||||
// // GEMINI
|
||||
// GEMINI25: {
|
||||
|
@ -151,4 +94,5 @@ var SupportedModels = map[ModelID]Model{
|
|||
|
||||
func init() {
|
||||
maps.Copy(SupportedModels, AnthropicModels)
|
||||
maps.Copy(SupportedModels, OpenAIModels)
|
||||
}
|
||||
|
|
169
internal/llm/models/openai.go
Normal file
169
internal/llm/models/openai.go
Normal file
|
@ -0,0 +1,169 @@
|
|||
package models
|
||||
|
||||
const (
|
||||
ProviderOpenAI ModelProvider = "openai"
|
||||
|
||||
GPT41 ModelID = "gpt-4.1"
|
||||
GPT41Mini ModelID = "gpt-4.1-mini"
|
||||
GPT41Nano ModelID = "gpt-4.1-nano"
|
||||
GPT45Preview ModelID = "gpt-4.5-preview"
|
||||
GPT4o ModelID = "gpt-4o"
|
||||
GPT4oMini ModelID = "gpt-4o-mini"
|
||||
O1 ModelID = "o1"
|
||||
O1Pro ModelID = "o1-pro"
|
||||
O1Mini ModelID = "o1-mini"
|
||||
O3 ModelID = "o3"
|
||||
O3Mini ModelID = "o3-mini"
|
||||
O4Mini ModelID = "o4-mini"
|
||||
)
|
||||
|
||||
var OpenAIModels = map[ModelID]Model{
|
||||
GPT41: {
|
||||
ID: GPT41,
|
||||
Name: "GPT 4.1",
|
||||
Provider: ProviderOpenAI,
|
||||
APIModel: "gpt-4.1",
|
||||
CostPer1MIn: 2.00,
|
||||
CostPer1MInCached: 0.50,
|
||||
CostPer1MOutCached: 0.0,
|
||||
CostPer1MOut: 8.00,
|
||||
ContextWindow: 1_047_576,
|
||||
DefaultMaxTokens: 20000,
|
||||
},
|
||||
GPT41Mini: {
|
||||
ID: GPT41Mini,
|
||||
Name: "GPT 4.1 mini",
|
||||
Provider: ProviderOpenAI,
|
||||
APIModel: "gpt-4.1",
|
||||
CostPer1MIn: 0.40,
|
||||
CostPer1MInCached: 0.10,
|
||||
CostPer1MOutCached: 0.0,
|
||||
CostPer1MOut: 1.60,
|
||||
ContextWindow: 200_000,
|
||||
DefaultMaxTokens: 20000,
|
||||
},
|
||||
GPT41Nano: {
|
||||
ID: GPT41Nano,
|
||||
Name: "GPT 4.1 nano",
|
||||
Provider: ProviderOpenAI,
|
||||
APIModel: "gpt-4.1-nano",
|
||||
CostPer1MIn: 0.10,
|
||||
CostPer1MInCached: 0.025,
|
||||
CostPer1MOutCached: 0.0,
|
||||
CostPer1MOut: 0.40,
|
||||
ContextWindow: 1_047_576,
|
||||
DefaultMaxTokens: 20000,
|
||||
},
|
||||
GPT45Preview: {
|
||||
ID: GPT45Preview,
|
||||
Name: "GPT 4.5 preview",
|
||||
Provider: ProviderOpenAI,
|
||||
APIModel: "gpt-4.5-preview",
|
||||
CostPer1MIn: 75.00,
|
||||
CostPer1MInCached: 37.50,
|
||||
CostPer1MOutCached: 0.0,
|
||||
CostPer1MOut: 150.00,
|
||||
ContextWindow: 128_000,
|
||||
DefaultMaxTokens: 15000,
|
||||
},
|
||||
GPT4o: {
|
||||
ID: GPT4o,
|
||||
Name: "GPT 4o",
|
||||
Provider: ProviderOpenAI,
|
||||
APIModel: "gpt-4o",
|
||||
CostPer1MIn: 2.50,
|
||||
CostPer1MInCached: 1.25,
|
||||
CostPer1MOutCached: 0.0,
|
||||
CostPer1MOut: 10.00,
|
||||
ContextWindow: 128_000,
|
||||
DefaultMaxTokens: 4096,
|
||||
},
|
||||
GPT4oMini: {
|
||||
ID: GPT4oMini,
|
||||
Name: "GPT 4o mini",
|
||||
Provider: ProviderOpenAI,
|
||||
APIModel: "gpt-4o-mini",
|
||||
CostPer1MIn: 0.15,
|
||||
CostPer1MInCached: 0.075,
|
||||
CostPer1MOutCached: 0.0,
|
||||
CostPer1MOut: 0.60,
|
||||
ContextWindow: 128_000,
|
||||
},
|
||||
O1: {
|
||||
ID: O1,
|
||||
Name: "O1",
|
||||
Provider: ProviderOpenAI,
|
||||
APIModel: "o1",
|
||||
CostPer1MIn: 15.00,
|
||||
CostPer1MInCached: 7.50,
|
||||
CostPer1MOutCached: 0.0,
|
||||
CostPer1MOut: 60.00,
|
||||
ContextWindow: 200_000,
|
||||
DefaultMaxTokens: 50000,
|
||||
CanReason: true,
|
||||
},
|
||||
O1Pro: {
|
||||
ID: O1Pro,
|
||||
Name: "o1 pro",
|
||||
Provider: ProviderOpenAI,
|
||||
APIModel: "o1-pro",
|
||||
CostPer1MIn: 150.00,
|
||||
CostPer1MInCached: 0.0,
|
||||
CostPer1MOutCached: 0.0,
|
||||
CostPer1MOut: 600.00,
|
||||
ContextWindow: 200_000,
|
||||
DefaultMaxTokens: 50000,
|
||||
CanReason: true,
|
||||
},
|
||||
O1Mini: {
|
||||
ID: O1Mini,
|
||||
Name: "o1 mini",
|
||||
Provider: ProviderOpenAI,
|
||||
APIModel: "o1-mini",
|
||||
CostPer1MIn: 1.10,
|
||||
CostPer1MInCached: 0.55,
|
||||
CostPer1MOutCached: 0.0,
|
||||
CostPer1MOut: 4.40,
|
||||
ContextWindow: 128_000,
|
||||
DefaultMaxTokens: 50000,
|
||||
CanReason: true,
|
||||
},
|
||||
O3: {
|
||||
ID: O3,
|
||||
Name: "o3",
|
||||
Provider: ProviderOpenAI,
|
||||
APIModel: "o3",
|
||||
CostPer1MIn: 10.00,
|
||||
CostPer1MInCached: 2.50,
|
||||
CostPer1MOutCached: 0.0,
|
||||
CostPer1MOut: 40.00,
|
||||
ContextWindow: 200_000,
|
||||
CanReason: true,
|
||||
},
|
||||
O3Mini: {
|
||||
ID: O3Mini,
|
||||
Name: "o3 mini",
|
||||
Provider: ProviderOpenAI,
|
||||
APIModel: "o3-mini",
|
||||
CostPer1MIn: 1.10,
|
||||
CostPer1MInCached: 0.55,
|
||||
CostPer1MOutCached: 0.0,
|
||||
CostPer1MOut: 4.40,
|
||||
ContextWindow: 200_000,
|
||||
DefaultMaxTokens: 50000,
|
||||
CanReason: true,
|
||||
},
|
||||
O4Mini: {
|
||||
ID: O4Mini,
|
||||
Name: "o4 mini",
|
||||
Provider: ProviderOpenAI,
|
||||
APIModel: "o4-mini",
|
||||
CostPer1MIn: 1.10,
|
||||
CostPer1MInCached: 0.275,
|
||||
CostPer1MOutCached: 0.0,
|
||||
CostPer1MOut: 4.40,
|
||||
ContextWindow: 128_000,
|
||||
DefaultMaxTokens: 50000,
|
||||
CanReason: true,
|
||||
},
|
||||
}
|
|
@ -25,44 +25,49 @@ func CoderPrompt(provider models.ModelProvider) string {
|
|||
}
|
||||
|
||||
const baseOpenAICoderPrompt = `
|
||||
You are **OpenCode**, an autonomous CLI assistant for software‑engineering tasks.
|
||||
You are operating as and within the OpenCode CLI, a terminal-based agentic coding assistant built by OpenAI. It wraps OpenAI models to enable natural language interaction with a local codebase. You are expected to be precise, safe, and helpful.
|
||||
|
||||
### ── INTERNAL REFLECTION ──
|
||||
• Silently think step‑by‑step about the user request, directory layout, and tool calls (never reveal this).
|
||||
• Formulate a plan, then execute without further approval unless a blocker triggers the Ask‑Only‑If rules.
|
||||
You can:
|
||||
- Receive user prompts, project context, and files.
|
||||
- Stream responses and emit function calls (e.g., shell commands, code edits).
|
||||
- Apply patches, run commands, and manage user approvals based on policy.
|
||||
- Work inside a sandboxed, git-backed workspace with rollback support.
|
||||
- Log telemetry so sessions can be replayed or inspected later.
|
||||
- More details on your functionality are available at "opencode --help"
|
||||
|
||||
### ── PUBLIC RESPONSE RULES ──
|
||||
• Visible reply ≤ 4 lines; no fluff, preamble, or postamble.
|
||||
• Use GitHub‑flavored Markdown.
|
||||
• When running a non‑trivial shell command, add ≤ 1 brief purpose sentence.
|
||||
|
||||
### ── CONTEXT & MEMORY ──
|
||||
• Infer file intent from directory structure before editing.
|
||||
• Auto‑load 'OpenCode.md'; ask once before writing new reusable commands or style notes.
|
||||
You are an agent - please keep going until the user's query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved. If you are not sure about file content or codebase structure pertaining to the user's request, use your tools to read files and gather the relevant information: do NOT guess or make up an answer.
|
||||
|
||||
### ── AUTONOMY PRIORITY ──
|
||||
**Ask‑Only‑If Decision Tree:**
|
||||
1. **Safety risk?** (e.g., destructive command, secret exposure) → ask.
|
||||
2. **Critical unknown?** (no docs/tests; cannot infer) → ask.
|
||||
3. **Tool failure after two self‑attempts?** → ask.
|
||||
Otherwise, proceed autonomously.
|
||||
Please resolve the user's task by editing and testing the code files in your current code execution session. You are a deployed coding agent. Your session allows for you to modify and run code. The repo(s) are already cloned in your working directory, and you must fully solve the problem for your answer to be considered correct.
|
||||
|
||||
### ── SAFETY & STYLE ──
|
||||
• Mimic existing code style; verify libraries exist before import.
|
||||
• Never commit unless explicitly told.
|
||||
• After edits, run lint & type‑check (ask for commands once, then offer to store in 'OpenCode.md').
|
||||
• Protect secrets; follow standard security practices :contentReference[oaicite:2]{index=2}.
|
||||
|
||||
### ── TOOL USAGE ──
|
||||
• Batch independent Agent search/file calls in one block for efficiency :contentReference[oaicite:3]{index=3}.
|
||||
• Communicate with the user only via visible text; do not expose tool output or internal reasoning.
|
||||
|
||||
### ── EXAMPLES ──
|
||||
user: list files
|
||||
assistant: ls
|
||||
|
||||
user: write tests for new feature
|
||||
assistant: [searches & edits autonomously, no extra chit‑chat]
|
||||
You MUST adhere to the following criteria when executing the task:
|
||||
- Working on the repo(s) in the current environment is allowed, even if they are proprietary.
|
||||
- Analyzing code for vulnerabilities is allowed.
|
||||
- Showing user code and tool call details is allowed.
|
||||
- User instructions may overwrite the *CODING GUIDELINES* section in this developer message.
|
||||
- If completing the user's task requires writing or modifying files:
|
||||
- Your code and final answer should follow these *CODING GUIDELINES*:
|
||||
- Fix the problem at the root cause rather than applying surface-level patches, when possible.
|
||||
- Avoid unneeded complexity in your solution.
|
||||
- Ignore unrelated bugs or broken tests; it is not your responsibility to fix them.
|
||||
- Update documentation as necessary.
|
||||
- Keep changes consistent with the style of the existing codebase. Changes should be minimal and focused on the task.
|
||||
- Use "git log" and "git blame" to search the history of the codebase if additional context is required; internet access is disabled.
|
||||
- NEVER add copyright or license headers unless specifically requested.
|
||||
- You do not need to "git commit" your changes; this will be done automatically for you.
|
||||
- Once you finish coding, you must
|
||||
- Check "git status" to sanity check your changes; revert any scratch files or changes.
|
||||
- Remove all inline comments you added as much as possible, even if they look normal. Check using "git diff". Inline comments must be generally avoided, unless active maintainers of the repo, after long careful study of the code and the issue, will still misinterpret the code without the comments.
|
||||
- Check if you accidentally add copyright or license headers. If so, remove them.
|
||||
- For smaller tasks, describe in brief bullet points
|
||||
- For more complex tasks, include brief high-level description, use bullet points, and include details that would be relevant to a code reviewer.
|
||||
- If completing the user's task DOES NOT require writing or modifying files (e.g., the user asks a question about the code base):
|
||||
- Respond in a friendly tune as a remote teammate, who is knowledgeable, capable and eager to help with coding.
|
||||
- When your task involves writing or modifying files:
|
||||
- Do NOT tell the user to "save the file" or "copy the code into a file" if you already created or modified the file using "apply_patch". Instead, reference the file as already saved.
|
||||
- Do NOT show the full contents of large files you have already written, unless the user explicitly asks for them.
|
||||
- When doing things with paths, always use use the full path, if the working directory is /abc/xyz and you want to edit the file abc.go in the working dir refer to it as /abc/xyz/abc.go.
|
||||
- If you send a path not including the working dir, the working dir will be prepended to it.
|
||||
`
|
||||
|
||||
const baseAnthropicCoderPrompt = `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.
|
||||
|
@ -125,7 +130,7 @@ assistant: src/foo.c
|
|||
|
||||
<example>
|
||||
user: write tests for new feature
|
||||
assistant: [uses grep and glob search tools to find where similar tests are defined, uses concurrent read file tool use blocks in one tool call to read relevant files at the same time, uses edit file tool to write new tests]
|
||||
assistant: [uses grep and glob search tools to find where similar tests are defined, uses concurrent read file tool use blocks in one tool call to read relevant files at the same time, uses edit/patch file tool to write new tests]
|
||||
</example>
|
||||
|
||||
# Proactiveness
|
||||
|
|
|
@ -14,11 +14,13 @@ import (
|
|||
"github.com/kujtimiihoxha/opencode/internal/message"
|
||||
"github.com/openai/openai-go"
|
||||
"github.com/openai/openai-go/option"
|
||||
"github.com/openai/openai-go/shared"
|
||||
)
|
||||
|
||||
type openaiOptions struct {
|
||||
baseURL string
|
||||
disableCache bool
|
||||
baseURL string
|
||||
disableCache bool
|
||||
reasoningEffort string
|
||||
}
|
||||
|
||||
type OpenAIOption func(*openaiOptions)
|
||||
|
@ -32,7 +34,9 @@ type openaiClient struct {
|
|||
type OpenAIClient ProviderClient
|
||||
|
||||
func newOpenAIClient(opts providerClientOptions) OpenAIClient {
|
||||
openaiOpts := openaiOptions{}
|
||||
openaiOpts := openaiOptions{
|
||||
reasoningEffort: "medium",
|
||||
}
|
||||
for _, o := range opts.openaiOptions {
|
||||
o(&openaiOpts)
|
||||
}
|
||||
|
@ -138,12 +142,29 @@ func (o *openaiClient) finishReason(reason string) message.FinishReason {
|
|||
}
|
||||
|
||||
func (o *openaiClient) preparedParams(messages []openai.ChatCompletionMessageParamUnion, tools []openai.ChatCompletionToolParam) openai.ChatCompletionNewParams {
|
||||
return openai.ChatCompletionNewParams{
|
||||
Model: openai.ChatModel(o.providerOptions.model.APIModel),
|
||||
Messages: messages,
|
||||
MaxTokens: openai.Int(o.providerOptions.maxTokens),
|
||||
Tools: tools,
|
||||
params := openai.ChatCompletionNewParams{
|
||||
Model: openai.ChatModel(o.providerOptions.model.APIModel),
|
||||
Messages: messages,
|
||||
Tools: tools,
|
||||
}
|
||||
|
||||
if o.providerOptions.model.CanReason == true {
|
||||
params.MaxCompletionTokens = openai.Int(o.providerOptions.maxTokens)
|
||||
switch o.options.reasoningEffort {
|
||||
case "low":
|
||||
params.ReasoningEffort = shared.ReasoningEffortLow
|
||||
case "medium":
|
||||
params.ReasoningEffort = shared.ReasoningEffortMedium
|
||||
case "high":
|
||||
params.ReasoningEffort = shared.ReasoningEffortHigh
|
||||
default:
|
||||
params.ReasoningEffort = shared.ReasoningEffortMedium
|
||||
}
|
||||
} else {
|
||||
params.MaxTokens = openai.Int(o.providerOptions.maxTokens)
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
func (o *openaiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) {
|
||||
|
@ -359,3 +380,15 @@ func WithOpenAIDisableCache() OpenAIOption {
|
|||
}
|
||||
}
|
||||
|
||||
func WithReasoningEffort(effort string) OpenAIOption {
|
||||
return func(options *openaiOptions) {
|
||||
defaultReasoningEffort := "medium"
|
||||
switch effort {
|
||||
case "low", "medium", "high":
|
||||
defaultReasoningEffort = effort
|
||||
default:
|
||||
logging.Warn("Invalid reasoning effort, using default: medium")
|
||||
}
|
||||
options.reasoningEffort = defaultReasoningEffort
|
||||
}
|
||||
}
|
||||
|
|
|
@ -192,6 +192,42 @@ func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) {
|
|||
}
|
||||
|
||||
func skipHidden(path string) bool {
|
||||
// Check for hidden files (starting with a dot)
|
||||
base := filepath.Base(path)
|
||||
return base != "." && strings.HasPrefix(base, ".")
|
||||
if base != "." && strings.HasPrefix(base, ".") {
|
||||
return true
|
||||
}
|
||||
|
||||
// List of commonly ignored directories in development projects
|
||||
commonIgnoredDirs := map[string]bool{
|
||||
"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,
|
||||
}
|
||||
|
||||
// Check if any path component is in our ignore list
|
||||
parts := strings.SplitSeq(path, string(os.PathSeparator))
|
||||
for part := range parts {
|
||||
if commonIgnoredDirs[part] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
|
|
@ -17,9 +17,10 @@ import (
|
|||
)
|
||||
|
||||
type GrepParams struct {
|
||||
Pattern string `json:"pattern"`
|
||||
Path string `json:"path"`
|
||||
Include string `json:"include"`
|
||||
Pattern string `json:"pattern"`
|
||||
Path string `json:"path"`
|
||||
Include string `json:"include"`
|
||||
LiteralText bool `json:"literal_text"`
|
||||
}
|
||||
|
||||
type grepMatch struct {
|
||||
|
@ -45,11 +46,12 @@ WHEN TO USE THIS TOOL:
|
|||
|
||||
HOW TO USE:
|
||||
- Provide a regex pattern to search for within file contents
|
||||
- Set literal_text=true if you want to search for the exact text with special characters (recommended for non-regex users)
|
||||
- Optionally specify a starting directory (defaults to current working directory)
|
||||
- Optionally provide an include pattern to filter which files to search
|
||||
- Results are sorted with most recently modified files first
|
||||
|
||||
REGEX PATTERN SYNTAX:
|
||||
REGEX PATTERN SYNTAX (when literal_text=false):
|
||||
- Supports standard regular expression syntax
|
||||
- 'function' searches for the literal text "function"
|
||||
- 'log\..*Error' finds text starting with "log." and ending with "Error"
|
||||
|
@ -69,7 +71,8 @@ LIMITATIONS:
|
|||
TIPS:
|
||||
- For faster, more targeted searches, first use Glob to find relevant files, then use Grep
|
||||
- When doing iterative exploration that may require multiple rounds of searching, consider using the Agent tool instead
|
||||
- Always check if results are truncated and refine your search pattern if needed`
|
||||
- Always check if results are truncated and refine your search pattern if needed
|
||||
- Use literal_text=true when searching for exact text containing special characters like dots, parentheses, etc.`
|
||||
)
|
||||
|
||||
func NewGrepTool() BaseTool {
|
||||
|
@ -93,11 +96,27 @@ func (g *grepTool) Info() ToolInfo {
|
|||
"type": "string",
|
||||
"description": "File pattern to include in the search (e.g. \"*.js\", \"*.{ts,tsx}\")",
|
||||
},
|
||||
"literal_text": map[string]any{
|
||||
"type": "boolean",
|
||||
"description": "If true, the pattern will be treated as literal text with special regex characters escaped. Default is false.",
|
||||
},
|
||||
},
|
||||
Required: []string{"pattern"},
|
||||
}
|
||||
}
|
||||
|
||||
// escapeRegexPattern escapes special regex characters so they're treated as literal characters
|
||||
func escapeRegexPattern(pattern string) string {
|
||||
specialChars := []string{"\\", ".", "+", "*", "?", "(", ")", "[", "]", "{", "}", "^", "$", "|"}
|
||||
escaped := pattern
|
||||
|
||||
for _, char := range specialChars {
|
||||
escaped = strings.ReplaceAll(escaped, char, "\\"+char)
|
||||
}
|
||||
|
||||
return escaped
|
||||
}
|
||||
|
||||
func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
|
||||
var params GrepParams
|
||||
if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
|
||||
|
@ -108,12 +127,18 @@ func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
|
|||
return NewTextErrorResponse("pattern is required"), nil
|
||||
}
|
||||
|
||||
// If literal_text is true, escape the pattern
|
||||
searchPattern := params.Pattern
|
||||
if params.LiteralText {
|
||||
searchPattern = escapeRegexPattern(params.Pattern)
|
||||
}
|
||||
|
||||
searchPath := params.Path
|
||||
if searchPath == "" {
|
||||
searchPath = config.WorkingDirectory()
|
||||
}
|
||||
|
||||
matches, truncated, err := searchFiles(params.Pattern, searchPath, params.Include, 100)
|
||||
matches, truncated, err := searchFiles(searchPattern, searchPath, params.Include, 100)
|
||||
if err != nil {
|
||||
return ToolResponse{}, fmt.Errorf("error searching files: %w", err)
|
||||
}
|
||||
|
|
|
@ -6,7 +6,6 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kujtimiihoxha/opencode/internal/config"
|
||||
|
@ -17,19 +16,13 @@ import (
|
|||
)
|
||||
|
||||
type PatchParams struct {
|
||||
FilePath string `json:"file_path"`
|
||||
Patch string `json:"patch"`
|
||||
}
|
||||
|
||||
type PatchPermissionsParams struct {
|
||||
FilePath string `json:"file_path"`
|
||||
Diff string `json:"diff"`
|
||||
PatchText string `json:"patch_text"`
|
||||
}
|
||||
|
||||
type PatchResponseMetadata struct {
|
||||
Diff string `json:"diff"`
|
||||
Additions int `json:"additions"`
|
||||
Removals int `json:"removals"`
|
||||
FilesChanged []string `json:"files_changed"`
|
||||
Additions int `json:"additions"`
|
||||
Removals int `json:"removals"`
|
||||
}
|
||||
|
||||
type patchTool struct {
|
||||
|
@ -39,47 +32,35 @@ type patchTool struct {
|
|||
}
|
||||
|
||||
const (
|
||||
// TODO: test if this works as expected
|
||||
PatchToolName = "patch"
|
||||
patchDescription = `Applies a patch to a file. This tool is similar to the edit tool but accepts a unified diff patch instead of old/new strings.
|
||||
patchDescription = `Applies a patch to multiple files in one operation. This tool is useful for making coordinated changes across multiple files.
|
||||
|
||||
The patch text must follow this format:
|
||||
*** Begin Patch
|
||||
*** Update File: /path/to/file
|
||||
@@ Context line (unique within the file)
|
||||
Line to keep
|
||||
-Line to remove
|
||||
+Line to add
|
||||
Line to keep
|
||||
*** Add File: /path/to/new/file
|
||||
+Content of the new file
|
||||
+More content
|
||||
*** Delete File: /path/to/file/to/delete
|
||||
*** End Patch
|
||||
|
||||
Before using this tool:
|
||||
|
||||
1. Use the FileRead tool to understand the file's contents and context
|
||||
|
||||
2. Verify the directory path is correct:
|
||||
- Use the LS tool to verify the parent directory exists and is the correct location
|
||||
|
||||
To apply a patch, provide the following:
|
||||
1. file_path: The absolute path to the file to modify (must be absolute, not relative)
|
||||
2. patch: A unified diff patch to apply to the file
|
||||
|
||||
The tool will apply the patch to the specified file. The patch must be in unified diff format.
|
||||
1. Use the FileRead tool to understand the files' contents and context
|
||||
2. Verify all file paths are correct (use the LS tool)
|
||||
|
||||
CRITICAL REQUIREMENTS FOR USING THIS TOOL:
|
||||
|
||||
1. PATCH FORMAT: The patch must be in unified diff format, which includes:
|
||||
- File headers (--- a/file_path, +++ b/file_path)
|
||||
- Hunk headers (@@ -start,count +start,count @@)
|
||||
- Added lines (prefixed with +)
|
||||
- Removed lines (prefixed with -)
|
||||
1. UNIQUENESS: Context lines MUST uniquely identify the specific sections you want to change
|
||||
2. PRECISION: All whitespace, indentation, and surrounding code must match exactly
|
||||
3. VALIDATION: Ensure edits result in idiomatic, correct code
|
||||
4. PATHS: Always use absolute file paths (starting with /)
|
||||
|
||||
2. CONTEXT: The patch must include sufficient context around the changes to ensure it applies correctly.
|
||||
|
||||
3. VERIFICATION: Before using this tool:
|
||||
- Ensure the patch applies cleanly to the current state of the file
|
||||
- Check that the file exists and you have read it first
|
||||
|
||||
WARNING: If you do not follow these requirements:
|
||||
- The tool will fail if the patch doesn't apply cleanly
|
||||
- You may change the wrong parts of the file if the context is insufficient
|
||||
|
||||
When applying patches:
|
||||
- Ensure the patch results in idiomatic, correct code
|
||||
- Do not leave the code in a broken state
|
||||
- Always use absolute file paths (starting with /)
|
||||
|
||||
Remember: patches are a powerful way to make multiple related changes at once, but they require careful preparation.`
|
||||
The tool will apply all changes in a single atomic operation.`
|
||||
)
|
||||
|
||||
func NewPatchTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service) BaseTool {
|
||||
|
@ -95,16 +76,12 @@ func (p *patchTool) Info() ToolInfo {
|
|||
Name: PatchToolName,
|
||||
Description: patchDescription,
|
||||
Parameters: map[string]any{
|
||||
"file_path": map[string]any{
|
||||
"patch_text": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The absolute path to the file to modify",
|
||||
},
|
||||
"patch": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The unified diff patch to apply",
|
||||
"description": "The full patch text that describes all changes to be made",
|
||||
},
|
||||
},
|
||||
Required: []string{"file_path", "patch"},
|
||||
Required: []string{"patch_text"},
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -114,187 +91,278 @@ func (p *patchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
|
|||
return NewTextErrorResponse("invalid parameters"), nil
|
||||
}
|
||||
|
||||
if params.FilePath == "" {
|
||||
return NewTextErrorResponse("file_path is required"), nil
|
||||
if params.PatchText == "" {
|
||||
return NewTextErrorResponse("patch_text is required"), nil
|
||||
}
|
||||
|
||||
if params.Patch == "" {
|
||||
return NewTextErrorResponse("patch is required"), nil
|
||||
}
|
||||
|
||||
if !filepath.IsAbs(params.FilePath) {
|
||||
wd := config.WorkingDirectory()
|
||||
params.FilePath = filepath.Join(wd, params.FilePath)
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
fileInfo, err := os.Stat(params.FilePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return NewTextErrorResponse(fmt.Sprintf("file not found: %s", params.FilePath)), nil
|
||||
// Identify all files needed for the patch and verify they've been read
|
||||
filesToRead := diff.IdentifyFilesNeeded(params.PatchText)
|
||||
for _, filePath := range filesToRead {
|
||||
absPath := filePath
|
||||
if !filepath.IsAbs(absPath) {
|
||||
wd := config.WorkingDirectory()
|
||||
absPath = filepath.Join(wd, absPath)
|
||||
}
|
||||
|
||||
if getLastReadTime(absPath).IsZero() {
|
||||
return NewTextErrorResponse(fmt.Sprintf("you must read the file %s before patching it. Use the FileRead tool first", filePath)), nil
|
||||
}
|
||||
|
||||
fileInfo, err := os.Stat(absPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return NewTextErrorResponse(fmt.Sprintf("file not found: %s", absPath)), nil
|
||||
}
|
||||
return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
|
||||
}
|
||||
|
||||
if fileInfo.IsDir() {
|
||||
return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", absPath)), nil
|
||||
}
|
||||
|
||||
modTime := fileInfo.ModTime()
|
||||
lastRead := getLastReadTime(absPath)
|
||||
if modTime.After(lastRead) {
|
||||
return NewTextErrorResponse(
|
||||
fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
|
||||
absPath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
|
||||
)), nil
|
||||
}
|
||||
return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
|
||||
}
|
||||
|
||||
if fileInfo.IsDir() {
|
||||
return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil
|
||||
// Check for new files to ensure they don't already exist
|
||||
filesToAdd := diff.IdentifyFilesAdded(params.PatchText)
|
||||
for _, filePath := range filesToAdd {
|
||||
absPath := filePath
|
||||
if !filepath.IsAbs(absPath) {
|
||||
wd := config.WorkingDirectory()
|
||||
absPath = filepath.Join(wd, absPath)
|
||||
}
|
||||
|
||||
_, err := os.Stat(absPath)
|
||||
if err == nil {
|
||||
return NewTextErrorResponse(fmt.Sprintf("file already exists and cannot be added: %s", absPath)), nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
return ToolResponse{}, fmt.Errorf("failed to check file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if getLastReadTime(params.FilePath).IsZero() {
|
||||
return NewTextErrorResponse("you must read the file before patching it. Use the View tool first"), nil
|
||||
// Load all required files
|
||||
currentFiles := make(map[string]string)
|
||||
for _, filePath := range filesToRead {
|
||||
absPath := filePath
|
||||
if !filepath.IsAbs(absPath) {
|
||||
wd := config.WorkingDirectory()
|
||||
absPath = filepath.Join(wd, absPath)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return ToolResponse{}, fmt.Errorf("failed to read file %s: %w", absPath, err)
|
||||
}
|
||||
currentFiles[filePath] = string(content)
|
||||
}
|
||||
|
||||
modTime := fileInfo.ModTime()
|
||||
lastRead := getLastReadTime(params.FilePath)
|
||||
if modTime.After(lastRead) {
|
||||
return NewTextErrorResponse(
|
||||
fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
|
||||
params.FilePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
|
||||
)), nil
|
||||
}
|
||||
|
||||
// Read the current file content
|
||||
content, err := os.ReadFile(params.FilePath)
|
||||
// Process the patch
|
||||
patch, fuzz, err := diff.TextToPatch(params.PatchText, currentFiles)
|
||||
if err != nil {
|
||||
return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
|
||||
return NewTextErrorResponse(fmt.Sprintf("failed to parse patch: %s", err)), nil
|
||||
}
|
||||
|
||||
oldContent := string(content)
|
||||
if fuzz > 0 {
|
||||
return NewTextErrorResponse(fmt.Sprintf("patch contains fuzzy matches (fuzz level: %d). Please make your context lines more precise", fuzz)), nil
|
||||
}
|
||||
|
||||
// Parse and apply the patch
|
||||
diffResult, err := diff.ParseUnifiedDiff(params.Patch)
|
||||
// Convert patch to commit
|
||||
commit, err := diff.PatchToCommit(patch, currentFiles)
|
||||
if err != nil {
|
||||
return NewTextErrorResponse(fmt.Sprintf("failed to parse patch: %v", err)), nil
|
||||
}
|
||||
|
||||
// Apply the patch to get the new content
|
||||
newContent, err := applyPatch(oldContent, diffResult)
|
||||
if err != nil {
|
||||
return NewTextErrorResponse(fmt.Sprintf("failed to apply patch: %v", err)), nil
|
||||
}
|
||||
|
||||
if oldContent == newContent {
|
||||
return NewTextErrorResponse("patch did not result in any changes to the file"), nil
|
||||
return NewTextErrorResponse(fmt.Sprintf("failed to create commit from patch: %s", err)), nil
|
||||
}
|
||||
|
||||
// Get session ID and message ID
|
||||
sessionID, messageID := GetContextValues(ctx)
|
||||
if sessionID == "" || messageID == "" {
|
||||
return ToolResponse{}, fmt.Errorf("session ID and message ID are required for patching a file")
|
||||
return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a patch")
|
||||
}
|
||||
|
||||
// Generate a diff for permission request and metadata
|
||||
diffText, additions, removals := diff.GenerateDiff(
|
||||
oldContent,
|
||||
newContent,
|
||||
params.FilePath,
|
||||
)
|
||||
|
||||
// Request permission to apply the patch
|
||||
p.permissions.Request(
|
||||
permission.CreatePermissionRequest{
|
||||
Path: filepath.Dir(params.FilePath),
|
||||
ToolName: PatchToolName,
|
||||
Action: "patch",
|
||||
Description: fmt.Sprintf("Apply patch to file %s", params.FilePath),
|
||||
Params: PatchPermissionsParams{
|
||||
FilePath: params.FilePath,
|
||||
Diff: diffText,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// Write the new content to the file
|
||||
err = os.WriteFile(params.FilePath, []byte(newContent), 0o644)
|
||||
if err != nil {
|
||||
return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
// Update file history
|
||||
file, err := p.files.GetByPathAndSession(ctx, params.FilePath, sessionID)
|
||||
if err != nil {
|
||||
_, err = p.files.Create(ctx, sessionID, params.FilePath, oldContent)
|
||||
if err != nil {
|
||||
return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
|
||||
// Request permission for all changes
|
||||
for path, change := range commit.Changes {
|
||||
switch change.Type {
|
||||
case diff.ActionAdd:
|
||||
dir := filepath.Dir(path)
|
||||
patchDiff, _, _ := diff.GenerateDiff("", *change.NewContent, path)
|
||||
p := p.permissions.Request(
|
||||
permission.CreatePermissionRequest{
|
||||
Path: dir,
|
||||
ToolName: PatchToolName,
|
||||
Action: "create",
|
||||
Description: fmt.Sprintf("Create file %s", path),
|
||||
Params: EditPermissionsParams{
|
||||
FilePath: path,
|
||||
Diff: patchDiff,
|
||||
},
|
||||
},
|
||||
)
|
||||
if !p {
|
||||
return ToolResponse{}, permission.ErrorPermissionDenied
|
||||
}
|
||||
case diff.ActionUpdate:
|
||||
currentContent := ""
|
||||
if change.OldContent != nil {
|
||||
currentContent = *change.OldContent
|
||||
}
|
||||
newContent := ""
|
||||
if change.NewContent != nil {
|
||||
newContent = *change.NewContent
|
||||
}
|
||||
patchDiff, _, _ := diff.GenerateDiff(currentContent, newContent, path)
|
||||
dir := filepath.Dir(path)
|
||||
p := p.permissions.Request(
|
||||
permission.CreatePermissionRequest{
|
||||
Path: dir,
|
||||
ToolName: PatchToolName,
|
||||
Action: "update",
|
||||
Description: fmt.Sprintf("Update file %s", path),
|
||||
Params: EditPermissionsParams{
|
||||
FilePath: path,
|
||||
Diff: patchDiff,
|
||||
},
|
||||
},
|
||||
)
|
||||
if !p {
|
||||
return ToolResponse{}, permission.ErrorPermissionDenied
|
||||
}
|
||||
case diff.ActionDelete:
|
||||
dir := filepath.Dir(path)
|
||||
patchDiff, _, _ := diff.GenerateDiff(*change.OldContent, "", path)
|
||||
p := p.permissions.Request(
|
||||
permission.CreatePermissionRequest{
|
||||
Path: dir,
|
||||
ToolName: PatchToolName,
|
||||
Action: "delete",
|
||||
Description: fmt.Sprintf("Delete file %s", path),
|
||||
Params: EditPermissionsParams{
|
||||
FilePath: path,
|
||||
Diff: patchDiff,
|
||||
},
|
||||
},
|
||||
)
|
||||
if !p {
|
||||
return ToolResponse{}, permission.ErrorPermissionDenied
|
||||
}
|
||||
}
|
||||
}
|
||||
if file.Content != oldContent {
|
||||
// User manually changed the content, store an intermediate version
|
||||
_, err = p.files.CreateVersion(ctx, sessionID, params.FilePath, oldContent)
|
||||
|
||||
// Apply the changes to the filesystem
|
||||
err = diff.ApplyCommit(commit, func(path string, content string) error {
|
||||
absPath := path
|
||||
if !filepath.IsAbs(absPath) {
|
||||
wd := config.WorkingDirectory()
|
||||
absPath = filepath.Join(wd, absPath)
|
||||
}
|
||||
|
||||
// Create parent directories if needed
|
||||
dir := filepath.Dir(absPath)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create parent directories for %s: %w", absPath, err)
|
||||
}
|
||||
|
||||
return os.WriteFile(absPath, []byte(content), 0o644)
|
||||
}, func(path string) error {
|
||||
absPath := path
|
||||
if !filepath.IsAbs(absPath) {
|
||||
wd := config.WorkingDirectory()
|
||||
absPath = filepath.Join(wd, absPath)
|
||||
}
|
||||
return os.Remove(absPath)
|
||||
})
|
||||
if err != nil {
|
||||
return NewTextErrorResponse(fmt.Sprintf("failed to apply patch: %s", err)), nil
|
||||
}
|
||||
|
||||
// Update file history for all modified files
|
||||
changedFiles := []string{}
|
||||
totalAdditions := 0
|
||||
totalRemovals := 0
|
||||
|
||||
for path, change := range commit.Changes {
|
||||
absPath := path
|
||||
if !filepath.IsAbs(absPath) {
|
||||
wd := config.WorkingDirectory()
|
||||
absPath = filepath.Join(wd, absPath)
|
||||
}
|
||||
changedFiles = append(changedFiles, absPath)
|
||||
|
||||
oldContent := ""
|
||||
if change.OldContent != nil {
|
||||
oldContent = *change.OldContent
|
||||
}
|
||||
|
||||
newContent := ""
|
||||
if change.NewContent != nil {
|
||||
newContent = *change.NewContent
|
||||
}
|
||||
|
||||
// Calculate diff statistics
|
||||
_, additions, removals := diff.GenerateDiff(oldContent, newContent, path)
|
||||
totalAdditions += additions
|
||||
totalRemovals += removals
|
||||
|
||||
// Update history
|
||||
file, err := p.files.GetByPathAndSession(ctx, absPath, sessionID)
|
||||
if err != nil && change.Type != diff.ActionAdd {
|
||||
// If not adding a file, create history entry for existing file
|
||||
_, err = p.files.Create(ctx, sessionID, absPath, oldContent)
|
||||
if err != nil {
|
||||
fmt.Printf("Error creating file history: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err == nil && change.Type != diff.ActionAdd && file.Content != oldContent {
|
||||
// User manually changed content, store intermediate version
|
||||
_, err = p.files.CreateVersion(ctx, sessionID, absPath, oldContent)
|
||||
if err != nil {
|
||||
fmt.Printf("Error creating file history version: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Store new version
|
||||
if change.Type == diff.ActionDelete {
|
||||
_, err = p.files.CreateVersion(ctx, sessionID, absPath, "")
|
||||
} else {
|
||||
_, err = p.files.CreateVersion(ctx, sessionID, absPath, newContent)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Printf("Error creating file history version: %v\n", err)
|
||||
}
|
||||
}
|
||||
// Store the new version
|
||||
_, err = p.files.CreateVersion(ctx, sessionID, params.FilePath, newContent)
|
||||
if err != nil {
|
||||
fmt.Printf("Error creating file history version: %v\n", err)
|
||||
|
||||
// Record file operations
|
||||
recordFileWrite(absPath)
|
||||
recordFileRead(absPath)
|
||||
}
|
||||
|
||||
recordFileWrite(params.FilePath)
|
||||
recordFileRead(params.FilePath)
|
||||
// Run LSP diagnostics on all changed files
|
||||
for _, filePath := range changedFiles {
|
||||
waitForLspDiagnostics(ctx, filePath, p.lspClients)
|
||||
}
|
||||
|
||||
// Wait for LSP diagnostics and include them in the response
|
||||
waitForLspDiagnostics(ctx, params.FilePath, p.lspClients)
|
||||
text := fmt.Sprintf("<r>\nPatch applied to file: %s\n</r>\n", params.FilePath)
|
||||
text += getDiagnostics(params.FilePath, p.lspClients)
|
||||
result := fmt.Sprintf("Patch applied successfully. %d files changed, %d additions, %d removals",
|
||||
len(changedFiles), totalAdditions, totalRemovals)
|
||||
|
||||
diagnosticsText := ""
|
||||
for _, filePath := range changedFiles {
|
||||
diagnosticsText += getDiagnostics(filePath, p.lspClients)
|
||||
}
|
||||
|
||||
if diagnosticsText != "" {
|
||||
result += "\n\nDiagnostics:\n" + diagnosticsText
|
||||
}
|
||||
|
||||
return WithResponseMetadata(
|
||||
NewTextResponse(text),
|
||||
NewTextResponse(result),
|
||||
PatchResponseMetadata{
|
||||
Diff: diffText,
|
||||
Additions: additions,
|
||||
Removals: removals,
|
||||
FilesChanged: changedFiles,
|
||||
Additions: totalAdditions,
|
||||
Removals: totalRemovals,
|
||||
}), nil
|
||||
}
|
||||
|
||||
// applyPatch applies a parsed diff to a string and returns the resulting content
|
||||
func applyPatch(content string, diffResult diff.DiffResult) (string, error) {
|
||||
lines := strings.Split(content, "\n")
|
||||
|
||||
// Process each hunk in the diff
|
||||
for _, hunk := range diffResult.Hunks {
|
||||
// Parse the hunk header to get line numbers
|
||||
var oldStart, oldCount, newStart, newCount int
|
||||
_, err := fmt.Sscanf(hunk.Header, "@@ -%d,%d +%d,%d @@", &oldStart, &oldCount, &newStart, &newCount)
|
||||
if err != nil {
|
||||
// Try alternative format with single line counts
|
||||
_, err = fmt.Sscanf(hunk.Header, "@@ -%d +%d @@", &oldStart, &newStart)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid hunk header format: %s", hunk.Header)
|
||||
}
|
||||
oldCount = 1
|
||||
newCount = 1
|
||||
}
|
||||
|
||||
// Adjust for 0-based array indexing
|
||||
oldStart--
|
||||
newStart--
|
||||
|
||||
// Apply the changes
|
||||
newLines := make([]string, 0)
|
||||
newLines = append(newLines, lines[:oldStart]...)
|
||||
|
||||
// Process the hunk lines in order
|
||||
currentOldLine := oldStart
|
||||
for _, line := range hunk.Lines {
|
||||
switch line.Kind {
|
||||
case diff.LineContext:
|
||||
newLines = append(newLines, line.Content)
|
||||
currentOldLine++
|
||||
case diff.LineRemoved:
|
||||
// Skip this line in the output (it's being removed)
|
||||
currentOldLine++
|
||||
case diff.LineAdded:
|
||||
// Add the new line
|
||||
newLines = append(newLines, line.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// Append the rest of the file
|
||||
newLines = append(newLines, lines[currentOldLine:]...)
|
||||
lines = newLines
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n"), nil
|
||||
}
|
||||
|
||||
|
|
|
@ -24,6 +24,11 @@ type viewTool struct {
|
|||
lspClients map[string]*lsp.Client
|
||||
}
|
||||
|
||||
type ViewResponseMetadata struct {
|
||||
FilePath string `json:"file_path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
const (
|
||||
ViewToolName = "view"
|
||||
MaxReadSize = 250 * 1024
|
||||
|
@ -180,7 +185,13 @@ func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
|
|||
output += "\n</file>\n"
|
||||
output += getDiagnostics(filePath, v.lspClients)
|
||||
recordFileRead(filePath)
|
||||
return NewTextResponse(output), nil
|
||||
return WithResponseMetadata(
|
||||
NewTextResponse(output),
|
||||
ViewResponseMetadata{
|
||||
FilePath: filePath,
|
||||
Content: content,
|
||||
},
|
||||
), nil
|
||||
}
|
||||
|
||||
func addLineNumbers(content string, startLine int) string {
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
package chat
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/textarea"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
@ -19,13 +22,15 @@ type editorCmp struct {
|
|||
}
|
||||
|
||||
type focusedEditorKeyMaps struct {
|
||||
Send key.Binding
|
||||
Blur key.Binding
|
||||
Send key.Binding
|
||||
OpenEditor key.Binding
|
||||
Blur key.Binding
|
||||
}
|
||||
|
||||
type bluredEditorKeyMaps struct {
|
||||
Send key.Binding
|
||||
Focus key.Binding
|
||||
Send key.Binding
|
||||
Focus key.Binding
|
||||
OpenEditor key.Binding
|
||||
}
|
||||
|
||||
var focusedKeyMaps = focusedEditorKeyMaps{
|
||||
|
@ -37,6 +42,10 @@ var focusedKeyMaps = focusedEditorKeyMaps{
|
|||
key.WithKeys("esc"),
|
||||
key.WithHelp("esc", "focus messages"),
|
||||
),
|
||||
OpenEditor: key.NewBinding(
|
||||
key.WithKeys("ctrl+e"),
|
||||
key.WithHelp("ctrl+e", "open editor"),
|
||||
),
|
||||
}
|
||||
|
||||
var bluredKeyMaps = bluredEditorKeyMaps{
|
||||
|
@ -48,6 +57,40 @@ var bluredKeyMaps = bluredEditorKeyMaps{
|
|||
key.WithKeys("i"),
|
||||
key.WithHelp("i", "focus editor"),
|
||||
),
|
||||
OpenEditor: key.NewBinding(
|
||||
key.WithKeys("ctrl+e"),
|
||||
key.WithHelp("ctrl+e", "open editor"),
|
||||
),
|
||||
}
|
||||
|
||||
func openEditor() tea.Cmd {
|
||||
editor := os.Getenv("EDITOR")
|
||||
if editor == "" {
|
||||
editor = "nvim"
|
||||
}
|
||||
|
||||
tmpfile, err := os.CreateTemp("", "msg_*.md")
|
||||
if err != nil {
|
||||
return util.ReportError(err)
|
||||
}
|
||||
tmpfile.Close()
|
||||
c := exec.Command(editor, tmpfile.Name()) //nolint:gosec
|
||||
c.Stdin = os.Stdin
|
||||
c.Stdout = os.Stdout
|
||||
c.Stderr = os.Stderr
|
||||
return tea.ExecProcess(c, func(err error) tea.Msg {
|
||||
if err != nil {
|
||||
return util.ReportError(err)
|
||||
}
|
||||
content, err := os.ReadFile(tmpfile.Name())
|
||||
if err != nil {
|
||||
return util.ReportError(err)
|
||||
}
|
||||
os.Remove(tmpfile.Name())
|
||||
return SendMsg{
|
||||
Text: string(content),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (m *editorCmp) Init() tea.Cmd {
|
||||
|
@ -82,6 +125,10 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
return m, nil
|
||||
case tea.KeyMsg:
|
||||
if key.Matches(msg, focusedKeyMaps.OpenEditor) {
|
||||
m.textarea.Blur()
|
||||
return m, openEditor()
|
||||
}
|
||||
// if the key does not match any binding, return
|
||||
if m.textarea.Focused() && key.Matches(msg, focusedKeyMaps.Send) {
|
||||
return m, m.send()
|
||||
|
@ -108,9 +155,10 @@ func (m *editorCmp) View() string {
|
|||
return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View())
|
||||
}
|
||||
|
||||
func (m *editorCmp) SetSize(width, height int) {
|
||||
func (m *editorCmp) SetSize(width, height int) tea.Cmd {
|
||||
m.textarea.SetWidth(width - 3) // account for the prompt and padding right
|
||||
m.textarea.SetHeight(height)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *editorCmp) GetSize() (int, int) {
|
||||
|
|
463
internal/tui/components/chat/list.go
Normal file
463
internal/tui/components/chat/list.go
Normal file
|
@ -0,0 +1,463 @@
|
|||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/kujtimiihoxha/opencode/internal/app"
|
||||
"github.com/kujtimiihoxha/opencode/internal/logging"
|
||||
"github.com/kujtimiihoxha/opencode/internal/message"
|
||||
"github.com/kujtimiihoxha/opencode/internal/pubsub"
|
||||
"github.com/kujtimiihoxha/opencode/internal/session"
|
||||
"github.com/kujtimiihoxha/opencode/internal/tui/layout"
|
||||
"github.com/kujtimiihoxha/opencode/internal/tui/styles"
|
||||
"github.com/kujtimiihoxha/opencode/internal/tui/util"
|
||||
)
|
||||
|
||||
type messagesCmp struct {
|
||||
app *app.App
|
||||
width, height int
|
||||
writingMode bool
|
||||
viewport viewport.Model
|
||||
session session.Session
|
||||
messages []message.Message
|
||||
uiMessages []uiMessage
|
||||
currentMsgID string
|
||||
mutex sync.Mutex
|
||||
cachedContent map[string][]uiMessage
|
||||
spinner spinner.Model
|
||||
rendering bool
|
||||
}
|
||||
type renderFinishedMsg struct{}
|
||||
|
||||
func (m *messagesCmp) Init() tea.Cmd {
|
||||
return tea.Batch(m.viewport.Init())
|
||||
}
|
||||
|
||||
func (m *messagesCmp) preloadSessions() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
sessions, err := m.app.Sessions.List(context.Background())
|
||||
if err != nil {
|
||||
return util.ReportError(err)()
|
||||
}
|
||||
if len(sessions) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(sessions) > 20 {
|
||||
sessions = sessions[:20]
|
||||
}
|
||||
for _, s := range sessions {
|
||||
messages, err := m.app.Messages.List(context.Background(), s.ID)
|
||||
if err != nil {
|
||||
return util.ReportError(err)()
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
continue
|
||||
}
|
||||
m.cacheSessionMessages(messages, m.width)
|
||||
|
||||
}
|
||||
logging.Debug("preloaded sessions")
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *messagesCmp) cacheSessionMessages(messages []message.Message, width int) {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
pos := 0
|
||||
if m.width == 0 {
|
||||
return
|
||||
}
|
||||
for inx, msg := range messages {
|
||||
switch msg.Role {
|
||||
case message.User:
|
||||
userMsg := renderUserMessage(
|
||||
msg,
|
||||
false,
|
||||
width,
|
||||
pos,
|
||||
)
|
||||
m.cachedContent[msg.ID] = []uiMessage{userMsg}
|
||||
pos += userMsg.height + 1 // + 1 for spacing
|
||||
case message.Assistant:
|
||||
assistantMessages := renderAssistantMessage(
|
||||
msg,
|
||||
inx,
|
||||
messages,
|
||||
m.app.Messages,
|
||||
"",
|
||||
width,
|
||||
pos,
|
||||
)
|
||||
for _, msg := range assistantMessages {
|
||||
pos += msg.height + 1 // + 1 for spacing
|
||||
}
|
||||
m.cachedContent[msg.ID] = assistantMessages
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
switch msg := msg.(type) {
|
||||
case EditorFocusMsg:
|
||||
m.writingMode = bool(msg)
|
||||
case SessionSelectedMsg:
|
||||
if msg.ID != m.session.ID {
|
||||
cmd := m.SetSession(msg)
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
case SessionClearedMsg:
|
||||
m.session = session.Session{}
|
||||
m.messages = make([]message.Message, 0)
|
||||
m.currentMsgID = ""
|
||||
m.rendering = false
|
||||
return m, nil
|
||||
|
||||
case renderFinishedMsg:
|
||||
m.rendering = false
|
||||
m.viewport.GotoBottom()
|
||||
case tea.KeyMsg:
|
||||
if m.writingMode {
|
||||
return m, nil
|
||||
}
|
||||
case pubsub.Event[message.Message]:
|
||||
needsRerender := false
|
||||
if msg.Type == pubsub.CreatedEvent {
|
||||
if msg.Payload.SessionID == m.session.ID {
|
||||
|
||||
messageExists := false
|
||||
for _, v := range m.messages {
|
||||
if v.ID == msg.Payload.ID {
|
||||
messageExists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !messageExists {
|
||||
if len(m.messages) > 0 {
|
||||
lastMsgID := m.messages[len(m.messages)-1].ID
|
||||
delete(m.cachedContent, lastMsgID)
|
||||
}
|
||||
|
||||
m.messages = append(m.messages, msg.Payload)
|
||||
delete(m.cachedContent, m.currentMsgID)
|
||||
m.currentMsgID = msg.Payload.ID
|
||||
needsRerender = true
|
||||
}
|
||||
}
|
||||
// There are tool calls from the child task
|
||||
for _, v := range m.messages {
|
||||
for _, c := range v.ToolCalls() {
|
||||
if c.ID == msg.Payload.SessionID {
|
||||
delete(m.cachedContent, v.ID)
|
||||
needsRerender = true
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if msg.Type == pubsub.UpdatedEvent && msg.Payload.SessionID == m.session.ID {
|
||||
for i, v := range m.messages {
|
||||
if v.ID == msg.Payload.ID {
|
||||
m.messages[i] = msg.Payload
|
||||
delete(m.cachedContent, msg.Payload.ID)
|
||||
needsRerender = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if needsRerender {
|
||||
m.renderView()
|
||||
if len(m.messages) > 0 {
|
||||
if (msg.Type == pubsub.CreatedEvent) ||
|
||||
(msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == m.messages[len(m.messages)-1].ID) {
|
||||
m.viewport.GotoBottom()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
u, cmd := m.viewport.Update(msg)
|
||||
m.viewport = u
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
spinner, cmd := m.spinner.Update(msg)
|
||||
m.spinner = spinner
|
||||
cmds = append(cmds, cmd)
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *messagesCmp) IsAgentWorking() bool {
|
||||
return m.app.CoderAgent.IsSessionBusy(m.session.ID)
|
||||
}
|
||||
|
||||
func formatTimeDifference(unixTime1, unixTime2 int64) string {
|
||||
diffSeconds := float64(math.Abs(float64(unixTime2 - unixTime1)))
|
||||
|
||||
if diffSeconds < 60 {
|
||||
return fmt.Sprintf("%.1fs", diffSeconds)
|
||||
}
|
||||
|
||||
minutes := int(diffSeconds / 60)
|
||||
seconds := int(diffSeconds) % 60
|
||||
return fmt.Sprintf("%dm%ds", minutes, seconds)
|
||||
}
|
||||
|
||||
func (m *messagesCmp) renderView() {
|
||||
m.uiMessages = make([]uiMessage, 0)
|
||||
pos := 0
|
||||
|
||||
if m.width == 0 {
|
||||
return
|
||||
}
|
||||
for inx, msg := range m.messages {
|
||||
switch msg.Role {
|
||||
case message.User:
|
||||
if messages, ok := m.cachedContent[msg.ID]; ok {
|
||||
m.uiMessages = append(m.uiMessages, messages...)
|
||||
continue
|
||||
}
|
||||
userMsg := renderUserMessage(
|
||||
msg,
|
||||
msg.ID == m.currentMsgID,
|
||||
m.width,
|
||||
pos,
|
||||
)
|
||||
m.uiMessages = append(m.uiMessages, userMsg)
|
||||
m.cachedContent[msg.ID] = []uiMessage{userMsg}
|
||||
pos += userMsg.height + 1 // + 1 for spacing
|
||||
case message.Assistant:
|
||||
if messages, ok := m.cachedContent[msg.ID]; ok {
|
||||
m.uiMessages = append(m.uiMessages, messages...)
|
||||
continue
|
||||
}
|
||||
assistantMessages := renderAssistantMessage(
|
||||
msg,
|
||||
inx,
|
||||
m.messages,
|
||||
m.app.Messages,
|
||||
m.currentMsgID,
|
||||
m.width,
|
||||
pos,
|
||||
)
|
||||
for _, msg := range assistantMessages {
|
||||
m.uiMessages = append(m.uiMessages, msg)
|
||||
pos += msg.height + 1 // + 1 for spacing
|
||||
}
|
||||
m.cachedContent[msg.ID] = assistantMessages
|
||||
}
|
||||
}
|
||||
|
||||
messages := make([]string, 0)
|
||||
for _, v := range m.uiMessages {
|
||||
messages = append(messages, v.content,
|
||||
styles.BaseStyle.
|
||||
Width(m.width).
|
||||
Render(
|
||||
"",
|
||||
),
|
||||
)
|
||||
}
|
||||
m.viewport.SetContent(
|
||||
styles.BaseStyle.
|
||||
Width(m.width).
|
||||
Render(
|
||||
lipgloss.JoinVertical(
|
||||
lipgloss.Top,
|
||||
messages...,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (m *messagesCmp) View() string {
|
||||
if m.rendering {
|
||||
return styles.BaseStyle.
|
||||
Width(m.width).
|
||||
Render(
|
||||
lipgloss.JoinVertical(
|
||||
lipgloss.Top,
|
||||
"Loading...",
|
||||
m.working(),
|
||||
m.help(),
|
||||
),
|
||||
)
|
||||
}
|
||||
if len(m.messages) == 0 {
|
||||
content := styles.BaseStyle.
|
||||
Width(m.width).
|
||||
Height(m.height - 1).
|
||||
Render(
|
||||
m.initialScreen(),
|
||||
)
|
||||
|
||||
return styles.BaseStyle.
|
||||
Width(m.width).
|
||||
Render(
|
||||
lipgloss.JoinVertical(
|
||||
lipgloss.Top,
|
||||
content,
|
||||
"",
|
||||
m.help(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return styles.BaseStyle.
|
||||
Width(m.width).
|
||||
Render(
|
||||
lipgloss.JoinVertical(
|
||||
lipgloss.Top,
|
||||
m.viewport.View(),
|
||||
m.working(),
|
||||
m.help(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func hasToolsWithoutResponse(messages []message.Message) bool {
|
||||
toolCalls := make([]message.ToolCall, 0)
|
||||
toolResults := make([]message.ToolResult, 0)
|
||||
for _, m := range messages {
|
||||
toolCalls = append(toolCalls, m.ToolCalls()...)
|
||||
toolResults = append(toolResults, m.ToolResults()...)
|
||||
}
|
||||
|
||||
for _, v := range toolCalls {
|
||||
found := false
|
||||
for _, r := range toolResults {
|
||||
if v.ID == r.ToolCallID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *messagesCmp) working() string {
|
||||
text := ""
|
||||
if m.IsAgentWorking() {
|
||||
task := "Thinking..."
|
||||
lastMessage := m.messages[len(m.messages)-1]
|
||||
if hasToolsWithoutResponse(m.messages) {
|
||||
task = "Waiting for tool response..."
|
||||
} else if !lastMessage.IsFinished() {
|
||||
lastUpdate := lastMessage.UpdatedAt
|
||||
currentTime := time.Now().Unix()
|
||||
if lastMessage.Content().String() != "" && lastUpdate != 0 && currentTime-lastUpdate > 5 {
|
||||
task = "Building tool call..."
|
||||
} else if lastMessage.Content().String() == "" {
|
||||
task = "Generating..."
|
||||
}
|
||||
task = ""
|
||||
}
|
||||
if task != "" {
|
||||
text += styles.BaseStyle.Width(m.width).Foreground(styles.PrimaryColor).Bold(true).Render(
|
||||
fmt.Sprintf("%s %s ", m.spinner.View(), task),
|
||||
)
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func (m *messagesCmp) help() string {
|
||||
text := ""
|
||||
|
||||
if m.writingMode {
|
||||
text += lipgloss.JoinHorizontal(
|
||||
lipgloss.Left,
|
||||
styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "),
|
||||
styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("esc"),
|
||||
styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render(" to exit writing mode"),
|
||||
)
|
||||
} else {
|
||||
text += lipgloss.JoinHorizontal(
|
||||
lipgloss.Left,
|
||||
styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "),
|
||||
styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("i"),
|
||||
styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render(" to start writing"),
|
||||
)
|
||||
}
|
||||
|
||||
return styles.BaseStyle.
|
||||
Width(m.width).
|
||||
Render(text)
|
||||
}
|
||||
|
||||
func (m *messagesCmp) initialScreen() string {
|
||||
return styles.BaseStyle.Width(m.width).Render(
|
||||
lipgloss.JoinVertical(
|
||||
lipgloss.Top,
|
||||
header(m.width),
|
||||
"",
|
||||
lspsConfigured(m.width),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (m *messagesCmp) SetSize(width, height int) tea.Cmd {
|
||||
if m.width == width && m.height == height {
|
||||
return nil
|
||||
}
|
||||
m.width = width
|
||||
m.height = height
|
||||
m.viewport.Width = width
|
||||
m.viewport.Height = height - 2
|
||||
m.renderView()
|
||||
return m.preloadSessions()
|
||||
}
|
||||
|
||||
func (m *messagesCmp) GetSize() (int, int) {
|
||||
return m.width, m.height
|
||||
}
|
||||
|
||||
func (m *messagesCmp) SetSession(session session.Session) tea.Cmd {
|
||||
if m.session.ID == session.ID {
|
||||
return nil
|
||||
}
|
||||
m.rendering = true
|
||||
return func() tea.Msg {
|
||||
m.session = session
|
||||
messages, err := m.app.Messages.List(context.Background(), session.ID)
|
||||
if err != nil {
|
||||
return util.ReportError(err)
|
||||
}
|
||||
m.messages = messages
|
||||
m.currentMsgID = m.messages[len(m.messages)-1].ID
|
||||
delete(m.cachedContent, m.currentMsgID)
|
||||
m.renderView()
|
||||
return renderFinishedMsg{}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *messagesCmp) BindingKeys() []key.Binding {
|
||||
bindings := layout.KeyMapToSlice(m.viewport.KeyMap)
|
||||
return bindings
|
||||
}
|
||||
|
||||
func NewMessagesCmp(app *app.App) tea.Model {
|
||||
s := spinner.New()
|
||||
s.Spinner = spinner.Pulse
|
||||
return &messagesCmp{
|
||||
app: app,
|
||||
writingMode: true,
|
||||
cachedContent: make(map[string][]uiMessage),
|
||||
viewport: viewport.New(0, 0),
|
||||
spinner: s,
|
||||
}
|
||||
}
|
561
internal/tui/components/chat/message.go
Normal file
561
internal/tui/components/chat/message.go
Normal file
|
@ -0,0 +1,561 @@
|
|||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/glamour"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/kujtimiihoxha/opencode/internal/config"
|
||||
"github.com/kujtimiihoxha/opencode/internal/diff"
|
||||
"github.com/kujtimiihoxha/opencode/internal/llm/agent"
|
||||
"github.com/kujtimiihoxha/opencode/internal/llm/models"
|
||||
"github.com/kujtimiihoxha/opencode/internal/llm/tools"
|
||||
"github.com/kujtimiihoxha/opencode/internal/message"
|
||||
"github.com/kujtimiihoxha/opencode/internal/tui/styles"
|
||||
)
|
||||
|
||||
type uiMessageType int
|
||||
|
||||
const (
|
||||
userMessageType uiMessageType = iota
|
||||
assistantMessageType
|
||||
toolMessageType
|
||||
|
||||
maxResultHeight = 15
|
||||
)
|
||||
|
||||
var diffStyle = diff.NewStyleConfig(diff.WithShowHeader(false), diff.WithShowHunkHeader(false))
|
||||
|
||||
type uiMessage struct {
|
||||
ID string
|
||||
messageType uiMessageType
|
||||
position int
|
||||
height int
|
||||
content string
|
||||
}
|
||||
|
||||
type renderCache struct {
|
||||
mutex sync.Mutex
|
||||
cache map[string][]uiMessage
|
||||
}
|
||||
|
||||
func toMarkdown(content string, focused bool, width int) string {
|
||||
r, _ := glamour.NewTermRenderer(
|
||||
glamour.WithStyles(styles.MarkdownTheme(false)),
|
||||
glamour.WithWordWrap(width),
|
||||
)
|
||||
if focused {
|
||||
r, _ = glamour.NewTermRenderer(
|
||||
glamour.WithStyles(styles.MarkdownTheme(true)),
|
||||
glamour.WithWordWrap(width),
|
||||
)
|
||||
}
|
||||
rendered, _ := r.Render(content)
|
||||
return rendered
|
||||
}
|
||||
|
||||
func renderMessage(msg string, isUser bool, isFocused bool, width int, info ...string) string {
|
||||
style := styles.BaseStyle.
|
||||
Width(width - 1).
|
||||
BorderLeft(true).
|
||||
Foreground(styles.ForgroundDim).
|
||||
BorderForeground(styles.PrimaryColor).
|
||||
BorderStyle(lipgloss.ThickBorder())
|
||||
if isUser {
|
||||
style = style.
|
||||
BorderForeground(styles.Blue)
|
||||
}
|
||||
parts := []string{
|
||||
styles.ForceReplaceBackgroundWithLipgloss(toMarkdown(msg, isFocused, width), styles.Background),
|
||||
}
|
||||
|
||||
// remove newline at the end
|
||||
parts[0] = strings.TrimSuffix(parts[0], "\n")
|
||||
if len(info) > 0 {
|
||||
parts = append(parts, info...)
|
||||
}
|
||||
rendered := style.Render(
|
||||
lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
parts...,
|
||||
),
|
||||
)
|
||||
|
||||
return rendered
|
||||
}
|
||||
|
||||
func renderUserMessage(msg message.Message, isFocused bool, width int, position int) uiMessage {
|
||||
content := renderMessage(msg.Content().String(), true, isFocused, width)
|
||||
userMsg := uiMessage{
|
||||
ID: msg.ID,
|
||||
messageType: userMessageType,
|
||||
position: position,
|
||||
height: lipgloss.Height(content),
|
||||
content: content,
|
||||
}
|
||||
return userMsg
|
||||
}
|
||||
|
||||
// Returns multiple uiMessages because of the tool calls
|
||||
func renderAssistantMessage(
|
||||
msg message.Message,
|
||||
msgIndex int,
|
||||
allMessages []message.Message, // we need this to get tool results and the user message
|
||||
messagesService message.Service, // We need this to get the task tool messages
|
||||
focusedUIMessageId string,
|
||||
width int,
|
||||
position int,
|
||||
) []uiMessage {
|
||||
// find the user message that is before this assistant message
|
||||
var userMsg message.Message
|
||||
for i := msgIndex - 1; i >= 0; i-- {
|
||||
msg := allMessages[i]
|
||||
if msg.Role == message.User {
|
||||
userMsg = allMessages[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
messages := []uiMessage{}
|
||||
content := msg.Content().String()
|
||||
finished := msg.IsFinished()
|
||||
finishData := msg.FinishPart()
|
||||
info := []string{}
|
||||
|
||||
// Add finish info if available
|
||||
if finished {
|
||||
switch finishData.Reason {
|
||||
case message.FinishReasonEndTurn:
|
||||
took := formatTimeDifference(userMsg.CreatedAt, finishData.Time)
|
||||
info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
|
||||
fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, took),
|
||||
))
|
||||
case message.FinishReasonCanceled:
|
||||
info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
|
||||
fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "canceled"),
|
||||
))
|
||||
case message.FinishReasonError:
|
||||
info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
|
||||
fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "error"),
|
||||
))
|
||||
case message.FinishReasonPermissionDenied:
|
||||
info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
|
||||
fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "permission denied"),
|
||||
))
|
||||
}
|
||||
}
|
||||
if content != "" {
|
||||
content = renderMessage(content, false, msg.ID == focusedUIMessageId, width, info...)
|
||||
messages = append(messages, uiMessage{
|
||||
ID: msg.ID,
|
||||
messageType: assistantMessageType,
|
||||
position: position,
|
||||
height: lipgloss.Height(content),
|
||||
content: content,
|
||||
})
|
||||
position += messages[0].height
|
||||
position++ // for the space
|
||||
}
|
||||
|
||||
for i, toolCall := range msg.ToolCalls() {
|
||||
toolCallContent := renderToolMessage(
|
||||
toolCall,
|
||||
allMessages,
|
||||
messagesService,
|
||||
focusedUIMessageId,
|
||||
false,
|
||||
width,
|
||||
i+1,
|
||||
)
|
||||
messages = append(messages, toolCallContent)
|
||||
position += toolCallContent.height
|
||||
position++ // for the space
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
func findToolResponse(toolCallID string, futureMessages []message.Message) *message.ToolResult {
|
||||
for _, msg := range futureMessages {
|
||||
for _, result := range msg.ToolResults() {
|
||||
if result.ToolCallID == toolCallID {
|
||||
return &result
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func toolName(name string) string {
|
||||
switch name {
|
||||
case agent.AgentToolName:
|
||||
return "Task"
|
||||
case tools.BashToolName:
|
||||
return "Bash"
|
||||
case tools.EditToolName:
|
||||
return "Edit"
|
||||
case tools.FetchToolName:
|
||||
return "Fetch"
|
||||
case tools.GlobToolName:
|
||||
return "Glob"
|
||||
case tools.GrepToolName:
|
||||
return "Grep"
|
||||
case tools.LSToolName:
|
||||
return "List"
|
||||
case tools.SourcegraphToolName:
|
||||
return "Sourcegraph"
|
||||
case tools.ViewToolName:
|
||||
return "View"
|
||||
case tools.WriteToolName:
|
||||
return "Write"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// renders params, params[0] (params[1]=params[2] ....)
|
||||
func renderParams(paramsWidth int, params ...string) string {
|
||||
if len(params) == 0 {
|
||||
return ""
|
||||
}
|
||||
mainParam := params[0]
|
||||
if len(mainParam) > paramsWidth {
|
||||
mainParam = mainParam[:paramsWidth-3] + "..."
|
||||
}
|
||||
|
||||
if len(params) == 1 {
|
||||
return mainParam
|
||||
}
|
||||
otherParams := params[1:]
|
||||
// create pairs of key/value
|
||||
// if odd number of params, the last one is a key without value
|
||||
if len(otherParams)%2 != 0 {
|
||||
otherParams = append(otherParams, "")
|
||||
}
|
||||
parts := make([]string, 0, len(otherParams)/2)
|
||||
for i := 0; i < len(otherParams); i += 2 {
|
||||
key := otherParams[i]
|
||||
value := otherParams[i+1]
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s=%s", key, value))
|
||||
}
|
||||
|
||||
partsRendered := strings.Join(parts, ", ")
|
||||
remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 5 // for the space
|
||||
if remainingWidth < 30 {
|
||||
// No space for the params, just show the main
|
||||
return mainParam
|
||||
}
|
||||
|
||||
if len(parts) > 0 {
|
||||
mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
|
||||
}
|
||||
|
||||
return ansi.Truncate(mainParam, paramsWidth, "...")
|
||||
}
|
||||
|
||||
func removeWorkingDirPrefix(path string) string {
|
||||
wd := config.WorkingDirectory()
|
||||
if strings.HasPrefix(path, wd) {
|
||||
path = strings.TrimPrefix(path, wd)
|
||||
}
|
||||
if strings.HasPrefix(path, "/") {
|
||||
path = strings.TrimPrefix(path, "/")
|
||||
}
|
||||
if strings.HasPrefix(path, "./") {
|
||||
path = strings.TrimPrefix(path, "./")
|
||||
}
|
||||
if strings.HasPrefix(path, "../") {
|
||||
path = strings.TrimPrefix(path, "../")
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func renderToolParams(paramWidth int, toolCall message.ToolCall) string {
|
||||
params := ""
|
||||
switch toolCall.Name {
|
||||
case agent.AgentToolName:
|
||||
var params agent.AgentParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
prompt := strings.ReplaceAll(params.Prompt, "\n", " ")
|
||||
return renderParams(paramWidth, prompt)
|
||||
case tools.BashToolName:
|
||||
var params tools.BashParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
command := strings.ReplaceAll(params.Command, "\n", " ")
|
||||
return renderParams(paramWidth, command)
|
||||
case tools.EditToolName:
|
||||
var params tools.EditParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
filePath := removeWorkingDirPrefix(params.FilePath)
|
||||
return renderParams(paramWidth, filePath)
|
||||
case tools.FetchToolName:
|
||||
var params tools.FetchParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
url := params.URL
|
||||
toolParams := []string{
|
||||
url,
|
||||
}
|
||||
if params.Format != "" {
|
||||
toolParams = append(toolParams, "format", params.Format)
|
||||
}
|
||||
if params.Timeout != 0 {
|
||||
toolParams = append(toolParams, "timeout", (time.Duration(params.Timeout) * time.Second).String())
|
||||
}
|
||||
return renderParams(paramWidth, toolParams...)
|
||||
case tools.GlobToolName:
|
||||
var params tools.GlobParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
pattern := params.Pattern
|
||||
toolParams := []string{
|
||||
pattern,
|
||||
}
|
||||
if params.Path != "" {
|
||||
toolParams = append(toolParams, "path", params.Path)
|
||||
}
|
||||
return renderParams(paramWidth, toolParams...)
|
||||
case tools.GrepToolName:
|
||||
var params tools.GrepParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
pattern := params.Pattern
|
||||
toolParams := []string{
|
||||
pattern,
|
||||
}
|
||||
if params.Path != "" {
|
||||
toolParams = append(toolParams, "path", params.Path)
|
||||
}
|
||||
if params.Include != "" {
|
||||
toolParams = append(toolParams, "include", params.Include)
|
||||
}
|
||||
if params.LiteralText {
|
||||
toolParams = append(toolParams, "literal", "true")
|
||||
}
|
||||
return renderParams(paramWidth, toolParams...)
|
||||
case tools.LSToolName:
|
||||
var params tools.LSParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
path := params.Path
|
||||
if path == "" {
|
||||
path = "."
|
||||
}
|
||||
return renderParams(paramWidth, path)
|
||||
case tools.SourcegraphToolName:
|
||||
var params tools.SourcegraphParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
return renderParams(paramWidth, params.Query)
|
||||
case tools.ViewToolName:
|
||||
var params tools.ViewParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
filePath := removeWorkingDirPrefix(params.FilePath)
|
||||
toolParams := []string{
|
||||
filePath,
|
||||
}
|
||||
if params.Limit != 0 {
|
||||
toolParams = append(toolParams, "limit", fmt.Sprintf("%d", params.Limit))
|
||||
}
|
||||
if params.Offset != 0 {
|
||||
toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset))
|
||||
}
|
||||
return renderParams(paramWidth, toolParams...)
|
||||
case tools.WriteToolName:
|
||||
var params tools.WriteParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
filePath := removeWorkingDirPrefix(params.FilePath)
|
||||
return renderParams(paramWidth, filePath)
|
||||
default:
|
||||
input := strings.ReplaceAll(toolCall.Input, "\n", " ")
|
||||
params = renderParams(paramWidth, input)
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func truncateHeight(content string, height int) string {
|
||||
lines := strings.Split(content, "\n")
|
||||
if len(lines) > height {
|
||||
return strings.Join(lines[:height], "\n")
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func renderToolResponse(toolCall message.ToolCall, response message.ToolResult, width int) string {
|
||||
if response.IsError {
|
||||
errContent := fmt.Sprintf("Error: %s", strings.ReplaceAll(response.Content, "\n", " "))
|
||||
errContent = ansi.Truncate(errContent, width-1, "...")
|
||||
return styles.BaseStyle.
|
||||
Foreground(styles.Error).
|
||||
Render(errContent)
|
||||
}
|
||||
resultContent := truncateHeight(response.Content, maxResultHeight)
|
||||
switch toolCall.Name {
|
||||
case agent.AgentToolName:
|
||||
return styles.ForceReplaceBackgroundWithLipgloss(
|
||||
toMarkdown(resultContent, false, width),
|
||||
styles.Background,
|
||||
)
|
||||
case tools.BashToolName:
|
||||
resultContent = fmt.Sprintf("```bash\n%s\n```", resultContent)
|
||||
return styles.ForceReplaceBackgroundWithLipgloss(
|
||||
toMarkdown(resultContent, true, width),
|
||||
styles.Background,
|
||||
)
|
||||
case tools.EditToolName:
|
||||
metadata := tools.EditResponseMetadata{}
|
||||
json.Unmarshal([]byte(response.Metadata), &metadata)
|
||||
truncDiff := truncateHeight(metadata.Diff, maxResultHeight)
|
||||
formattedDiff, _ := diff.FormatDiff(truncDiff, diff.WithTotalWidth(width), diff.WithStyle(diffStyle))
|
||||
return formattedDiff
|
||||
case tools.FetchToolName:
|
||||
var params tools.FetchParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
mdFormat := "markdown"
|
||||
switch params.Format {
|
||||
case "text":
|
||||
mdFormat = "text"
|
||||
case "html":
|
||||
mdFormat = "html"
|
||||
}
|
||||
resultContent = fmt.Sprintf("```%s\n%s\n```", mdFormat, resultContent)
|
||||
return styles.ForceReplaceBackgroundWithLipgloss(
|
||||
toMarkdown(resultContent, true, width),
|
||||
styles.Background,
|
||||
)
|
||||
case tools.GlobToolName:
|
||||
return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
|
||||
case tools.GrepToolName:
|
||||
return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
|
||||
case tools.LSToolName:
|
||||
return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
|
||||
case tools.SourcegraphToolName:
|
||||
return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
|
||||
case tools.ViewToolName:
|
||||
metadata := tools.ViewResponseMetadata{}
|
||||
json.Unmarshal([]byte(response.Metadata), &metadata)
|
||||
ext := filepath.Ext(metadata.FilePath)
|
||||
if ext == "" {
|
||||
ext = ""
|
||||
} else {
|
||||
ext = strings.ToLower(ext[1:])
|
||||
}
|
||||
resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(metadata.Content, maxResultHeight))
|
||||
return styles.ForceReplaceBackgroundWithLipgloss(
|
||||
toMarkdown(resultContent, true, width),
|
||||
styles.Background,
|
||||
)
|
||||
case tools.WriteToolName:
|
||||
params := tools.WriteParams{}
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
metadata := tools.WriteResponseMetadata{}
|
||||
json.Unmarshal([]byte(response.Metadata), &metadata)
|
||||
ext := filepath.Ext(params.FilePath)
|
||||
if ext == "" {
|
||||
ext = ""
|
||||
} else {
|
||||
ext = strings.ToLower(ext[1:])
|
||||
}
|
||||
resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(params.Content, maxResultHeight))
|
||||
return styles.ForceReplaceBackgroundWithLipgloss(
|
||||
toMarkdown(resultContent, true, width),
|
||||
styles.Background,
|
||||
)
|
||||
default:
|
||||
resultContent = fmt.Sprintf("```text\n%s\n```", resultContent)
|
||||
return styles.ForceReplaceBackgroundWithLipgloss(
|
||||
toMarkdown(resultContent, true, width),
|
||||
styles.Background,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func renderToolMessage(
|
||||
toolCall message.ToolCall,
|
||||
allMessages []message.Message,
|
||||
messagesService message.Service,
|
||||
focusedUIMessageId string,
|
||||
nested bool,
|
||||
width int,
|
||||
position int,
|
||||
) uiMessage {
|
||||
if nested {
|
||||
width = width - 3
|
||||
}
|
||||
response := findToolResponse(toolCall.ID, allMessages)
|
||||
toolName := styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf("%s: ", toolName(toolCall.Name)))
|
||||
params := renderToolParams(width-2-lipgloss.Width(toolName), toolCall)
|
||||
responseContent := ""
|
||||
if response != nil {
|
||||
responseContent = renderToolResponse(toolCall, *response, width-2)
|
||||
responseContent = strings.TrimSuffix(responseContent, "\n")
|
||||
} else {
|
||||
responseContent = styles.BaseStyle.
|
||||
Italic(true).
|
||||
Width(width - 2).
|
||||
Foreground(styles.ForgroundDim).
|
||||
Render("Waiting for response...")
|
||||
}
|
||||
style := styles.BaseStyle.
|
||||
Width(width - 1).
|
||||
BorderLeft(true).
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
PaddingLeft(1).
|
||||
BorderForeground(styles.ForgroundDim)
|
||||
|
||||
parts := []string{}
|
||||
if !nested {
|
||||
params := styles.BaseStyle.
|
||||
Width(width - 2 - lipgloss.Width(toolName)).
|
||||
Foreground(styles.ForgroundDim).
|
||||
Render(params)
|
||||
|
||||
parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, toolName, params))
|
||||
} else {
|
||||
prefix := styles.BaseStyle.
|
||||
Foreground(styles.ForgroundDim).
|
||||
Render(" └ ")
|
||||
params := styles.BaseStyle.
|
||||
Width(width - 2 - lipgloss.Width(toolName)).
|
||||
Foreground(styles.ForgroundMid).
|
||||
Render(params)
|
||||
parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, prefix, toolName, params))
|
||||
}
|
||||
if toolCall.Name == agent.AgentToolName {
|
||||
taskMessages, _ := messagesService.List(context.Background(), toolCall.ID)
|
||||
toolCalls := []message.ToolCall{}
|
||||
for _, v := range taskMessages {
|
||||
toolCalls = append(toolCalls, v.ToolCalls()...)
|
||||
}
|
||||
for _, call := range toolCalls {
|
||||
rendered := renderToolMessage(call, []message.Message{}, messagesService, focusedUIMessageId, true, width, 0)
|
||||
parts = append(parts, rendered.content)
|
||||
}
|
||||
}
|
||||
if responseContent != "" && !nested {
|
||||
parts = append(parts, responseContent)
|
||||
}
|
||||
|
||||
content := style.Render(
|
||||
lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
parts...,
|
||||
),
|
||||
)
|
||||
if nested {
|
||||
content = lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
parts...,
|
||||
)
|
||||
}
|
||||
toolMsg := uiMessage{
|
||||
messageType: toolMessageType,
|
||||
position: position,
|
||||
height: lipgloss.Height(content),
|
||||
content: content,
|
||||
}
|
||||
return toolMsg
|
||||
}
|
|
@ -1,742 +0,0 @@
|
|||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/glamour"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/kujtimiihoxha/opencode/internal/app"
|
||||
"github.com/kujtimiihoxha/opencode/internal/llm/agent"
|
||||
"github.com/kujtimiihoxha/opencode/internal/llm/models"
|
||||
"github.com/kujtimiihoxha/opencode/internal/llm/tools"
|
||||
"github.com/kujtimiihoxha/opencode/internal/logging"
|
||||
"github.com/kujtimiihoxha/opencode/internal/message"
|
||||
"github.com/kujtimiihoxha/opencode/internal/pubsub"
|
||||
"github.com/kujtimiihoxha/opencode/internal/session"
|
||||
"github.com/kujtimiihoxha/opencode/internal/tui/layout"
|
||||
"github.com/kujtimiihoxha/opencode/internal/tui/styles"
|
||||
"github.com/kujtimiihoxha/opencode/internal/tui/util"
|
||||
)
|
||||
|
||||
type uiMessageType int
|
||||
|
||||
const (
|
||||
userMessageType uiMessageType = iota
|
||||
assistantMessageType
|
||||
toolMessageType
|
||||
)
|
||||
|
||||
// messagesTickMsg is a message sent by the timer to refresh messages
|
||||
type messagesTickMsg time.Time
|
||||
|
||||
type uiMessage struct {
|
||||
ID string
|
||||
messageType uiMessageType
|
||||
position int
|
||||
height int
|
||||
content string
|
||||
}
|
||||
|
||||
type messagesCmp struct {
|
||||
app *app.App
|
||||
width, height int
|
||||
writingMode bool
|
||||
viewport viewport.Model
|
||||
session session.Session
|
||||
messages []message.Message
|
||||
uiMessages []uiMessage
|
||||
currentMsgID string
|
||||
renderer *glamour.TermRenderer
|
||||
focusRenderer *glamour.TermRenderer
|
||||
cachedContent map[string]string
|
||||
spinner spinner.Model
|
||||
needsRerender bool
|
||||
}
|
||||
|
||||
func (m *messagesCmp) Init() tea.Cmd {
|
||||
return tea.Batch(m.viewport.Init(), m.spinner.Tick, m.tickMessages())
|
||||
}
|
||||
|
||||
func (m *messagesCmp) tickMessages() tea.Cmd {
|
||||
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
|
||||
return messagesTickMsg(t)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
switch msg := msg.(type) {
|
||||
case messagesTickMsg:
|
||||
// Refresh messages if we have an active session
|
||||
if m.session.ID != "" {
|
||||
messages, err := m.app.Messages.List(context.Background(), m.session.ID)
|
||||
if err == nil {
|
||||
m.messages = messages
|
||||
m.needsRerender = true
|
||||
}
|
||||
}
|
||||
// Continue ticking
|
||||
cmds = append(cmds, m.tickMessages())
|
||||
case EditorFocusMsg:
|
||||
m.writingMode = bool(msg)
|
||||
case SessionSelectedMsg:
|
||||
if msg.ID != m.session.ID {
|
||||
cmd := m.SetSession(msg)
|
||||
m.needsRerender = true
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
case SessionClearedMsg:
|
||||
m.session = session.Session{}
|
||||
m.messages = make([]message.Message, 0)
|
||||
m.currentMsgID = ""
|
||||
m.needsRerender = true
|
||||
m.cachedContent = make(map[string]string)
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
if m.writingMode {
|
||||
return m, nil
|
||||
}
|
||||
case pubsub.Event[message.Message]:
|
||||
if msg.Type == pubsub.CreatedEvent {
|
||||
if msg.Payload.SessionID == m.session.ID {
|
||||
// check if message exists
|
||||
|
||||
messageExists := false
|
||||
for _, v := range m.messages {
|
||||
if v.ID == msg.Payload.ID {
|
||||
messageExists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !messageExists {
|
||||
// If we have messages, ensure the previous last message is not cached
|
||||
if len(m.messages) > 0 {
|
||||
lastMsgID := m.messages[len(m.messages)-1].ID
|
||||
delete(m.cachedContent, lastMsgID)
|
||||
}
|
||||
|
||||
m.messages = append(m.messages, msg.Payload)
|
||||
delete(m.cachedContent, m.currentMsgID)
|
||||
m.currentMsgID = msg.Payload.ID
|
||||
m.needsRerender = true
|
||||
}
|
||||
}
|
||||
for _, v := range m.messages {
|
||||
for _, c := range v.ToolCalls() {
|
||||
if c.ID == msg.Payload.SessionID {
|
||||
m.needsRerender = true
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if msg.Type == pubsub.UpdatedEvent && msg.Payload.SessionID == m.session.ID {
|
||||
logging.Debug("Message", "finish", msg.Payload.FinishReason())
|
||||
for i, v := range m.messages {
|
||||
if v.ID == msg.Payload.ID {
|
||||
m.messages[i] = msg.Payload
|
||||
delete(m.cachedContent, msg.Payload.ID)
|
||||
|
||||
// If this is the last message, ensure it's not cached
|
||||
if i == len(m.messages)-1 {
|
||||
delete(m.cachedContent, msg.Payload.ID)
|
||||
}
|
||||
|
||||
m.needsRerender = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
oldPos := m.viewport.YPosition
|
||||
u, cmd := m.viewport.Update(msg)
|
||||
m.viewport = u
|
||||
m.needsRerender = m.needsRerender || m.viewport.YPosition != oldPos
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
spinner, cmd := m.spinner.Update(msg)
|
||||
m.spinner = spinner
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
if m.needsRerender {
|
||||
m.renderView()
|
||||
if len(m.messages) > 0 {
|
||||
if msg, ok := msg.(pubsub.Event[message.Message]); ok {
|
||||
if (msg.Type == pubsub.CreatedEvent) ||
|
||||
(msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == m.messages[len(m.messages)-1].ID) {
|
||||
m.viewport.GotoBottom()
|
||||
}
|
||||
}
|
||||
}
|
||||
m.needsRerender = false
|
||||
}
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *messagesCmp) IsAgentWorking() bool {
|
||||
return m.app.CoderAgent.IsSessionBusy(m.session.ID)
|
||||
}
|
||||
|
||||
func (m *messagesCmp) renderSimpleMessage(msg message.Message, info ...string) string {
|
||||
// Check if this is the last message in the list
|
||||
isLastMessage := len(m.messages) > 0 && m.messages[len(m.messages)-1].ID == msg.ID
|
||||
|
||||
// Only use cache for non-last messages
|
||||
if !isLastMessage {
|
||||
if v, ok := m.cachedContent[msg.ID]; ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
style := styles.BaseStyle.
|
||||
Width(m.width).
|
||||
BorderLeft(true).
|
||||
Foreground(styles.ForgroundDim).
|
||||
BorderForeground(styles.ForgroundDim).
|
||||
BorderStyle(lipgloss.ThickBorder())
|
||||
|
||||
renderer := m.renderer
|
||||
if msg.ID == m.currentMsgID {
|
||||
style = style.
|
||||
Foreground(styles.Forground).
|
||||
BorderForeground(styles.Blue).
|
||||
BorderStyle(lipgloss.ThickBorder())
|
||||
renderer = m.focusRenderer
|
||||
}
|
||||
c, _ := renderer.Render(msg.Content().String())
|
||||
parts := []string{
|
||||
styles.ForceReplaceBackgroundWithLipgloss(c, styles.Background),
|
||||
}
|
||||
// remove newline at the end
|
||||
parts[0] = strings.TrimSuffix(parts[0], "\n")
|
||||
if len(info) > 0 {
|
||||
parts = append(parts, info...)
|
||||
}
|
||||
rendered := style.Render(
|
||||
lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
parts...,
|
||||
),
|
||||
)
|
||||
|
||||
// Only cache if it's not the last message
|
||||
if !isLastMessage {
|
||||
m.cachedContent[msg.ID] = rendered
|
||||
}
|
||||
|
||||
return rendered
|
||||
}
|
||||
|
||||
func formatTimeDifference(unixTime1, unixTime2 int64) string {
|
||||
diffSeconds := float64(math.Abs(float64(unixTime2 - unixTime1)))
|
||||
|
||||
if diffSeconds < 60 {
|
||||
return fmt.Sprintf("%.1fs", diffSeconds)
|
||||
}
|
||||
|
||||
minutes := int(diffSeconds / 60)
|
||||
seconds := int(diffSeconds) % 60
|
||||
return fmt.Sprintf("%dm%ds", minutes, seconds)
|
||||
}
|
||||
|
||||
func (m *messagesCmp) findToolResponse(callID string) *message.ToolResult {
|
||||
for _, v := range m.messages {
|
||||
for _, c := range v.ToolResults() {
|
||||
if c.ToolCallID == callID {
|
||||
return &c
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *messagesCmp) renderToolCall(toolCall message.ToolCall, isNested bool) string {
|
||||
key := ""
|
||||
value := ""
|
||||
result := styles.BaseStyle.Foreground(styles.PrimaryColor).Render(m.spinner.View() + " waiting for response...")
|
||||
|
||||
response := m.findToolResponse(toolCall.ID)
|
||||
if response != nil && response.IsError {
|
||||
// Clean up error message for display by removing newlines
|
||||
// This ensures error messages display properly in the UI
|
||||
errMsg := strings.ReplaceAll(response.Content, "\n", " ")
|
||||
result = styles.BaseStyle.Foreground(styles.Error).Render(ansi.Truncate(errMsg, 40, "..."))
|
||||
} else if response != nil {
|
||||
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render("Done")
|
||||
}
|
||||
switch toolCall.Name {
|
||||
// TODO: add result data to the tools
|
||||
case agent.AgentToolName:
|
||||
key = "Task"
|
||||
var params agent.AgentParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
value = strings.ReplaceAll(params.Prompt, "\n", " ")
|
||||
if response != nil && !response.IsError {
|
||||
firstRow := strings.ReplaceAll(response.Content, "\n", " ")
|
||||
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(ansi.Truncate(firstRow, 40, "..."))
|
||||
}
|
||||
case tools.BashToolName:
|
||||
key = "Bash"
|
||||
var params tools.BashParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
value = params.Command
|
||||
if response != nil && !response.IsError {
|
||||
metadata := tools.BashResponseMetadata{}
|
||||
json.Unmarshal([]byte(response.Metadata), &metadata)
|
||||
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("Took %s", formatTimeDifference(metadata.StartTime, metadata.EndTime)))
|
||||
}
|
||||
|
||||
case tools.EditToolName:
|
||||
key = "Edit"
|
||||
var params tools.EditParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
value = params.FilePath
|
||||
if response != nil && !response.IsError {
|
||||
metadata := tools.EditResponseMetadata{}
|
||||
json.Unmarshal([]byte(response.Metadata), &metadata)
|
||||
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d Additions %d Removals", metadata.Additions, metadata.Removals))
|
||||
}
|
||||
case tools.FetchToolName:
|
||||
key = "Fetch"
|
||||
var params tools.FetchParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
value = params.URL
|
||||
if response != nil && !response.IsError {
|
||||
result = styles.BaseStyle.Foreground(styles.Error).Render(response.Content)
|
||||
}
|
||||
case tools.GlobToolName:
|
||||
key = "Glob"
|
||||
var params tools.GlobParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
if params.Path == "" {
|
||||
params.Path = "."
|
||||
}
|
||||
value = fmt.Sprintf("%s (%s)", params.Pattern, params.Path)
|
||||
if response != nil && !response.IsError {
|
||||
metadata := tools.GlobResponseMetadata{}
|
||||
json.Unmarshal([]byte(response.Metadata), &metadata)
|
||||
if metadata.Truncated {
|
||||
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found (truncated)", metadata.NumberOfFiles))
|
||||
} else {
|
||||
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found", metadata.NumberOfFiles))
|
||||
}
|
||||
}
|
||||
case tools.GrepToolName:
|
||||
key = "Grep"
|
||||
var params tools.GrepParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
if params.Path == "" {
|
||||
params.Path = "."
|
||||
}
|
||||
value = fmt.Sprintf("%s (%s)", params.Pattern, params.Path)
|
||||
if response != nil && !response.IsError {
|
||||
metadata := tools.GrepResponseMetadata{}
|
||||
json.Unmarshal([]byte(response.Metadata), &metadata)
|
||||
if metadata.Truncated {
|
||||
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found (truncated)", metadata.NumberOfMatches))
|
||||
} else {
|
||||
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found", metadata.NumberOfMatches))
|
||||
}
|
||||
}
|
||||
case tools.LSToolName:
|
||||
key = "ls"
|
||||
var params tools.LSParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
if params.Path == "" {
|
||||
params.Path = "."
|
||||
}
|
||||
value = params.Path
|
||||
if response != nil && !response.IsError {
|
||||
metadata := tools.LSResponseMetadata{}
|
||||
json.Unmarshal([]byte(response.Metadata), &metadata)
|
||||
if metadata.Truncated {
|
||||
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found (truncated)", metadata.NumberOfFiles))
|
||||
} else {
|
||||
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found", metadata.NumberOfFiles))
|
||||
}
|
||||
}
|
||||
case tools.SourcegraphToolName:
|
||||
key = "Sourcegraph"
|
||||
var params tools.SourcegraphParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
value = params.Query
|
||||
if response != nil && !response.IsError {
|
||||
metadata := tools.SourcegraphResponseMetadata{}
|
||||
json.Unmarshal([]byte(response.Metadata), &metadata)
|
||||
if metadata.Truncated {
|
||||
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d matches found (truncated)", metadata.NumberOfMatches))
|
||||
} else {
|
||||
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d matches found", metadata.NumberOfMatches))
|
||||
}
|
||||
}
|
||||
case tools.ViewToolName:
|
||||
key = "View"
|
||||
var params tools.ViewParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
value = params.FilePath
|
||||
case tools.WriteToolName:
|
||||
key = "Write"
|
||||
var params tools.WriteParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
value = params.FilePath
|
||||
if response != nil && !response.IsError {
|
||||
metadata := tools.WriteResponseMetadata{}
|
||||
json.Unmarshal([]byte(response.Metadata), &metadata)
|
||||
|
||||
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d Additions %d Removals", metadata.Additions, metadata.Removals))
|
||||
}
|
||||
default:
|
||||
key = toolCall.Name
|
||||
var params map[string]any
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
jsonData, _ := json.Marshal(params)
|
||||
value = string(jsonData)
|
||||
}
|
||||
|
||||
style := styles.BaseStyle.
|
||||
Width(m.width).
|
||||
BorderLeft(true).
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
PaddingLeft(1).
|
||||
BorderForeground(styles.Yellow)
|
||||
|
||||
keyStyle := styles.BaseStyle.
|
||||
Foreground(styles.ForgroundDim)
|
||||
valyeStyle := styles.BaseStyle.
|
||||
Foreground(styles.Forground)
|
||||
|
||||
if isNested {
|
||||
valyeStyle = valyeStyle.Foreground(styles.ForgroundMid)
|
||||
}
|
||||
keyValye := keyStyle.Render(
|
||||
fmt.Sprintf("%s: ", key),
|
||||
)
|
||||
if !isNested {
|
||||
value = valyeStyle.
|
||||
Render(
|
||||
ansi.Truncate(
|
||||
value+" ",
|
||||
m.width-lipgloss.Width(keyValye)-2-lipgloss.Width(result),
|
||||
"...",
|
||||
),
|
||||
)
|
||||
value += result
|
||||
|
||||
} else {
|
||||
keyValye = keyStyle.Render(
|
||||
fmt.Sprintf(" └ %s: ", key),
|
||||
)
|
||||
value = valyeStyle.
|
||||
Width(m.width - lipgloss.Width(keyValye) - 2).
|
||||
Render(
|
||||
ansi.Truncate(
|
||||
value,
|
||||
m.width-lipgloss.Width(keyValye)-2,
|
||||
"...",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
innerToolCalls := make([]string, 0)
|
||||
if toolCall.Name == agent.AgentToolName {
|
||||
messages, _ := m.app.Messages.List(context.Background(), toolCall.ID)
|
||||
toolCalls := make([]message.ToolCall, 0)
|
||||
for _, v := range messages {
|
||||
toolCalls = append(toolCalls, v.ToolCalls()...)
|
||||
}
|
||||
for _, v := range toolCalls {
|
||||
call := m.renderToolCall(v, true)
|
||||
innerToolCalls = append(innerToolCalls, call)
|
||||
}
|
||||
}
|
||||
|
||||
if isNested {
|
||||
return lipgloss.JoinHorizontal(
|
||||
lipgloss.Left,
|
||||
keyValye,
|
||||
value,
|
||||
)
|
||||
}
|
||||
callContent := lipgloss.JoinHorizontal(
|
||||
lipgloss.Left,
|
||||
keyValye,
|
||||
value,
|
||||
)
|
||||
callContent = strings.ReplaceAll(callContent, "\n", "")
|
||||
if len(innerToolCalls) > 0 {
|
||||
callContent = lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
callContent,
|
||||
lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
innerToolCalls...,
|
||||
),
|
||||
)
|
||||
}
|
||||
return style.Render(callContent)
|
||||
}
|
||||
|
||||
func (m *messagesCmp) renderAssistantMessage(msg message.Message) []uiMessage {
|
||||
// find the user message that is before this assistant message
|
||||
var userMsg message.Message
|
||||
for i := len(m.messages) - 1; i >= 0; i-- {
|
||||
if m.messages[i].Role == message.User {
|
||||
userMsg = m.messages[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
messages := make([]uiMessage, 0)
|
||||
if msg.Content().String() != "" {
|
||||
info := make([]string, 0)
|
||||
if msg.IsFinished() && msg.FinishReason() == "end_turn" {
|
||||
finish := msg.FinishPart()
|
||||
took := formatTimeDifference(userMsg.CreatedAt, finish.Time)
|
||||
|
||||
info = append(info, styles.BaseStyle.Width(m.width-1).Foreground(styles.ForgroundDim).Render(
|
||||
fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, took),
|
||||
))
|
||||
}
|
||||
content := m.renderSimpleMessage(msg, info...)
|
||||
messages = append(messages, uiMessage{
|
||||
messageType: assistantMessageType,
|
||||
position: 0, // gets updated in renderView
|
||||
height: lipgloss.Height(content),
|
||||
content: content,
|
||||
})
|
||||
}
|
||||
for _, v := range msg.ToolCalls() {
|
||||
content := m.renderToolCall(v, false)
|
||||
messages = append(messages,
|
||||
uiMessage{
|
||||
messageType: toolMessageType,
|
||||
position: 0, // gets updated in renderView
|
||||
height: lipgloss.Height(content),
|
||||
content: content,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
func (m *messagesCmp) renderView() {
|
||||
m.uiMessages = make([]uiMessage, 0)
|
||||
pos := 0
|
||||
|
||||
// If we have messages, ensure the last message is not cached
|
||||
// This ensures we always render the latest content for the most recent message
|
||||
// which may be actively updating (e.g., during generation)
|
||||
if len(m.messages) > 0 {
|
||||
lastMsgID := m.messages[len(m.messages)-1].ID
|
||||
delete(m.cachedContent, lastMsgID)
|
||||
}
|
||||
|
||||
// Limit cache to 10 messages
|
||||
if len(m.cachedContent) > 15 {
|
||||
// Create a list of keys to delete (oldest messages first)
|
||||
keys := make([]string, 0, len(m.cachedContent))
|
||||
for k := range m.cachedContent {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
// Delete oldest messages until we have 10 or fewer
|
||||
for i := 0; i < len(keys)-15; i++ {
|
||||
delete(m.cachedContent, keys[i])
|
||||
}
|
||||
}
|
||||
|
||||
for _, v := range m.messages {
|
||||
switch v.Role {
|
||||
case message.User:
|
||||
content := m.renderSimpleMessage(v)
|
||||
m.uiMessages = append(m.uiMessages, uiMessage{
|
||||
messageType: userMessageType,
|
||||
position: pos,
|
||||
height: lipgloss.Height(content),
|
||||
content: content,
|
||||
})
|
||||
pos += lipgloss.Height(content) + 1 // + 1 for spacing
|
||||
case message.Assistant:
|
||||
assistantMessages := m.renderAssistantMessage(v)
|
||||
for _, msg := range assistantMessages {
|
||||
msg.position = pos
|
||||
m.uiMessages = append(m.uiMessages, msg)
|
||||
pos += msg.height + 1 // + 1 for spacing
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
messages := make([]string, 0)
|
||||
for _, v := range m.uiMessages {
|
||||
messages = append(messages, v.content,
|
||||
styles.BaseStyle.
|
||||
Width(m.width).
|
||||
Render(
|
||||
"",
|
||||
),
|
||||
)
|
||||
}
|
||||
m.viewport.SetContent(
|
||||
styles.BaseStyle.
|
||||
Width(m.width).
|
||||
Render(
|
||||
lipgloss.JoinVertical(
|
||||
lipgloss.Top,
|
||||
messages...,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (m *messagesCmp) View() string {
|
||||
if len(m.messages) == 0 {
|
||||
content := styles.BaseStyle.
|
||||
Width(m.width).
|
||||
Height(m.height - 1).
|
||||
Render(
|
||||
m.initialScreen(),
|
||||
)
|
||||
|
||||
return styles.BaseStyle.
|
||||
Width(m.width).
|
||||
Render(
|
||||
lipgloss.JoinVertical(
|
||||
lipgloss.Top,
|
||||
content,
|
||||
m.help(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return styles.BaseStyle.
|
||||
Width(m.width).
|
||||
Render(
|
||||
lipgloss.JoinVertical(
|
||||
lipgloss.Top,
|
||||
m.viewport.View(),
|
||||
m.help(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (m *messagesCmp) help() string {
|
||||
text := ""
|
||||
|
||||
if m.IsAgentWorking() {
|
||||
text += styles.BaseStyle.Foreground(styles.PrimaryColor).Bold(true).Render(
|
||||
fmt.Sprintf("%s %s ", m.spinner.View(), "Generating..."),
|
||||
)
|
||||
}
|
||||
if m.writingMode {
|
||||
text += lipgloss.JoinHorizontal(
|
||||
lipgloss.Left,
|
||||
styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "),
|
||||
styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("esc"),
|
||||
styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render(" to exit writing mode"),
|
||||
)
|
||||
} else {
|
||||
text += lipgloss.JoinHorizontal(
|
||||
lipgloss.Left,
|
||||
styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "),
|
||||
styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("i"),
|
||||
styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render(" to start writing"),
|
||||
)
|
||||
}
|
||||
|
||||
return styles.BaseStyle.
|
||||
Width(m.width).
|
||||
Render(text)
|
||||
}
|
||||
|
||||
func (m *messagesCmp) initialScreen() string {
|
||||
return styles.BaseStyle.Width(m.width).Render(
|
||||
lipgloss.JoinVertical(
|
||||
lipgloss.Top,
|
||||
header(m.width),
|
||||
"",
|
||||
lspsConfigured(m.width),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (m *messagesCmp) SetSize(width, height int) {
|
||||
m.width = width
|
||||
m.height = height
|
||||
m.viewport.Width = width
|
||||
m.viewport.Height = height - 1
|
||||
focusRenderer, _ := glamour.NewTermRenderer(
|
||||
glamour.WithStyles(styles.MarkdownTheme(true)),
|
||||
glamour.WithWordWrap(width-1),
|
||||
)
|
||||
renderer, _ := glamour.NewTermRenderer(
|
||||
glamour.WithStyles(styles.MarkdownTheme(false)),
|
||||
glamour.WithWordWrap(width-1),
|
||||
)
|
||||
m.focusRenderer = focusRenderer
|
||||
// clear the cached content
|
||||
for k := range m.cachedContent {
|
||||
delete(m.cachedContent, k)
|
||||
}
|
||||
m.renderer = renderer
|
||||
if len(m.messages) > 0 {
|
||||
m.renderView()
|
||||
m.viewport.GotoBottom()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *messagesCmp) GetSize() (int, int) {
|
||||
return m.width, m.height
|
||||
}
|
||||
|
||||
func (m *messagesCmp) SetSession(session session.Session) tea.Cmd {
|
||||
m.session = session
|
||||
messages, err := m.app.Messages.List(context.Background(), session.ID)
|
||||
if err != nil {
|
||||
return util.ReportError(err)
|
||||
}
|
||||
m.messages = messages
|
||||
m.currentMsgID = m.messages[len(m.messages)-1].ID
|
||||
m.needsRerender = true
|
||||
m.cachedContent = make(map[string]string)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *messagesCmp) BindingKeys() []key.Binding {
|
||||
bindings := layout.KeyMapToSlice(m.viewport.KeyMap)
|
||||
return bindings
|
||||
}
|
||||
|
||||
func NewMessagesCmp(app *app.App) tea.Model {
|
||||
focusRenderer, _ := glamour.NewTermRenderer(
|
||||
glamour.WithStyles(styles.MarkdownTheme(true)),
|
||||
glamour.WithWordWrap(80),
|
||||
)
|
||||
renderer, _ := glamour.NewTermRenderer(
|
||||
glamour.WithStyles(styles.MarkdownTheme(false)),
|
||||
glamour.WithWordWrap(80),
|
||||
)
|
||||
|
||||
s := spinner.New()
|
||||
s.Spinner = spinner.Pulse
|
||||
return &messagesCmp{
|
||||
app: app,
|
||||
writingMode: true,
|
||||
cachedContent: make(map[string]string),
|
||||
viewport: viewport.New(0, 0),
|
||||
focusRenderer: focusRenderer,
|
||||
renderer: renderer,
|
||||
spinner: s,
|
||||
}
|
||||
}
|
|
@ -51,6 +51,12 @@ func (m *sidebarCmp) Init() tea.Cmd {
|
|||
|
||||
func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case SessionSelectedMsg:
|
||||
if msg.ID != m.session.ID {
|
||||
m.session = msg
|
||||
ctx := context.Background()
|
||||
m.loadModifiedFiles(ctx)
|
||||
}
|
||||
case pubsub.Event[session.Session]:
|
||||
if msg.Type == pubsub.UpdatedEvent {
|
||||
if m.session.ID == msg.Payload.ID {
|
||||
|
@ -59,10 +65,16 @@ func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
case pubsub.Event[history.File]:
|
||||
if msg.Payload.SessionID == m.session.ID {
|
||||
// When a file changes, reload all modified files
|
||||
// This ensures we have the complete and accurate list
|
||||
// Process the individual file change instead of reloading all files
|
||||
ctx := context.Background()
|
||||
m.loadModifiedFiles(ctx)
|
||||
m.processFileChanges(ctx, msg.Payload)
|
||||
|
||||
// Return a command to continue receiving events
|
||||
return m, func() tea.Msg {
|
||||
ctx := context.Background()
|
||||
filesCh := m.history.Subscribe(ctx)
|
||||
return <-filesCh
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
|
@ -71,6 +83,8 @@ func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
func (m *sidebarCmp) View() string {
|
||||
return styles.BaseStyle.
|
||||
Width(m.width).
|
||||
PaddingLeft(4).
|
||||
PaddingRight(2).
|
||||
Height(m.height - 1).
|
||||
Render(
|
||||
lipgloss.JoinVertical(
|
||||
|
@ -79,9 +93,9 @@ func (m *sidebarCmp) View() string {
|
|||
" ",
|
||||
m.sessionSection(),
|
||||
" ",
|
||||
m.modifiedFiles(),
|
||||
" ",
|
||||
lspsConfigured(m.width),
|
||||
" ",
|
||||
m.modifiedFiles(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
@ -170,9 +184,10 @@ func (m *sidebarCmp) modifiedFiles() string {
|
|||
)
|
||||
}
|
||||
|
||||
func (m *sidebarCmp) SetSize(width, height int) {
|
||||
func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
|
||||
m.width = width
|
||||
m.height = height
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *sidebarCmp) GetSize() (int, int) {
|
||||
|
@ -203,6 +218,12 @@ func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Clear the existing map to rebuild it
|
||||
m.modFiles = make(map[string]struct {
|
||||
additions int
|
||||
removals int
|
||||
})
|
||||
|
||||
// Process each latest file
|
||||
for _, file := range latestFiles {
|
||||
// Skip if this is the initial version (no changes to show)
|
||||
|
@ -250,28 +271,23 @@ func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) {
|
|||
}
|
||||
|
||||
func (m *sidebarCmp) processFileChanges(ctx context.Context, file history.File) {
|
||||
// Skip if not the latest version
|
||||
// Skip if this is the initial version (no changes to show)
|
||||
if file.Version == history.InitialVersion {
|
||||
return
|
||||
}
|
||||
|
||||
// Get all versions of this file
|
||||
fileVersions, err := m.history.ListBySession(ctx, m.session.ID)
|
||||
if err != nil {
|
||||
// Find the initial version for this file
|
||||
initialVersion, err := m.findInitialVersion(ctx, file.Path)
|
||||
if err != nil || initialVersion.ID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Find the initial version
|
||||
var initialVersion history.File
|
||||
for _, v := range fileVersions {
|
||||
if v.Path == file.Path && v.Version == history.InitialVersion {
|
||||
initialVersion = v
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if we can't find the initial version
|
||||
if initialVersion.ID == "" {
|
||||
// Skip if content hasn't changed
|
||||
if initialVersion.Content == file.Content {
|
||||
// If this file was previously modified but now matches the initial version,
|
||||
// remove it from the modified files list
|
||||
displayPath := getDisplayPath(file.Path)
|
||||
delete(m.modFiles, displayPath)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -280,12 +296,7 @@ func (m *sidebarCmp) processFileChanges(ctx context.Context, file history.File)
|
|||
|
||||
// Only add to modified files if there are changes
|
||||
if additions > 0 || removals > 0 {
|
||||
// Remove working directory prefix from file path
|
||||
displayPath := file.Path
|
||||
workingDir := config.WorkingDirectory()
|
||||
displayPath = strings.TrimPrefix(displayPath, workingDir)
|
||||
displayPath = strings.TrimPrefix(displayPath, "/")
|
||||
|
||||
displayPath := getDisplayPath(file.Path)
|
||||
m.modFiles[displayPath] = struct {
|
||||
additions int
|
||||
removals int
|
||||
|
@ -293,5 +304,34 @@ func (m *sidebarCmp) processFileChanges(ctx context.Context, file history.File)
|
|||
additions: additions,
|
||||
removals: removals,
|
||||
}
|
||||
} else {
|
||||
// If no changes, remove from modified files
|
||||
displayPath := getDisplayPath(file.Path)
|
||||
delete(m.modFiles, displayPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to find the initial version of a file
|
||||
func (m *sidebarCmp) findInitialVersion(ctx context.Context, path string) (history.File, error) {
|
||||
// Get all versions of this file for the session
|
||||
fileVersions, err := m.history.ListBySession(ctx, m.session.ID)
|
||||
if err != nil {
|
||||
return history.File{}, err
|
||||
}
|
||||
|
||||
// Find the initial version
|
||||
for _, v := range fileVersions {
|
||||
if v.Path == path && v.Version == history.InitialVersion {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
|
||||
return history.File{}, fmt.Errorf("initial version not found")
|
||||
}
|
||||
|
||||
// Helper function to get the display path for a file
|
||||
func getDisplayPath(path string) string {
|
||||
workingDir := config.WorkingDirectory()
|
||||
displayPath := strings.TrimPrefix(path, workingDir)
|
||||
return strings.TrimPrefix(displayPath, "/")
|
||||
}
|
||||
|
|
|
@ -166,19 +166,31 @@ func (m *statusCmp) projectDiagnostics() string {
|
|||
diagnostics := []string{}
|
||||
|
||||
if len(errorDiagnostics) > 0 {
|
||||
errStr := lipgloss.NewStyle().Foreground(styles.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, len(errorDiagnostics)))
|
||||
errStr := lipgloss.NewStyle().
|
||||
Background(styles.BackgroundDarker).
|
||||
Foreground(styles.Error).
|
||||
Render(fmt.Sprintf("%s %d", styles.ErrorIcon, len(errorDiagnostics)))
|
||||
diagnostics = append(diagnostics, errStr)
|
||||
}
|
||||
if len(warnDiagnostics) > 0 {
|
||||
warnStr := lipgloss.NewStyle().Foreground(styles.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, len(warnDiagnostics)))
|
||||
warnStr := lipgloss.NewStyle().
|
||||
Background(styles.BackgroundDarker).
|
||||
Foreground(styles.Warning).
|
||||
Render(fmt.Sprintf("%s %d", styles.WarningIcon, len(warnDiagnostics)))
|
||||
diagnostics = append(diagnostics, warnStr)
|
||||
}
|
||||
if len(hintDiagnostics) > 0 {
|
||||
hintStr := lipgloss.NewStyle().Foreground(styles.Text).Render(fmt.Sprintf("%s %d", styles.HintIcon, len(hintDiagnostics)))
|
||||
hintStr := lipgloss.NewStyle().
|
||||
Background(styles.BackgroundDarker).
|
||||
Foreground(styles.Text).
|
||||
Render(fmt.Sprintf("%s %d", styles.HintIcon, len(hintDiagnostics)))
|
||||
diagnostics = append(diagnostics, hintStr)
|
||||
}
|
||||
if len(infoDiagnostics) > 0 {
|
||||
infoStr := lipgloss.NewStyle().Foreground(styles.Peach).Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics)))
|
||||
infoStr := lipgloss.NewStyle().
|
||||
Background(styles.BackgroundDarker).
|
||||
Foreground(styles.Peach).
|
||||
Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics)))
|
||||
diagnostics = append(diagnostics, infoStr)
|
||||
}
|
||||
|
||||
|
@ -187,10 +199,12 @@ func (m *statusCmp) projectDiagnostics() string {
|
|||
|
||||
func (m statusCmp) availableFooterMsgWidth(diagnostics string) int {
|
||||
tokens := ""
|
||||
tokensWidth := 0
|
||||
if m.session.ID != "" {
|
||||
tokens = formatTokensAndCost(m.session.PromptTokens+m.session.CompletionTokens, m.session.Cost)
|
||||
tokensWidth = lipgloss.Width(tokens) + 2
|
||||
}
|
||||
return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-lipgloss.Width(tokens))
|
||||
return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-tokensWidth)
|
||||
}
|
||||
|
||||
func (m statusCmp) model() string {
|
||||
|
|
|
@ -36,7 +36,7 @@ type PermissionResponseMsg struct {
|
|||
type PermissionDialogCmp interface {
|
||||
tea.Model
|
||||
layout.Bindings
|
||||
SetPermissions(permission permission.PermissionRequest)
|
||||
SetPermissions(permission permission.PermissionRequest) tea.Cmd
|
||||
}
|
||||
|
||||
type permissionsMapping struct {
|
||||
|
@ -98,7 +98,8 @@ func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
p.windowSize = msg
|
||||
p.SetSize()
|
||||
cmd := p.SetSize()
|
||||
cmds = append(cmds, cmd)
|
||||
p.markdownCache = make(map[string]string)
|
||||
p.diffCache = make(map[string]string)
|
||||
case tea.KeyMsg:
|
||||
|
@ -267,7 +268,7 @@ func (p *permissionDialogCmp) renderEditContent() string {
|
|||
}
|
||||
|
||||
func (p *permissionDialogCmp) renderPatchContent() string {
|
||||
if pr, ok := p.permission.Params.(tools.PatchPermissionsParams); ok {
|
||||
if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
|
||||
diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
|
||||
return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
|
||||
})
|
||||
|
@ -401,9 +402,9 @@ func (p *permissionDialogCmp) BindingKeys() []key.Binding {
|
|||
return layout.KeyMapToSlice(helpKeys)
|
||||
}
|
||||
|
||||
func (p *permissionDialogCmp) SetSize() {
|
||||
func (p *permissionDialogCmp) SetSize() tea.Cmd {
|
||||
if p.permission.ID == "" {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
switch p.permission.ToolName {
|
||||
case tools.BashToolName:
|
||||
|
@ -422,11 +423,12 @@ func (p *permissionDialogCmp) SetSize() {
|
|||
p.width = int(float64(p.windowSize.Width) * 0.7)
|
||||
p.height = int(float64(p.windowSize.Height) * 0.5)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *permissionDialogCmp) SetPermissions(permission permission.PermissionRequest) {
|
||||
func (p *permissionDialogCmp) SetPermissions(permission permission.PermissionRequest) tea.Cmd {
|
||||
p.permission = permission
|
||||
p.SetSize()
|
||||
return p.SetSize()
|
||||
}
|
||||
|
||||
// Helper to get or set cached diff content
|
||||
|
|
224
internal/tui/components/dialog/session.go
Normal file
224
internal/tui/components/dialog/session.go
Normal file
|
@ -0,0 +1,224 @@
|
|||
package dialog
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/kujtimiihoxha/opencode/internal/session"
|
||||
"github.com/kujtimiihoxha/opencode/internal/tui/layout"
|
||||
"github.com/kujtimiihoxha/opencode/internal/tui/styles"
|
||||
"github.com/kujtimiihoxha/opencode/internal/tui/util"
|
||||
)
|
||||
|
||||
// SessionSelectedMsg is sent when a session is selected
|
||||
type SessionSelectedMsg struct {
|
||||
Session session.Session
|
||||
}
|
||||
|
||||
// CloseSessionDialogMsg is sent when the session dialog is closed
|
||||
type CloseSessionDialogMsg struct{}
|
||||
|
||||
// SessionDialog interface for the session switching dialog
|
||||
type SessionDialog interface {
|
||||
tea.Model
|
||||
layout.Bindings
|
||||
SetSessions(sessions []session.Session)
|
||||
SetSelectedSession(sessionID string)
|
||||
}
|
||||
|
||||
type sessionDialogCmp struct {
|
||||
sessions []session.Session
|
||||
selectedIdx int
|
||||
width int
|
||||
height int
|
||||
selectedSessionID string
|
||||
}
|
||||
|
||||
type sessionKeyMap struct {
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
Enter key.Binding
|
||||
Escape key.Binding
|
||||
J key.Binding
|
||||
K key.Binding
|
||||
}
|
||||
|
||||
var sessionKeys = sessionKeyMap{
|
||||
Up: key.NewBinding(
|
||||
key.WithKeys("up"),
|
||||
key.WithHelp("↑", "previous session"),
|
||||
),
|
||||
Down: key.NewBinding(
|
||||
key.WithKeys("down"),
|
||||
key.WithHelp("↓", "next session"),
|
||||
),
|
||||
Enter: key.NewBinding(
|
||||
key.WithKeys("enter"),
|
||||
key.WithHelp("enter", "select session"),
|
||||
),
|
||||
Escape: key.NewBinding(
|
||||
key.WithKeys("esc"),
|
||||
key.WithHelp("esc", "close"),
|
||||
),
|
||||
J: key.NewBinding(
|
||||
key.WithKeys("j"),
|
||||
key.WithHelp("j", "next session"),
|
||||
),
|
||||
K: key.NewBinding(
|
||||
key.WithKeys("k"),
|
||||
key.WithHelp("k", "previous session"),
|
||||
),
|
||||
}
|
||||
|
||||
func (s *sessionDialogCmp) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case key.Matches(msg, sessionKeys.Up) || key.Matches(msg, sessionKeys.K):
|
||||
if s.selectedIdx > 0 {
|
||||
s.selectedIdx--
|
||||
}
|
||||
return s, nil
|
||||
case key.Matches(msg, sessionKeys.Down) || key.Matches(msg, sessionKeys.J):
|
||||
if s.selectedIdx < len(s.sessions)-1 {
|
||||
s.selectedIdx++
|
||||
}
|
||||
return s, nil
|
||||
case key.Matches(msg, sessionKeys.Enter):
|
||||
if len(s.sessions) > 0 {
|
||||
return s, util.CmdHandler(SessionSelectedMsg{
|
||||
Session: s.sessions[s.selectedIdx],
|
||||
})
|
||||
}
|
||||
case key.Matches(msg, sessionKeys.Escape):
|
||||
return s, util.CmdHandler(CloseSessionDialogMsg{})
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
s.width = msg.Width
|
||||
s.height = msg.Height
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *sessionDialogCmp) View() string {
|
||||
if len(s.sessions) == 0 {
|
||||
return styles.BaseStyle.Padding(1, 2).
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderBackground(styles.Background).
|
||||
BorderForeground(styles.ForgroundDim).
|
||||
Width(40).
|
||||
Render("No sessions available")
|
||||
}
|
||||
|
||||
// Calculate max width needed for session titles
|
||||
maxWidth := 40 // Minimum width
|
||||
for _, sess := range s.sessions {
|
||||
if len(sess.Title) > maxWidth-4 { // Account for padding
|
||||
maxWidth = len(sess.Title) + 4
|
||||
}
|
||||
}
|
||||
|
||||
// Limit height to avoid taking up too much screen space
|
||||
maxVisibleSessions := min(10, len(s.sessions))
|
||||
|
||||
// Build the session list
|
||||
sessionItems := make([]string, 0, maxVisibleSessions)
|
||||
startIdx := 0
|
||||
|
||||
// If we have more sessions than can be displayed, adjust the start index
|
||||
if len(s.sessions) > maxVisibleSessions {
|
||||
// Center the selected item when possible
|
||||
halfVisible := maxVisibleSessions / 2
|
||||
if s.selectedIdx >= halfVisible && s.selectedIdx < len(s.sessions)-halfVisible {
|
||||
startIdx = s.selectedIdx - halfVisible
|
||||
} else if s.selectedIdx >= len(s.sessions)-halfVisible {
|
||||
startIdx = len(s.sessions) - maxVisibleSessions
|
||||
}
|
||||
}
|
||||
|
||||
endIdx := min(startIdx+maxVisibleSessions, len(s.sessions))
|
||||
|
||||
for i := startIdx; i < endIdx; i++ {
|
||||
sess := s.sessions[i]
|
||||
itemStyle := styles.BaseStyle.Width(maxWidth)
|
||||
|
||||
if i == s.selectedIdx {
|
||||
itemStyle = itemStyle.
|
||||
Background(styles.PrimaryColor).
|
||||
Foreground(styles.Background).
|
||||
Bold(true)
|
||||
}
|
||||
|
||||
sessionItems = append(sessionItems, itemStyle.Padding(0, 1).Render(sess.Title))
|
||||
}
|
||||
|
||||
title := styles.BaseStyle.
|
||||
Foreground(styles.PrimaryColor).
|
||||
Bold(true).
|
||||
Padding(0, 1).
|
||||
Render("Switch Session")
|
||||
|
||||
content := lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
title,
|
||||
styles.BaseStyle.Render(""),
|
||||
lipgloss.JoinVertical(lipgloss.Left, sessionItems...),
|
||||
styles.BaseStyle.Render(""),
|
||||
styles.BaseStyle.Foreground(styles.ForgroundDim).Render("↑/k: up ↓/j: down enter: select esc: cancel"),
|
||||
)
|
||||
|
||||
return styles.BaseStyle.Padding(1, 2).
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderBackground(styles.Background).
|
||||
BorderForeground(styles.ForgroundDim).
|
||||
Width(lipgloss.Width(content) + 4).
|
||||
Render(content)
|
||||
}
|
||||
|
||||
func (s *sessionDialogCmp) BindingKeys() []key.Binding {
|
||||
return layout.KeyMapToSlice(sessionKeys)
|
||||
}
|
||||
|
||||
func (s *sessionDialogCmp) SetSessions(sessions []session.Session) {
|
||||
s.sessions = sessions
|
||||
|
||||
// If we have a selected session ID, find its index
|
||||
if s.selectedSessionID != "" {
|
||||
for i, sess := range sessions {
|
||||
if sess.ID == s.selectedSessionID {
|
||||
s.selectedIdx = i
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to first session if selected not found
|
||||
s.selectedIdx = 0
|
||||
}
|
||||
|
||||
func (s *sessionDialogCmp) SetSelectedSession(sessionID string) {
|
||||
s.selectedSessionID = sessionID
|
||||
|
||||
// Update the selected index if sessions are already loaded
|
||||
if len(s.sessions) > 0 {
|
||||
for i, sess := range s.sessions {
|
||||
if sess.ID == sessionID {
|
||||
s.selectedIdx = i
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NewSessionDialogCmp creates a new session switching dialog
|
||||
func NewSessionDialogCmp() SessionDialog {
|
||||
return &sessionDialogCmp{
|
||||
sessions: []session.Session{},
|
||||
selectedIdx: 0,
|
||||
selectedSessionID: "",
|
||||
}
|
||||
}
|
|
@ -119,27 +119,17 @@ func (i *detailCmp) GetSize() (int, int) {
|
|||
return i.width, i.height
|
||||
}
|
||||
|
||||
func (i *detailCmp) SetSize(width int, height int) {
|
||||
func (i *detailCmp) SetSize(width int, height int) tea.Cmd {
|
||||
i.width = width
|
||||
i.height = height
|
||||
i.viewport.Width = i.width
|
||||
i.viewport.Height = i.height
|
||||
i.updateContent()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *detailCmp) BindingKeys() []key.Binding {
|
||||
return []key.Binding{
|
||||
i.viewport.KeyMap.PageDown,
|
||||
i.viewport.KeyMap.PageUp,
|
||||
i.viewport.KeyMap.HalfPageDown,
|
||||
i.viewport.KeyMap.HalfPageUp,
|
||||
}
|
||||
}
|
||||
|
||||
func (i *detailCmp) BorderText() map[layout.BorderPosition]string {
|
||||
return map[layout.BorderPosition]string{
|
||||
layout.TopLeftBorder: "Log Details",
|
||||
}
|
||||
return layout.KeyMapToSlice(i.viewport.KeyMap)
|
||||
}
|
||||
|
||||
func NewLogsDetails() DetailComponent {
|
||||
|
|
|
@ -68,7 +68,7 @@ func (i *tableCmp) GetSize() (int, int) {
|
|||
return i.table.Width(), i.table.Height()
|
||||
}
|
||||
|
||||
func (i *tableCmp) SetSize(width int, height int) {
|
||||
func (i *tableCmp) SetSize(width int, height int) tea.Cmd {
|
||||
i.table.SetWidth(width)
|
||||
i.table.SetHeight(height)
|
||||
cloumns := i.table.Columns()
|
||||
|
@ -77,6 +77,7 @@ func (i *tableCmp) SetSize(width int, height int) {
|
|||
cloumns[i] = col
|
||||
}
|
||||
i.table.SetColumns(cloumns)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *tableCmp) BindingKeys() []key.Binding {
|
||||
|
|
|
@ -1,392 +0,0 @@
|
|||
package layout
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type paneID string
|
||||
|
||||
const (
|
||||
BentoLeftPane paneID = "left"
|
||||
BentoRightTopPane paneID = "right-top"
|
||||
BentoRightBottomPane paneID = "right-bottom"
|
||||
)
|
||||
|
||||
type BentoPanes map[paneID]tea.Model
|
||||
|
||||
const (
|
||||
defaultLeftWidthRatio = 0.2
|
||||
defaultRightTopHeightRatio = 0.85
|
||||
|
||||
minLeftWidth = 10
|
||||
minRightBottomHeight = 10
|
||||
)
|
||||
|
||||
type BentoLayout interface {
|
||||
tea.Model
|
||||
Sizeable
|
||||
Bindings
|
||||
}
|
||||
|
||||
type BentoKeyBindings struct {
|
||||
SwitchPane key.Binding
|
||||
SwitchPaneBack key.Binding
|
||||
HideCurrentPane key.Binding
|
||||
ShowAllPanes key.Binding
|
||||
}
|
||||
|
||||
var defaultBentoKeyBindings = BentoKeyBindings{
|
||||
SwitchPane: key.NewBinding(
|
||||
key.WithKeys("tab"),
|
||||
key.WithHelp("tab", "switch pane"),
|
||||
),
|
||||
SwitchPaneBack: key.NewBinding(
|
||||
key.WithKeys("shift+tab"),
|
||||
key.WithHelp("shift+tab", "switch pane back"),
|
||||
),
|
||||
HideCurrentPane: key.NewBinding(
|
||||
key.WithKeys("X"),
|
||||
key.WithHelp("X", "hide current pane"),
|
||||
),
|
||||
ShowAllPanes: key.NewBinding(
|
||||
key.WithKeys("R"),
|
||||
key.WithHelp("R", "show all panes"),
|
||||
),
|
||||
}
|
||||
|
||||
type bentoLayout struct {
|
||||
width int
|
||||
height int
|
||||
|
||||
leftWidthRatio float64
|
||||
rightTopHeightRatio float64
|
||||
|
||||
currentPane paneID
|
||||
panes map[paneID]SinglePaneLayout
|
||||
hiddenPanes map[paneID]bool
|
||||
}
|
||||
|
||||
func (b *bentoLayout) GetSize() (int, int) {
|
||||
return b.width, b.height
|
||||
}
|
||||
|
||||
func (b *bentoLayout) Init() tea.Cmd {
|
||||
var cmds []tea.Cmd
|
||||
for _, pane := range b.panes {
|
||||
cmd := pane.Init()
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (b *bentoLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
b.SetSize(msg.Width, msg.Height)
|
||||
return b, nil
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case key.Matches(msg, defaultBentoKeyBindings.SwitchPane):
|
||||
return b, b.SwitchPane(false)
|
||||
case key.Matches(msg, defaultBentoKeyBindings.SwitchPaneBack):
|
||||
return b, b.SwitchPane(true)
|
||||
case key.Matches(msg, defaultBentoKeyBindings.HideCurrentPane):
|
||||
return b, b.HidePane(b.currentPane)
|
||||
case key.Matches(msg, defaultBentoKeyBindings.ShowAllPanes):
|
||||
for id := range b.hiddenPanes {
|
||||
delete(b.hiddenPanes, id)
|
||||
}
|
||||
b.SetSize(b.width, b.height)
|
||||
return b, nil
|
||||
}
|
||||
}
|
||||
|
||||
var cmds []tea.Cmd
|
||||
for id, pane := range b.panes {
|
||||
u, cmd := pane.Update(msg)
|
||||
b.panes[id] = u.(SinglePaneLayout)
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
}
|
||||
return b, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (b *bentoLayout) View() string {
|
||||
if b.width <= 0 || b.height <= 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
for id, pane := range b.panes {
|
||||
if b.currentPane == id {
|
||||
pane.Focus()
|
||||
} else {
|
||||
pane.Blur()
|
||||
}
|
||||
}
|
||||
|
||||
leftVisible := false
|
||||
rightTopVisible := false
|
||||
rightBottomVisible := false
|
||||
|
||||
var leftPane, rightTopPane, rightBottomPane string
|
||||
|
||||
if pane, ok := b.panes[BentoLeftPane]; ok && !b.hiddenPanes[BentoLeftPane] {
|
||||
leftPane = pane.View()
|
||||
leftVisible = true
|
||||
}
|
||||
|
||||
if pane, ok := b.panes[BentoRightTopPane]; ok && !b.hiddenPanes[BentoRightTopPane] {
|
||||
rightTopPane = pane.View()
|
||||
rightTopVisible = true
|
||||
}
|
||||
|
||||
if pane, ok := b.panes[BentoRightBottomPane]; ok && !b.hiddenPanes[BentoRightBottomPane] {
|
||||
rightBottomPane = pane.View()
|
||||
rightBottomVisible = true
|
||||
}
|
||||
|
||||
if leftVisible {
|
||||
if rightTopVisible || rightBottomVisible {
|
||||
rightSection := ""
|
||||
if rightTopVisible && rightBottomVisible {
|
||||
rightSection = lipgloss.JoinVertical(lipgloss.Top, rightTopPane, rightBottomPane)
|
||||
} else if rightTopVisible {
|
||||
rightSection = rightTopPane
|
||||
} else {
|
||||
rightSection = rightBottomPane
|
||||
}
|
||||
return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(
|
||||
lipgloss.JoinHorizontal(lipgloss.Left, leftPane, rightSection),
|
||||
)
|
||||
} else {
|
||||
return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(leftPane)
|
||||
}
|
||||
} else if rightTopVisible || rightBottomVisible {
|
||||
if rightTopVisible && rightBottomVisible {
|
||||
return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(
|
||||
lipgloss.JoinVertical(lipgloss.Top, rightTopPane, rightBottomPane),
|
||||
)
|
||||
} else if rightTopVisible {
|
||||
return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(rightTopPane)
|
||||
} else {
|
||||
return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(rightBottomPane)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (b *bentoLayout) SetSize(width int, height int) {
|
||||
if width < 0 || height < 0 {
|
||||
return
|
||||
}
|
||||
b.width = width
|
||||
b.height = height
|
||||
|
||||
leftExists := false
|
||||
rightTopExists := false
|
||||
rightBottomExists := false
|
||||
|
||||
if _, ok := b.panes[BentoLeftPane]; ok && !b.hiddenPanes[BentoLeftPane] {
|
||||
leftExists = true
|
||||
}
|
||||
if _, ok := b.panes[BentoRightTopPane]; ok && !b.hiddenPanes[BentoRightTopPane] {
|
||||
rightTopExists = true
|
||||
}
|
||||
if _, ok := b.panes[BentoRightBottomPane]; ok && !b.hiddenPanes[BentoRightBottomPane] {
|
||||
rightBottomExists = true
|
||||
}
|
||||
|
||||
leftWidth := 0
|
||||
rightWidth := 0
|
||||
rightTopHeight := 0
|
||||
rightBottomHeight := 0
|
||||
|
||||
if leftExists && (rightTopExists || rightBottomExists) {
|
||||
leftWidth = int(float64(width) * b.leftWidthRatio)
|
||||
if leftWidth < minLeftWidth && width >= minLeftWidth {
|
||||
leftWidth = minLeftWidth
|
||||
}
|
||||
rightWidth = width - leftWidth
|
||||
|
||||
if rightTopExists && rightBottomExists {
|
||||
rightTopHeight = int(float64(height) * b.rightTopHeightRatio)
|
||||
rightBottomHeight = height - rightTopHeight
|
||||
|
||||
if rightBottomHeight < minRightBottomHeight && height >= minRightBottomHeight {
|
||||
rightBottomHeight = minRightBottomHeight
|
||||
rightTopHeight = height - rightBottomHeight
|
||||
}
|
||||
} else if rightTopExists {
|
||||
rightTopHeight = height
|
||||
} else if rightBottomExists {
|
||||
rightBottomHeight = height
|
||||
}
|
||||
} else if leftExists {
|
||||
leftWidth = width
|
||||
} else if rightTopExists || rightBottomExists {
|
||||
rightWidth = width
|
||||
|
||||
if rightTopExists && rightBottomExists {
|
||||
rightTopHeight = int(float64(height) * b.rightTopHeightRatio)
|
||||
rightBottomHeight = height - rightTopHeight
|
||||
|
||||
if rightBottomHeight < minRightBottomHeight && height >= minRightBottomHeight {
|
||||
rightBottomHeight = minRightBottomHeight
|
||||
rightTopHeight = height - rightBottomHeight
|
||||
}
|
||||
} else if rightTopExists {
|
||||
rightTopHeight = height
|
||||
} else if rightBottomExists {
|
||||
rightBottomHeight = height
|
||||
}
|
||||
}
|
||||
|
||||
if pane, ok := b.panes[BentoLeftPane]; ok && !b.hiddenPanes[BentoLeftPane] {
|
||||
pane.SetSize(leftWidth, height)
|
||||
}
|
||||
if pane, ok := b.panes[BentoRightTopPane]; ok && !b.hiddenPanes[BentoRightTopPane] {
|
||||
pane.SetSize(rightWidth, rightTopHeight)
|
||||
}
|
||||
if pane, ok := b.panes[BentoRightBottomPane]; ok && !b.hiddenPanes[BentoRightBottomPane] {
|
||||
pane.SetSize(rightWidth, rightBottomHeight)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *bentoLayout) HidePane(pane paneID) tea.Cmd {
|
||||
if len(b.panes)-len(b.hiddenPanes) == 1 {
|
||||
return nil
|
||||
}
|
||||
if _, ok := b.panes[pane]; ok {
|
||||
b.hiddenPanes[pane] = true
|
||||
}
|
||||
b.SetSize(b.width, b.height)
|
||||
return b.SwitchPane(false)
|
||||
}
|
||||
|
||||
func (b *bentoLayout) SwitchPane(back bool) tea.Cmd {
|
||||
orderForward := []paneID{BentoLeftPane, BentoRightTopPane, BentoRightBottomPane}
|
||||
orderBackward := []paneID{BentoLeftPane, BentoRightBottomPane, BentoRightTopPane}
|
||||
|
||||
order := orderForward
|
||||
if back {
|
||||
order = orderBackward
|
||||
}
|
||||
|
||||
currentIdx := -1
|
||||
for i, id := range order {
|
||||
if id == b.currentPane {
|
||||
currentIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if currentIdx == -1 {
|
||||
for _, id := range order {
|
||||
if _, exists := b.panes[id]; exists {
|
||||
if _, hidden := b.hiddenPanes[id]; !hidden {
|
||||
b.currentPane = id
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
startIdx := currentIdx
|
||||
for {
|
||||
currentIdx = (currentIdx + 1) % len(order)
|
||||
|
||||
nextID := order[currentIdx]
|
||||
if _, exists := b.panes[nextID]; exists {
|
||||
if _, hidden := b.hiddenPanes[nextID]; !hidden {
|
||||
b.currentPane = nextID
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if currentIdx == startIdx {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var cmds []tea.Cmd
|
||||
for id, pane := range b.panes {
|
||||
if _, ok := b.hiddenPanes[id]; ok {
|
||||
continue
|
||||
}
|
||||
if id == b.currentPane {
|
||||
cmds = append(cmds, pane.Focus())
|
||||
} else {
|
||||
cmds = append(cmds, pane.Blur())
|
||||
}
|
||||
}
|
||||
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (s *bentoLayout) BindingKeys() []key.Binding {
|
||||
bindings := KeyMapToSlice(defaultBentoKeyBindings)
|
||||
if b, ok := s.panes[s.currentPane].(Bindings); ok {
|
||||
bindings = append(bindings, b.BindingKeys()...)
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
|
||||
type BentoLayoutOption func(*bentoLayout)
|
||||
|
||||
func NewBentoLayout(panes BentoPanes, opts ...BentoLayoutOption) BentoLayout {
|
||||
p := make(map[paneID]SinglePaneLayout, len(panes))
|
||||
for id, pane := range panes {
|
||||
if sp, ok := pane.(SinglePaneLayout); !ok {
|
||||
p[id] = NewSinglePane(
|
||||
pane,
|
||||
WithSinglePaneFocusable(true),
|
||||
WithSinglePaneBordered(true),
|
||||
)
|
||||
} else {
|
||||
p[id] = sp
|
||||
}
|
||||
}
|
||||
if len(p) == 0 {
|
||||
panic("no panes provided for BentoLayout")
|
||||
}
|
||||
layout := &bentoLayout{
|
||||
panes: p,
|
||||
hiddenPanes: make(map[paneID]bool),
|
||||
currentPane: BentoLeftPane,
|
||||
leftWidthRatio: defaultLeftWidthRatio,
|
||||
rightTopHeightRatio: defaultRightTopHeightRatio,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(layout)
|
||||
}
|
||||
|
||||
return layout
|
||||
}
|
||||
|
||||
func WithBentoLayoutLeftWidthRatio(ratio float64) BentoLayoutOption {
|
||||
return func(b *bentoLayout) {
|
||||
if ratio > 0 && ratio < 1 {
|
||||
b.leftWidthRatio = ratio
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func WithBentoLayoutRightTopHeightRatio(ratio float64) BentoLayoutOption {
|
||||
return func(b *bentoLayout) {
|
||||
if ratio > 0 && ratio < 1 {
|
||||
b.rightTopHeightRatio = ratio
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func WithBentoLayoutCurrentPane(pane paneID) BentoLayoutOption {
|
||||
return func(b *bentoLayout) {
|
||||
b.currentPane = pane
|
||||
}
|
||||
}
|
|
@ -1,121 +0,0 @@
|
|||
package layout
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/kujtimiihoxha/opencode/internal/tui/styles"
|
||||
)
|
||||
|
||||
type BorderPosition int
|
||||
|
||||
const (
|
||||
TopLeftBorder BorderPosition = iota
|
||||
TopMiddleBorder
|
||||
TopRightBorder
|
||||
BottomLeftBorder
|
||||
BottomMiddleBorder
|
||||
BottomRightBorder
|
||||
)
|
||||
|
||||
var (
|
||||
ActiveBorder = styles.Blue
|
||||
InactivePreviewBorder = styles.Grey
|
||||
)
|
||||
|
||||
type BorderOptions struct {
|
||||
Active bool
|
||||
EmbeddedText map[BorderPosition]string
|
||||
ActiveColor lipgloss.TerminalColor
|
||||
InactiveColor lipgloss.TerminalColor
|
||||
ActiveBorder lipgloss.Border
|
||||
InactiveBorder lipgloss.Border
|
||||
}
|
||||
|
||||
func Borderize(content string, opts BorderOptions) string {
|
||||
if opts.EmbeddedText == nil {
|
||||
opts.EmbeddedText = make(map[BorderPosition]string)
|
||||
}
|
||||
if opts.ActiveColor == nil {
|
||||
opts.ActiveColor = ActiveBorder
|
||||
}
|
||||
if opts.InactiveColor == nil {
|
||||
opts.InactiveColor = InactivePreviewBorder
|
||||
}
|
||||
if opts.ActiveBorder == (lipgloss.Border{}) {
|
||||
opts.ActiveBorder = lipgloss.ThickBorder()
|
||||
}
|
||||
if opts.InactiveBorder == (lipgloss.Border{}) {
|
||||
opts.InactiveBorder = lipgloss.NormalBorder()
|
||||
}
|
||||
|
||||
var (
|
||||
thickness = map[bool]lipgloss.Border{
|
||||
true: opts.ActiveBorder,
|
||||
false: opts.InactiveBorder,
|
||||
}
|
||||
color = map[bool]lipgloss.TerminalColor{
|
||||
true: opts.ActiveColor,
|
||||
false: opts.InactiveColor,
|
||||
}
|
||||
border = thickness[opts.Active]
|
||||
style = lipgloss.NewStyle().Foreground(color[opts.Active])
|
||||
width = lipgloss.Width(content)
|
||||
)
|
||||
|
||||
encloseInSquareBrackets := func(text string) string {
|
||||
if text != "" {
|
||||
return fmt.Sprintf("%s%s%s",
|
||||
style.Render(border.TopRight),
|
||||
text,
|
||||
style.Render(border.TopLeft),
|
||||
)
|
||||
}
|
||||
return text
|
||||
}
|
||||
buildHorizontalBorder := func(leftText, middleText, rightText, leftCorner, inbetween, rightCorner string) string {
|
||||
leftText = encloseInSquareBrackets(leftText)
|
||||
middleText = encloseInSquareBrackets(middleText)
|
||||
rightText = encloseInSquareBrackets(rightText)
|
||||
// Calculate length of border between embedded texts
|
||||
remaining := max(0, width-lipgloss.Width(leftText)-lipgloss.Width(middleText)-lipgloss.Width(rightText))
|
||||
leftBorderLen := max(0, (width/2)-lipgloss.Width(leftText)-(lipgloss.Width(middleText)/2))
|
||||
rightBorderLen := max(0, remaining-leftBorderLen)
|
||||
// Then construct border string
|
||||
s := leftText +
|
||||
style.Render(strings.Repeat(inbetween, leftBorderLen)) +
|
||||
middleText +
|
||||
style.Render(strings.Repeat(inbetween, rightBorderLen)) +
|
||||
rightText
|
||||
// Make it fit in the space available between the two corners.
|
||||
s = lipgloss.NewStyle().
|
||||
Inline(true).
|
||||
MaxWidth(width).
|
||||
Render(s)
|
||||
// Add the corners
|
||||
return style.Render(leftCorner) + s + style.Render(rightCorner)
|
||||
}
|
||||
// Stack top border, content and horizontal borders, and bottom border.
|
||||
return strings.Join([]string{
|
||||
buildHorizontalBorder(
|
||||
opts.EmbeddedText[TopLeftBorder],
|
||||
opts.EmbeddedText[TopMiddleBorder],
|
||||
opts.EmbeddedText[TopRightBorder],
|
||||
border.TopLeft,
|
||||
border.Top,
|
||||
border.TopRight,
|
||||
),
|
||||
lipgloss.NewStyle().
|
||||
BorderForeground(color[opts.Active]).
|
||||
Border(border, false, true, false, true).Render(content),
|
||||
buildHorizontalBorder(
|
||||
opts.EmbeddedText[BottomLeftBorder],
|
||||
opts.EmbeddedText[BottomMiddleBorder],
|
||||
opts.EmbeddedText[BottomRightBorder],
|
||||
border.BottomLeft,
|
||||
border.Bottom,
|
||||
border.BottomRight,
|
||||
),
|
||||
}, "\n")
|
||||
}
|
|
@ -86,7 +86,7 @@ func (c *container) View() string {
|
|||
return style.Render(c.content.View())
|
||||
}
|
||||
|
||||
func (c *container) SetSize(width, height int) {
|
||||
func (c *container) SetSize(width, height int) tea.Cmd {
|
||||
c.width = width
|
||||
c.height = height
|
||||
|
||||
|
@ -113,8 +113,9 @@ func (c *container) SetSize(width, height int) {
|
|||
// Set content size with adjusted dimensions
|
||||
contentWidth := max(0, width-horizontalSpace)
|
||||
contentHeight := max(0, height-verticalSpace)
|
||||
sizeable.SetSize(contentWidth, contentHeight)
|
||||
return sizeable.SetSize(contentWidth, contentHeight)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *container) GetSize() (int, int) {
|
||||
|
|
|
@ -1,254 +0,0 @@
|
|||
package layout
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type GridLayout interface {
|
||||
tea.Model
|
||||
Sizeable
|
||||
Bindings
|
||||
Panes() [][]tea.Model
|
||||
}
|
||||
|
||||
type gridLayout struct {
|
||||
width int
|
||||
height int
|
||||
|
||||
rows int
|
||||
columns int
|
||||
|
||||
panes [][]tea.Model
|
||||
|
||||
gap int
|
||||
bordered bool
|
||||
focusable bool
|
||||
|
||||
currentRow int
|
||||
currentColumn int
|
||||
|
||||
activeColor lipgloss.TerminalColor
|
||||
}
|
||||
|
||||
type GridOption func(*gridLayout)
|
||||
|
||||
func (g *gridLayout) Init() tea.Cmd {
|
||||
var cmds []tea.Cmd
|
||||
for i := range g.panes {
|
||||
for j := range g.panes[i] {
|
||||
if g.panes[i][j] != nil {
|
||||
cmds = append(cmds, g.panes[i][j].Init())
|
||||
}
|
||||
}
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (g *gridLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
g.SetSize(msg.Width, msg.Height)
|
||||
return g, nil
|
||||
case tea.KeyMsg:
|
||||
if key.Matches(msg, g.nextPaneBinding()) {
|
||||
return g.focusNextPane()
|
||||
}
|
||||
}
|
||||
|
||||
// Update all panes
|
||||
for i := range g.panes {
|
||||
for j := range g.panes[i] {
|
||||
if g.panes[i][j] != nil {
|
||||
var cmd tea.Cmd
|
||||
g.panes[i][j], cmd = g.panes[i][j].Update(msg)
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return g, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (g *gridLayout) focusNextPane() (tea.Model, tea.Cmd) {
|
||||
if !g.focusable {
|
||||
return g, nil
|
||||
}
|
||||
|
||||
var cmds []tea.Cmd
|
||||
|
||||
// Blur current pane
|
||||
if g.currentRow < len(g.panes) && g.currentColumn < len(g.panes[g.currentRow]) {
|
||||
if currentPane, ok := g.panes[g.currentRow][g.currentColumn].(Focusable); ok {
|
||||
cmds = append(cmds, currentPane.Blur())
|
||||
}
|
||||
}
|
||||
|
||||
// Find next valid pane
|
||||
g.currentColumn++
|
||||
if g.currentColumn >= len(g.panes[g.currentRow]) {
|
||||
g.currentColumn = 0
|
||||
g.currentRow++
|
||||
if g.currentRow >= len(g.panes) {
|
||||
g.currentRow = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Focus next pane
|
||||
if g.currentRow < len(g.panes) && g.currentColumn < len(g.panes[g.currentRow]) {
|
||||
if nextPane, ok := g.panes[g.currentRow][g.currentColumn].(Focusable); ok {
|
||||
cmds = append(cmds, nextPane.Focus())
|
||||
}
|
||||
}
|
||||
|
||||
return g, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (g *gridLayout) nextPaneBinding() key.Binding {
|
||||
return key.NewBinding(
|
||||
key.WithKeys("tab"),
|
||||
key.WithHelp("tab", "next pane"),
|
||||
)
|
||||
}
|
||||
|
||||
func (g *gridLayout) View() string {
|
||||
if len(g.panes) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Calculate dimensions for each cell
|
||||
cellWidth := (g.width - (g.columns-1)*g.gap) / g.columns
|
||||
cellHeight := (g.height - (g.rows-1)*g.gap) / g.rows
|
||||
|
||||
// Render each row
|
||||
rows := make([]string, g.rows)
|
||||
for i := range g.rows {
|
||||
// Render each column in this row
|
||||
cols := make([]string, len(g.panes[i]))
|
||||
for j := range g.panes[i] {
|
||||
if g.panes[i][j] == nil {
|
||||
cols[j] = ""
|
||||
continue
|
||||
}
|
||||
|
||||
// Set size for each pane
|
||||
if sizable, ok := g.panes[i][j].(Sizeable); ok {
|
||||
effectiveWidth, effectiveHeight := cellWidth, cellHeight
|
||||
if g.bordered {
|
||||
effectiveWidth -= 2
|
||||
effectiveHeight -= 2
|
||||
}
|
||||
sizable.SetSize(effectiveWidth, effectiveHeight)
|
||||
}
|
||||
|
||||
// Render the pane
|
||||
content := g.panes[i][j].View()
|
||||
|
||||
// Apply border if needed
|
||||
if g.bordered {
|
||||
isFocused := false
|
||||
if focusable, ok := g.panes[i][j].(Focusable); ok {
|
||||
isFocused = focusable.IsFocused()
|
||||
}
|
||||
|
||||
borderText := map[BorderPosition]string{}
|
||||
if bordered, ok := g.panes[i][j].(Bordered); ok {
|
||||
borderText = bordered.BorderText()
|
||||
}
|
||||
|
||||
content = Borderize(content, BorderOptions{
|
||||
Active: isFocused,
|
||||
EmbeddedText: borderText,
|
||||
})
|
||||
}
|
||||
|
||||
cols[j] = content
|
||||
}
|
||||
|
||||
// Join columns with gap
|
||||
rows[i] = lipgloss.JoinHorizontal(lipgloss.Top, cols...)
|
||||
}
|
||||
|
||||
// Join rows with gap
|
||||
return lipgloss.JoinVertical(lipgloss.Left, rows...)
|
||||
}
|
||||
|
||||
func (g *gridLayout) SetSize(width, height int) {
|
||||
g.width = width
|
||||
g.height = height
|
||||
}
|
||||
|
||||
func (g *gridLayout) GetSize() (int, int) {
|
||||
return g.width, g.height
|
||||
}
|
||||
|
||||
func (g *gridLayout) BindingKeys() []key.Binding {
|
||||
var bindings []key.Binding
|
||||
bindings = append(bindings, g.nextPaneBinding())
|
||||
|
||||
// Collect bindings from all panes
|
||||
for i := range g.panes {
|
||||
for j := range g.panes[i] {
|
||||
if g.panes[i][j] != nil {
|
||||
if bindable, ok := g.panes[i][j].(Bindings); ok {
|
||||
bindings = append(bindings, bindable.BindingKeys()...)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bindings
|
||||
}
|
||||
|
||||
func (g *gridLayout) Panes() [][]tea.Model {
|
||||
return g.panes
|
||||
}
|
||||
|
||||
// NewGridLayout creates a new grid layout with the given number of rows and columns
|
||||
func NewGridLayout(rows, cols int, panes [][]tea.Model, opts ...GridOption) GridLayout {
|
||||
grid := &gridLayout{
|
||||
rows: rows,
|
||||
columns: cols,
|
||||
panes: panes,
|
||||
gap: 1,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(grid)
|
||||
}
|
||||
|
||||
return grid
|
||||
}
|
||||
|
||||
// WithGridGap sets the gap between cells
|
||||
func WithGridGap(gap int) GridOption {
|
||||
return func(g *gridLayout) {
|
||||
g.gap = gap
|
||||
}
|
||||
}
|
||||
|
||||
// WithGridBordered sets whether cells should have borders
|
||||
func WithGridBordered(bordered bool) GridOption {
|
||||
return func(g *gridLayout) {
|
||||
g.bordered = bordered
|
||||
}
|
||||
}
|
||||
|
||||
// WithGridFocusable sets whether the grid supports focus navigation
|
||||
func WithGridFocusable(focusable bool) GridOption {
|
||||
return func(g *gridLayout) {
|
||||
g.focusable = focusable
|
||||
}
|
||||
}
|
||||
|
||||
// WithGridActiveColor sets the active border color
|
||||
func WithGridActiveColor(color lipgloss.TerminalColor) GridOption {
|
||||
return func(g *gridLayout) {
|
||||
g.activeColor = color
|
||||
}
|
||||
}
|
|
@ -13,12 +13,8 @@ type Focusable interface {
|
|||
IsFocused() bool
|
||||
}
|
||||
|
||||
type Bordered interface {
|
||||
BorderText() map[BorderPosition]string
|
||||
}
|
||||
|
||||
type Sizeable interface {
|
||||
SetSize(width, height int)
|
||||
SetSize(width, height int) tea.Cmd
|
||||
GetSize() (int, int)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,189 +0,0 @@
|
|||
package layout
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type SinglePaneLayout interface {
|
||||
tea.Model
|
||||
Focusable
|
||||
Sizeable
|
||||
Bindings
|
||||
Pane() tea.Model
|
||||
}
|
||||
|
||||
type singlePaneLayout struct {
|
||||
width int
|
||||
height int
|
||||
|
||||
focusable bool
|
||||
focused bool
|
||||
|
||||
bordered bool
|
||||
borderText map[BorderPosition]string
|
||||
|
||||
content tea.Model
|
||||
|
||||
padding []int
|
||||
|
||||
activeColor lipgloss.TerminalColor
|
||||
}
|
||||
|
||||
type SinglePaneOption func(*singlePaneLayout)
|
||||
|
||||
func (s *singlePaneLayout) Init() tea.Cmd {
|
||||
return s.content.Init()
|
||||
}
|
||||
|
||||
func (s *singlePaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
s.SetSize(msg.Width, msg.Height)
|
||||
return s, nil
|
||||
}
|
||||
u, cmd := s.content.Update(msg)
|
||||
s.content = u
|
||||
return s, cmd
|
||||
}
|
||||
|
||||
func (s *singlePaneLayout) View() string {
|
||||
style := lipgloss.NewStyle().Width(s.width).Height(s.height)
|
||||
if s.bordered {
|
||||
style = style.Width(s.width - 2).Height(s.height - 2)
|
||||
}
|
||||
if s.padding != nil {
|
||||
style = style.Padding(s.padding...)
|
||||
}
|
||||
content := style.Render(s.content.View())
|
||||
if s.bordered {
|
||||
if s.borderText == nil {
|
||||
s.borderText = map[BorderPosition]string{}
|
||||
}
|
||||
if bordered, ok := s.content.(Bordered); ok {
|
||||
s.borderText = bordered.BorderText()
|
||||
}
|
||||
return Borderize(content, BorderOptions{
|
||||
Active: s.focused,
|
||||
EmbeddedText: s.borderText,
|
||||
})
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func (s *singlePaneLayout) Blur() tea.Cmd {
|
||||
if s.focusable {
|
||||
s.focused = false
|
||||
}
|
||||
if blurable, ok := s.content.(Focusable); ok {
|
||||
return blurable.Blur()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *singlePaneLayout) Focus() tea.Cmd {
|
||||
if s.focusable {
|
||||
s.focused = true
|
||||
}
|
||||
if focusable, ok := s.content.(Focusable); ok {
|
||||
return focusable.Focus()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *singlePaneLayout) SetSize(width, height int) {
|
||||
s.width = width
|
||||
s.height = height
|
||||
childWidth, childHeight := s.width, s.height
|
||||
if s.bordered {
|
||||
childWidth -= 2
|
||||
childHeight -= 2
|
||||
}
|
||||
if s.padding != nil {
|
||||
if len(s.padding) == 1 {
|
||||
childWidth -= s.padding[0] * 2
|
||||
childHeight -= s.padding[0] * 2
|
||||
} else if len(s.padding) == 2 {
|
||||
childWidth -= s.padding[0] * 2
|
||||
childHeight -= s.padding[1] * 2
|
||||
} else if len(s.padding) == 3 {
|
||||
childWidth -= s.padding[0] * 2
|
||||
childHeight -= s.padding[1] + s.padding[2]
|
||||
} else if len(s.padding) == 4 {
|
||||
childWidth -= s.padding[0] + s.padding[2]
|
||||
childHeight -= s.padding[1] + s.padding[3]
|
||||
}
|
||||
}
|
||||
if s.content != nil {
|
||||
if c, ok := s.content.(Sizeable); ok {
|
||||
c.SetSize(childWidth, childHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *singlePaneLayout) IsFocused() bool {
|
||||
return s.focused
|
||||
}
|
||||
|
||||
func (s *singlePaneLayout) GetSize() (int, int) {
|
||||
return s.width, s.height
|
||||
}
|
||||
|
||||
func (s *singlePaneLayout) BindingKeys() []key.Binding {
|
||||
if b, ok := s.content.(Bindings); ok {
|
||||
return b.BindingKeys()
|
||||
}
|
||||
return []key.Binding{}
|
||||
}
|
||||
|
||||
func (s *singlePaneLayout) Pane() tea.Model {
|
||||
return s.content
|
||||
}
|
||||
|
||||
func NewSinglePane(content tea.Model, opts ...SinglePaneOption) SinglePaneLayout {
|
||||
layout := &singlePaneLayout{
|
||||
content: content,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(layout)
|
||||
}
|
||||
return layout
|
||||
}
|
||||
|
||||
func WithSinglePaneSize(width, height int) SinglePaneOption {
|
||||
return func(opts *singlePaneLayout) {
|
||||
opts.width = width
|
||||
opts.height = height
|
||||
}
|
||||
}
|
||||
|
||||
func WithSinglePaneFocusable(focusable bool) SinglePaneOption {
|
||||
return func(opts *singlePaneLayout) {
|
||||
opts.focusable = focusable
|
||||
}
|
||||
}
|
||||
|
||||
func WithSinglePaneBordered(bordered bool) SinglePaneOption {
|
||||
return func(opts *singlePaneLayout) {
|
||||
opts.bordered = bordered
|
||||
}
|
||||
}
|
||||
|
||||
func WithSinglePaneBorderText(borderText map[BorderPosition]string) SinglePaneOption {
|
||||
return func(opts *singlePaneLayout) {
|
||||
opts.borderText = borderText
|
||||
}
|
||||
}
|
||||
|
||||
func WithSinglePanePadding(padding ...int) SinglePaneOption {
|
||||
return func(opts *singlePaneLayout) {
|
||||
opts.padding = padding
|
||||
}
|
||||
}
|
||||
|
||||
func WithSinglePaneActiveColor(color lipgloss.TerminalColor) SinglePaneOption {
|
||||
return func(opts *singlePaneLayout) {
|
||||
opts.activeColor = color
|
||||
}
|
||||
}
|
|
@ -11,9 +11,9 @@ type SplitPaneLayout interface {
|
|||
tea.Model
|
||||
Sizeable
|
||||
Bindings
|
||||
SetLeftPanel(panel Container)
|
||||
SetRightPanel(panel Container)
|
||||
SetBottomPanel(panel Container)
|
||||
SetLeftPanel(panel Container) tea.Cmd
|
||||
SetRightPanel(panel Container) tea.Cmd
|
||||
SetBottomPanel(panel Container) tea.Cmd
|
||||
}
|
||||
|
||||
type splitPaneLayout struct {
|
||||
|
@ -53,8 +53,7 @@ func (s *splitPaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
var cmds []tea.Cmd
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
s.SetSize(msg.Width, msg.Height)
|
||||
return s, nil
|
||||
return s, s.SetSize(msg.Width, msg.Height)
|
||||
}
|
||||
|
||||
if s.rightPanel != nil {
|
||||
|
@ -122,7 +121,7 @@ func (s *splitPaneLayout) View() string {
|
|||
return finalView
|
||||
}
|
||||
|
||||
func (s *splitPaneLayout) SetSize(width, height int) {
|
||||
func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
|
||||
s.width = width
|
||||
s.height = height
|
||||
|
||||
|
@ -147,42 +146,50 @@ func (s *splitPaneLayout) SetSize(width, height int) {
|
|||
rightWidth = width
|
||||
}
|
||||
|
||||
var cmds []tea.Cmd
|
||||
if s.leftPanel != nil {
|
||||
s.leftPanel.SetSize(leftWidth, topHeight)
|
||||
cmd := s.leftPanel.SetSize(leftWidth, topHeight)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
if s.rightPanel != nil {
|
||||
s.rightPanel.SetSize(rightWidth, topHeight)
|
||||
cmd := s.rightPanel.SetSize(rightWidth, topHeight)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
if s.bottomPanel != nil {
|
||||
s.bottomPanel.SetSize(width, bottomHeight)
|
||||
cmd := s.bottomPanel.SetSize(width, bottomHeight)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (s *splitPaneLayout) GetSize() (int, int) {
|
||||
return s.width, s.height
|
||||
}
|
||||
|
||||
func (s *splitPaneLayout) SetLeftPanel(panel Container) {
|
||||
func (s *splitPaneLayout) SetLeftPanel(panel Container) tea.Cmd {
|
||||
s.leftPanel = panel
|
||||
if s.width > 0 && s.height > 0 {
|
||||
s.SetSize(s.width, s.height)
|
||||
return s.SetSize(s.width, s.height)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *splitPaneLayout) SetRightPanel(panel Container) {
|
||||
func (s *splitPaneLayout) SetRightPanel(panel Container) tea.Cmd {
|
||||
s.rightPanel = panel
|
||||
if s.width > 0 && s.height > 0 {
|
||||
s.SetSize(s.width, s.height)
|
||||
return s.SetSize(s.width, s.height)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *splitPaneLayout) SetBottomPanel(panel Container) {
|
||||
func (s *splitPaneLayout) SetBottomPanel(panel Container) tea.Cmd {
|
||||
s.bottomPanel = panel
|
||||
if s.width > 0 && s.height > 0 {
|
||||
s.SetSize(s.width, s.height)
|
||||
return s.SetSize(s.width, s.height)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *splitPaneLayout) BindingKeys() []key.Binding {
|
||||
|
|
|
@ -54,9 +54,11 @@ func (p *chatPage) Init() tea.Cmd {
|
|||
}
|
||||
|
||||
func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
p.layout.SetSize(msg.Width, msg.Height)
|
||||
cmd := p.layout.SetSize(msg.Width, msg.Height)
|
||||
cmds = append(cmds, cmd)
|
||||
case chat.SendMsg:
|
||||
cmd := p.sendMessage(msg.Text)
|
||||
if cmd != nil {
|
||||
|
@ -68,8 +70,10 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
switch {
|
||||
case key.Matches(msg, keyMap.NewSession):
|
||||
p.session = session.Session{}
|
||||
p.clearSidebar()
|
||||
return p, util.CmdHandler(chat.SessionClearedMsg{})
|
||||
return p, tea.Batch(
|
||||
p.clearSidebar(),
|
||||
util.CmdHandler(chat.SessionClearedMsg{}),
|
||||
)
|
||||
case key.Matches(msg, keyMap.Cancel):
|
||||
if p.session.ID != "" {
|
||||
// Cancel the current session's generation process
|
||||
|
@ -80,11 +84,9 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
}
|
||||
u, cmd := p.layout.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
p.layout = u.(layout.SplitPaneLayout)
|
||||
if cmd != nil {
|
||||
return p, cmd
|
||||
}
|
||||
return p, nil
|
||||
return p, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (p *chatPage) setSidebar() tea.Cmd {
|
||||
|
@ -92,16 +94,11 @@ func (p *chatPage) setSidebar() tea.Cmd {
|
|||
chat.NewSidebarCmp(p.session, p.app.History),
|
||||
layout.WithPadding(1, 1, 1, 1),
|
||||
)
|
||||
p.layout.SetRightPanel(sidebarContainer)
|
||||
width, height := p.layout.GetSize()
|
||||
p.layout.SetSize(width, height)
|
||||
return sidebarContainer.Init()
|
||||
return tea.Batch(p.layout.SetRightPanel(sidebarContainer), sidebarContainer.Init())
|
||||
}
|
||||
|
||||
func (p *chatPage) clearSidebar() {
|
||||
p.layout.SetRightPanel(nil)
|
||||
width, height := p.layout.GetSize()
|
||||
p.layout.SetSize(width, height)
|
||||
func (p *chatPage) clearSidebar() tea.Cmd {
|
||||
return p.layout.SetRightPanel(nil)
|
||||
}
|
||||
|
||||
func (p *chatPage) sendMessage(text string) tea.Cmd {
|
||||
|
@ -124,8 +121,8 @@ func (p *chatPage) sendMessage(text string) tea.Cmd {
|
|||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (p *chatPage) SetSize(width, height int) {
|
||||
p.layout.SetSize(width, height)
|
||||
func (p *chatPage) SetSize(width, height int) tea.Cmd {
|
||||
return p.layout.SetSize(width, height)
|
||||
}
|
||||
|
||||
func (p *chatPage) GetSize() (int, int) {
|
||||
|
|
|
@ -23,15 +23,14 @@ type logsPage struct {
|
|||
}
|
||||
|
||||
func (p *logsPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
p.width = msg.Width
|
||||
p.height = msg.Height
|
||||
p.table.SetSize(msg.Width, msg.Height/2)
|
||||
p.details.SetSize(msg.Width, msg.Height/2)
|
||||
return p, p.SetSize(msg.Width, msg.Height)
|
||||
}
|
||||
|
||||
var cmds []tea.Cmd
|
||||
table, cmd := p.table.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
p.table = table.(layout.Container)
|
||||
|
@ -60,11 +59,13 @@ func (p *logsPage) GetSize() (int, int) {
|
|||
}
|
||||
|
||||
// SetSize implements LogPage.
|
||||
func (p *logsPage) SetSize(width int, height int) {
|
||||
func (p *logsPage) SetSize(width int, height int) tea.Cmd {
|
||||
p.width = width
|
||||
p.height = height
|
||||
p.table.SetSize(width, height/2)
|
||||
p.details.SetSize(width, height/2)
|
||||
return tea.Batch(
|
||||
p.table.SetSize(width, height/2),
|
||||
p.details.SetSize(width, height/2),
|
||||
)
|
||||
}
|
||||
|
||||
func (p *logsPage) Init() tea.Cmd {
|
||||
|
|
|
@ -3,7 +3,6 @@ package styles
|
|||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
@ -25,57 +24,100 @@ func getColorRGB(c lipgloss.TerminalColor) (uint8, uint8, uint8) {
|
|||
return uint8(r >> 8), uint8(g >> 8), uint8(b >> 8)
|
||||
}
|
||||
|
||||
// ForceReplaceBackgroundWithLipgloss replaces any ANSI background color codes
|
||||
// in `input` with a single 24‑bit background (48;2;R;G;B).
|
||||
func ForceReplaceBackgroundWithLipgloss(input string, newBgColor lipgloss.TerminalColor) string {
|
||||
// Precompute our new-bg sequence once
|
||||
r, g, b := getColorRGB(newBgColor)
|
||||
|
||||
newBg := fmt.Sprintf("48;2;%d;%d;%d", r, g, b)
|
||||
|
||||
return ansiEscape.ReplaceAllStringFunc(input, func(seq string) string {
|
||||
// Extract content between "\x1b[" and "m"
|
||||
content := seq[2 : len(seq)-1]
|
||||
tokens := strings.Split(content, ";")
|
||||
var newTokens []string
|
||||
const (
|
||||
escPrefixLen = 2 // "\x1b["
|
||||
escSuffixLen = 1 // "m"
|
||||
)
|
||||
|
||||
// Skip background color tokens
|
||||
for i := 0; i < len(tokens); i++ {
|
||||
if tokens[i] == "" {
|
||||
continue
|
||||
raw := seq
|
||||
start := escPrefixLen
|
||||
end := len(raw) - escSuffixLen
|
||||
|
||||
var sb strings.Builder
|
||||
// reserve enough space: original content minus bg codes + our newBg
|
||||
sb.Grow((end - start) + len(newBg) + 2)
|
||||
|
||||
// scan from start..end, token by token
|
||||
for i := start; i < end; {
|
||||
// find the next ';' or end
|
||||
j := i
|
||||
for j < end && raw[j] != ';' {
|
||||
j++
|
||||
}
|
||||
token := raw[i:j]
|
||||
|
||||
val, err := strconv.Atoi(tokens[i])
|
||||
if err != nil {
|
||||
newTokens = append(newTokens, tokens[i])
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip background color tokens
|
||||
if val == 48 {
|
||||
// Skip "48;5;N" or "48;2;R;G;B" sequences
|
||||
if i+1 < len(tokens) {
|
||||
if nextVal, err := strconv.Atoi(tokens[i+1]); err == nil {
|
||||
switch nextVal {
|
||||
case 5:
|
||||
i += 2 // Skip "5" and color index
|
||||
case 2:
|
||||
i += 4 // Skip "2" and RGB components
|
||||
// fast‑path: skip "48;5;N" or "48;2;R;G;B"
|
||||
if len(token) == 2 && token[0] == '4' && token[1] == '8' {
|
||||
k := j + 1
|
||||
if k < end {
|
||||
// find next token
|
||||
l := k
|
||||
for l < end && raw[l] != ';' {
|
||||
l++
|
||||
}
|
||||
next := raw[k:l]
|
||||
if next == "5" {
|
||||
// skip "48;5;N"
|
||||
m := l + 1
|
||||
for m < end && raw[m] != ';' {
|
||||
m++
|
||||
}
|
||||
i = m + 1
|
||||
continue
|
||||
} else if next == "2" {
|
||||
// skip "48;2;R;G;B"
|
||||
m := l + 1
|
||||
for count := 0; count < 3 && m < end; count++ {
|
||||
for m < end && raw[m] != ';' {
|
||||
m++
|
||||
}
|
||||
m++
|
||||
}
|
||||
i = m
|
||||
continue
|
||||
}
|
||||
}
|
||||
} else if (val < 40 || val > 47) && (val < 100 || val > 107) && val != 49 {
|
||||
// Keep non-background tokens
|
||||
newTokens = append(newTokens, tokens[i])
|
||||
}
|
||||
|
||||
// decide whether to keep this token
|
||||
// manually parse ASCII digits to int
|
||||
isNum := true
|
||||
val := 0
|
||||
for p := i; p < j; p++ {
|
||||
c := raw[p]
|
||||
if c < '0' || c > '9' {
|
||||
isNum = false
|
||||
break
|
||||
}
|
||||
val = val*10 + int(c-'0')
|
||||
}
|
||||
keep := !isNum ||
|
||||
((val < 40 || val > 47) && (val < 100 || val > 107) && val != 49)
|
||||
|
||||
if keep {
|
||||
if sb.Len() > 0 {
|
||||
sb.WriteByte(';')
|
||||
}
|
||||
sb.WriteString(token)
|
||||
}
|
||||
// advance past this token (and the semicolon)
|
||||
i = j + 1
|
||||
}
|
||||
|
||||
// Add new background if provided
|
||||
if newBg != "" {
|
||||
newTokens = append(newTokens, strings.Split(newBg, ";")...)
|
||||
// append our new background
|
||||
if sb.Len() > 0 {
|
||||
sb.WriteByte(';')
|
||||
}
|
||||
sb.WriteString(newBg)
|
||||
|
||||
if len(newTokens) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return "\x1b[" + strings.Join(newTokens, ";") + "m"
|
||||
return "\x1b[" + sb.String() + "m"
|
||||
})
|
||||
}
|
||||
|
|
|
@ -2,19 +2,11 @@ package styles
|
|||
|
||||
const (
|
||||
OpenCodeIcon string = "⌬"
|
||||
SessionsIcon string = ""
|
||||
ChatIcon string = ""
|
||||
|
||||
BotIcon string = ""
|
||||
ToolIcon string = ""
|
||||
UserIcon string = ""
|
||||
|
||||
CheckIcon string = "✓"
|
||||
ErrorIcon string = ""
|
||||
WarningIcon string = ""
|
||||
ErrorIcon string = "✖"
|
||||
WarningIcon string = "⚠"
|
||||
InfoIcon string = ""
|
||||
HintIcon string = ""
|
||||
HintIcon string = "i"
|
||||
SpinnerIcon string = "..."
|
||||
BugIcon string = ""
|
||||
SleepIcon string = ""
|
||||
)
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
@ -8,6 +10,7 @@ import (
|
|||
"github.com/kujtimiihoxha/opencode/internal/logging"
|
||||
"github.com/kujtimiihoxha/opencode/internal/permission"
|
||||
"github.com/kujtimiihoxha/opencode/internal/pubsub"
|
||||
"github.com/kujtimiihoxha/opencode/internal/tui/components/chat"
|
||||
"github.com/kujtimiihoxha/opencode/internal/tui/components/core"
|
||||
"github.com/kujtimiihoxha/opencode/internal/tui/components/dialog"
|
||||
"github.com/kujtimiihoxha/opencode/internal/tui/layout"
|
||||
|
@ -16,9 +19,10 @@ import (
|
|||
)
|
||||
|
||||
type keyMap struct {
|
||||
Logs key.Binding
|
||||
Quit key.Binding
|
||||
Help key.Binding
|
||||
Logs key.Binding
|
||||
Quit key.Binding
|
||||
Help key.Binding
|
||||
SwitchSession key.Binding
|
||||
}
|
||||
|
||||
var keys = keyMap{
|
||||
|
@ -35,6 +39,10 @@ var keys = keyMap{
|
|||
key.WithKeys("ctrl+_"),
|
||||
key.WithHelp("ctrl+?", "toggle help"),
|
||||
),
|
||||
SwitchSession: key.NewBinding(
|
||||
key.WithKeys("ctrl+a"),
|
||||
key.WithHelp("ctrl+a", "switch session"),
|
||||
),
|
||||
}
|
||||
|
||||
var returnKey = key.NewBinding(
|
||||
|
@ -64,6 +72,9 @@ type appModel struct {
|
|||
|
||||
showQuit bool
|
||||
quit dialog.QuitDialog
|
||||
|
||||
showSessionDialog bool
|
||||
sessionDialog dialog.SessionDialog
|
||||
}
|
||||
|
||||
func (a appModel) Init() tea.Cmd {
|
||||
|
@ -77,6 +88,8 @@ func (a appModel) Init() tea.Cmd {
|
|||
cmds = append(cmds, cmd)
|
||||
cmd = a.help.Init()
|
||||
cmds = append(cmds, cmd)
|
||||
cmd = a.sessionDialog.Init()
|
||||
cmds = append(cmds, cmd)
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
|
@ -100,6 +113,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
a.help = help.(dialog.HelpCmp)
|
||||
cmds = append(cmds, helpCmd)
|
||||
|
||||
session, sessionCmd := a.sessionDialog.Update(msg)
|
||||
a.sessionDialog = session.(dialog.SessionDialog)
|
||||
cmds = append(cmds, sessionCmd)
|
||||
|
||||
return a, tea.Batch(cmds...)
|
||||
|
||||
// Status
|
||||
|
@ -144,8 +161,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
// Permission
|
||||
case pubsub.Event[permission.PermissionRequest]:
|
||||
a.showPermissions = true
|
||||
a.permissions.SetPermissions(msg.Payload)
|
||||
return a, nil
|
||||
return a, a.permissions.SetPermissions(msg.Payload)
|
||||
case dialog.PermissionResponseMsg:
|
||||
switch msg.Action {
|
||||
case dialog.PermissionAllow:
|
||||
|
@ -165,6 +181,19 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
a.showQuit = false
|
||||
return a, nil
|
||||
|
||||
case dialog.CloseSessionDialogMsg:
|
||||
a.showSessionDialog = false
|
||||
return a, nil
|
||||
|
||||
case chat.SessionSelectedMsg:
|
||||
a.sessionDialog.SetSelectedSession(msg.ID)
|
||||
case dialog.SessionSelectedMsg:
|
||||
a.showSessionDialog = false
|
||||
if a.currentPage == page.ChatPage {
|
||||
return a, util.CmdHandler(chat.SessionSelectedMsg(msg.Session))
|
||||
}
|
||||
return a, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case key.Matches(msg, keys.Quit):
|
||||
|
@ -172,6 +201,24 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
if a.showHelp {
|
||||
a.showHelp = false
|
||||
}
|
||||
if a.showSessionDialog {
|
||||
a.showSessionDialog = false
|
||||
}
|
||||
return a, nil
|
||||
case key.Matches(msg, keys.SwitchSession):
|
||||
if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions {
|
||||
// Load sessions and show the dialog
|
||||
sessions, err := a.app.Sessions.List(context.Background())
|
||||
if err != nil {
|
||||
return a, util.ReportError(err)
|
||||
}
|
||||
if len(sessions) == 0 {
|
||||
return a, util.ReportWarn("No sessions available")
|
||||
}
|
||||
a.sessionDialog.SetSessions(sessions)
|
||||
a.showSessionDialog = true
|
||||
return a, nil
|
||||
}
|
||||
return a, nil
|
||||
case key.Matches(msg, logsKeyReturnKey):
|
||||
if a.currentPage == page.LogsPage {
|
||||
|
@ -216,6 +263,16 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
}
|
||||
|
||||
if a.showSessionDialog {
|
||||
d, sessionCmd := a.sessionDialog.Update(msg)
|
||||
a.sessionDialog = d.(dialog.SessionDialog)
|
||||
cmds = append(cmds, sessionCmd)
|
||||
// Only block key messages send all other messages down
|
||||
if _, ok := msg.(tea.KeyMsg); ok {
|
||||
return a, tea.Batch(cmds...)
|
||||
}
|
||||
}
|
||||
|
||||
a.status, _ = a.status.Update(msg)
|
||||
a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
|
@ -223,18 +280,24 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
|
||||
func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
|
||||
var cmd tea.Cmd
|
||||
if a.app.CoderAgent.IsBusy() {
|
||||
// For now we don't move to any page if the agent is busy
|
||||
return util.ReportWarn("Agent is busy, please wait...")
|
||||
}
|
||||
var cmds []tea.Cmd
|
||||
if _, ok := a.loadedPages[pageID]; !ok {
|
||||
cmd = a.pages[pageID].Init()
|
||||
cmd := a.pages[pageID].Init()
|
||||
cmds = append(cmds, cmd)
|
||||
a.loadedPages[pageID] = true
|
||||
}
|
||||
a.previousPage = a.currentPage
|
||||
a.currentPage = pageID
|
||||
if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
|
||||
sizable.SetSize(a.width, a.height)
|
||||
cmd := sizable.SetSize(a.width, a.height)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
return cmd
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (a appModel) View() string {
|
||||
|
@ -304,19 +367,35 @@ func (a appModel) View() string {
|
|||
)
|
||||
}
|
||||
|
||||
if a.showSessionDialog {
|
||||
overlay := a.sessionDialog.View()
|
||||
row := lipgloss.Height(appView) / 2
|
||||
row -= lipgloss.Height(overlay) / 2
|
||||
col := lipgloss.Width(appView) / 2
|
||||
col -= lipgloss.Width(overlay) / 2
|
||||
appView = layout.PlaceOverlay(
|
||||
col,
|
||||
row,
|
||||
overlay,
|
||||
appView,
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
return appView
|
||||
}
|
||||
|
||||
func New(app *app.App) tea.Model {
|
||||
startPage := page.ChatPage
|
||||
return &appModel{
|
||||
currentPage: startPage,
|
||||
loadedPages: make(map[page.PageID]bool),
|
||||
status: core.NewStatusCmp(app.LSPClients),
|
||||
help: dialog.NewHelpCmp(),
|
||||
quit: dialog.NewQuitCmp(),
|
||||
permissions: dialog.NewPermissionDialogCmp(),
|
||||
app: app,
|
||||
currentPage: startPage,
|
||||
loadedPages: make(map[page.PageID]bool),
|
||||
status: core.NewStatusCmp(app.LSPClients),
|
||||
help: dialog.NewHelpCmp(),
|
||||
quit: dialog.NewQuitCmp(),
|
||||
sessionDialog: dialog.NewSessionDialogCmp(),
|
||||
permissions: dialog.NewPermissionDialogCmp(),
|
||||
app: app,
|
||||
pages: map[page.PageID]tea.Model{
|
||||
page.ChatPage: page.NewChatPage(app),
|
||||
page.LogsPage: page.NewLogsPage(),
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue