implement patch, update ui, improve rendering

This commit is contained in:
Kujtim Hoxha 2025-04-18 20:17:38 +02:00
parent 05d0e86f10
commit 333ea6ec4b
38 changed files with 3312 additions and 2262 deletions

301
README.md
View file

@ -4,8 +4,6 @@
A powerful terminal-based AI assistant for developers, providing intelligent coding assistance directly in your terminal.
[![OpenCode Demo](https://asciinema.org/a/dtc4nJyGSZX79HRUmFLY3gmoy.svg)](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.

View file

@ -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)
}
}

View file

@ -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
View 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
}

View file

@ -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)

View file

@ -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)

View file

@ -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...,

View file

@ -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,
},
}

View file

@ -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)
}

View 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,
},
}

View file

@ -25,44 +25,49 @@ func CoderPrompt(provider models.ModelProvider) string {
}
const baseOpenAICoderPrompt = `
You are **OpenCode**, an autonomous CLI assistant for softwareengineering 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 stepbystep 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 AskOnlyIf 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 GitHubflavored Markdown.
When running a nontrivial shell command, add  1 brief purpose sentence.
### CONTEXT & MEMORY
Infer file intent from directory structure before editing.
Autoload '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
**AskOnlyIf 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 selfattempts?** 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 & typecheck (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 chitchat]
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

View file

@ -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
}
}

View file

@ -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
}

View file

@ -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), &params); 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)
}

View file

@ -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
}

View file

@ -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 {

View file

@ -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) {

View 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,
}
}

View 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), &params)
prompt := strings.ReplaceAll(params.Prompt, "\n", " ")
return renderParams(paramWidth, prompt)
case tools.BashToolName:
var params tools.BashParams
json.Unmarshal([]byte(toolCall.Input), &params)
command := strings.ReplaceAll(params.Command, "\n", " ")
return renderParams(paramWidth, command)
case tools.EditToolName:
var params tools.EditParams
json.Unmarshal([]byte(toolCall.Input), &params)
filePath := removeWorkingDirPrefix(params.FilePath)
return renderParams(paramWidth, filePath)
case tools.FetchToolName:
var params tools.FetchParams
json.Unmarshal([]byte(toolCall.Input), &params)
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), &params)
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), &params)
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), &params)
path := params.Path
if path == "" {
path = "."
}
return renderParams(paramWidth, path)
case tools.SourcegraphToolName:
var params tools.SourcegraphParams
json.Unmarshal([]byte(toolCall.Input), &params)
return renderParams(paramWidth, params.Query)
case tools.ViewToolName:
var params tools.ViewParams
json.Unmarshal([]byte(toolCall.Input), &params)
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), &params)
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), &params)
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), &params)
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
}

View file

@ -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), &params)
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), &params)
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), &params)
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), &params)
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), &params)
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), &params)
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), &params)
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), &params)
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), &params)
value = params.FilePath
case tools.WriteToolName:
key = "Write"
var params tools.WriteParams
json.Unmarshal([]byte(toolCall.Input), &params)
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), &params)
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,
}
}

View file

@ -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, "/")
}

View file

@ -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 {

View file

@ -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

View 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: "",
}
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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
}
}

View file

@ -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")
}

View file

@ -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) {

View file

@ -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
}
}

View file

@ -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)
}

View file

@ -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
}
}

View file

@ -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 {

View file

@ -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) {

View file

@ -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 {

View file

@ -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 24bit 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
// fastpath: 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"
})
}

View file

@ -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 = "󰒲"
)

View file

@ -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(),