on main help sign in
implement patch, update ui, improve rendering
333ea6eKujtim Hoxha
README.md+271−30
@@ -4,8 +4,6 @@
4
4
5A powerful terminal-based AI assistant for developers, providing intelligent coding assistance directly in your terminal.
5A powerful terminal-based AI assistant for developers, providing intelligent coding assistance directly in your terminal.
6
6
7−[![OpenCode Demo](https://asciinema.org/a/dtc4nJyGSZX79HRUmFLY3gmoy.svg)](https://asciinema.org/a/dtc4nJyGSZX79HRUmFLY3gmoy)
8−
9## Overview
7## Overview
10
8
11OpenCode 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.
9OpenCode 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
13## Features
11## Features
14
12
15- **Interactive TUI**: Built with [Bubble Tea](https://github.com/charmbracelet/bubbletea) for a smooth terminal experience
13- **Interactive TUI**: Built with [Bubble Tea](https://github.com/charmbracelet/bubbletea) for a smooth terminal experience
16−- **Multiple AI Providers**: Support for OpenAI, Anthropic Claude, and Google Gemini models
14+- **Multiple AI Providers**: Support for OpenAI, Anthropic Claude, Google Gemini, AWS Bedrock, and Groq
17- **Session Management**: Save and manage multiple conversation sessions
15- **Session Management**: Save and manage multiple conversation sessions
18- **Tool Integration**: AI can execute commands, search files, and modify code
16- **Tool Integration**: AI can execute commands, search files, and modify code
19−- **Vim-like Editor**: Integrated editor with Vim keybindings for text input
17+- **Vim-like Editor**: Integrated editor with text input capabilities
20- **Persistent Storage**: SQLite database for storing conversations and sessions
18- **Persistent Storage**: SQLite database for storing conversations and sessions
19+- **LSP Integration**: Language Server Protocol support for code intelligence
20+- **File Change Tracking**: Track and visualize file changes during sessions
21
21
22## Installation
22## Installation
23
23
@@ -34,11 +34,107 @@ OpenCode looks for configuration in the following locations:
34- `$XDG_CONFIG_HOME/opencode/.opencode.json`
34- `$XDG_CONFIG_HOME/opencode/.opencode.json`
35- `./.opencode.json` (local directory)
35- `./.opencode.json` (local directory)
36
36
37+### Environment Variables
38+
37−You can also use environment variables:
39+You can configure OpenCode using environment variables:
40+
41+| Environment Variable | Purpose |
42+| ----------------------- | ------------------------ |
43+| `ANTHROPIC_API_KEY` | For Claude models |
44+| `OPENAI_API_KEY` | For OpenAI models |
45+| `GEMINI_API_KEY` | For Google Gemini models |
46+| `GROQ_API_KEY` | For Groq models |
47+| `AWS_ACCESS_KEY_ID` | For AWS Bedrock (Claude) |
48+| `AWS_SECRET_ACCESS_KEY` | For AWS Bedrock (Claude) |
49+| `AWS_REGION` | For AWS Bedrock (Claude) |
50+
51+### Configuration File Structure
52+
53+```json
54+{
55+ "data": {
56+ "directory": ".opencode"
57+ },
58+ "providers": {
59+ "openai": {
60+ "apiKey": "your-api-key",
61+ "disabled": false
62+ },
63+ "anthropic": {
64+ "apiKey": "your-api-key",
65+ "disabled": false
66+ }
67+ },
68+ "agents": {
69+ "coder": {
70+ "model": "claude-3.7-sonnet",
71+ "maxTokens": 5000
72+ },
73+ "task": {
74+ "model": "claude-3.7-sonnet",
75+ "maxTokens": 5000
76+ },
77+ "title": {
78+ "model": "claude-3.7-sonnet",
79+ "maxTokens": 80
80+ }
81+ },
82+ "mcpServers": {
83+ "example": {
84+ "type": "stdio",
85+ "command": "path/to/mcp-server",
86+ "env": [],
87+ "args": []
88+ }
89+ },
90+ "lsp": {
91+ "go": {
92+ "disabled": false,
93+ "command": "gopls"
94+ }
95+ },
96+ "debug": false,
97+ "debugLSP": false
98+}
99+```
38
100
39−- `ANTHROPIC_API_KEY`: For Claude models
101+## Supported AI Models
40−- `OPENAI_API_KEY`: For OpenAI models
102+
103+### OpenAI Models
104+
105+| Model ID | Name | Context Window |
106+| ----------------- | --------------- | ---------------- |
107+| `gpt-4.1` | GPT 4.1 | 1,047,576 tokens |
108+| `gpt-4.1-mini` | GPT 4.1 Mini | 200,000 tokens |
109+| `gpt-4.1-nano` | GPT 4.1 Nano | 1,047,576 tokens |
110+| `gpt-4.5-preview` | GPT 4.5 Preview | 128,000 tokens |
111+| `gpt-4o` | GPT-4o | 128,000 tokens |
112+| `gpt-4o-mini` | GPT-4o Mini | 128,000 tokens |
113+| `o1` | O1 | 200,000 tokens |
114+| `o1-pro` | O1 Pro | 200,000 tokens |
115+| `o1-mini` | O1 Mini | 128,000 tokens |
116+| `o3` | O3 | 200,000 tokens |
117+| `o3-mini` | O3 Mini | 200,000 tokens |
118+| `o4-mini` | O4 Mini | 128,000 tokens |
119+
120+### Anthropic Models
121+
122+| Model ID | Name | Context Window |
123+| ------------------- | ----------------- | -------------- |
124+| `claude-3.5-sonnet` | Claude 3.5 Sonnet | 200,000 tokens |
125+| `claude-3-haiku` | Claude 3 Haiku | 200,000 tokens |
126+| `claude-3.7-sonnet` | Claude 3.7 Sonnet | 200,000 tokens |
127+| `claude-3.5-haiku` | Claude 3.5 Haiku | 200,000 tokens |
128+| `claude-3-opus` | Claude 3 Opus | 200,000 tokens |
129+
130+### Other Models
131+
132+| Model ID | Provider | Name | Context Window |
133+| --------------------------- | ----------- | ----------------- | -------------- |
41−- `GEMINI_API_KEY`: For Google Gemini models
134+| `gemini-2.5` | Google | Gemini 2.5 Pro | - |
135+| `gemini-2.0-flash` | Google | Gemini 2.0 Flash | - |
136+| `qwen-qwq` | Groq | Qwen Qwq | - |
137+| `bedrock.claude-3.7-sonnet` | AWS Bedrock | Claude 3.7 Sonnet | - |
42
138
43## Usage
139## Usage
44
140
@@ -48,36 +144,78 @@ opencode
48
144
49# Start with debug logging
145# Start with debug logging
50opencode -d
146opencode -d
147+
148+# Start with a specific working directory
149+opencode -c /path/to/project
51```
150```
52
151
152+## Command-line Flags
153+
154+| Flag | Short | Description |
155+| --------- | ----- | ----------------------------- |
156+| `--help` | `-h` | Display help information |
157+| `--debug` | `-d` | Enable debug mode |
158+| `--cwd` | `-c` | Set current working directory |
159+
53−### Keyboard Shortcuts
160+## Keyboard Shortcuts
161+
162+### Global Shortcuts
163+
164+| Shortcut | Action |
165+| -------- | ------------------------------------------------------- |
166+| `Ctrl+C` | Quit application |
167+| `Ctrl+?` | Toggle help dialog |
168+| `Ctrl+L` | View logs |
169+| `Esc` | Close current overlay/dialog or return to previous mode |
170+
171+### Chat Page Shortcuts
172+
173+| Shortcut | Action |
174+| -------- | --------------------------------------- |
175+| `Ctrl+N` | Create new session |
176+| `Ctrl+X` | Cancel current operation/generation |
177+| `i` | Focus editor (when not in writing mode) |
178+| `Esc` | Exit writing mode and focus messages |
179+
180+### Editor Shortcuts
54
181
55−#### Global Shortcuts
182+| Shortcut | Action |
183+| ------------------- | ----------------------------------------- |
184+| `Ctrl+S` | Send message (when editor is focused) |
185+| `Enter` or `Ctrl+S` | Send message (when editor is not focused) |
186+| `Esc` | Blur editor and focus messages |
56
187
57−- `?`: Toggle help panel
188+### Logs Page Shortcuts
58−- `Ctrl+C` or `q`: Quit application
59−- `L`: View logs
60−- `Backspace`: Go back to previous page
61−- `Esc`: Close current view/dialog or return to normal mode
62
189
63−#### Session Management
190+| Shortcut | Action |
191+| ----------- | ------------------- |
192+| `Backspace` | Return to chat page |
64
193
65−- `N`: Create new session
194+## AI Assistant Tools
66−- `Enter` or `Space`: Select session (in sessions list)
67
195
68−#### Editor Shortcuts (Vim-like)
196+OpenCode's AI assistant has access to various tools to help with coding tasks:
69
197
70−- `i`: Enter insert mode
198+### File and Code Tools
71−- `Esc`: Enter normal mode
72−- `v`: Enter visual mode
73−- `V`: Enter visual line mode
74−- `Enter`: Send message (in normal mode)
75−- `Ctrl+S`: Send message (in insert mode)
76
199
77−#### Navigation
200+| Tool | Description | Parameters |
201+| ------------- | --------------------------- | ---------------------------------------------------------------------------------------- |
202+| `glob` | Find files by pattern | `pattern` (required), `path` (optional) |
203+| `grep` | Search file contents | `pattern` (required), `path` (optional), `include` (optional), `literal_text` (optional) |
204+| `ls` | List directory contents | `path` (optional), `ignore` (optional array of patterns) |
205+| `view` | View file contents | `file_path` (required), `offset` (optional), `limit` (optional) |
206+| `write` | Write to files | `file_path` (required), `content` (required) |
207+| `edit` | Edit files | Various parameters for file editing |
208+| `patch` | Apply patches to files | `file_path` (required), `diff` (required) |
209+| `diagnostics` | Get diagnostics information | `file_path` (optional) |
78
210
79−- Arrow keys: Navigate through lists and content
211+### Other Tools
80−- Page Up/Down: Scroll through content
212+
213+| Tool | Description | Parameters |
214+| ------------- | -------------------------------------- | ----------------------------------------------------------------------------------------- |
215+| `bash` | Execute shell commands | `command` (required), `timeout` (optional) |
216+| `fetch` | Fetch data from URLs | `url` (required), `format` (required), `timeout` (optional) |
217+| `sourcegraph` | Search code across public repositories | `query` (required), `count` (optional), `context_window` (optional), `timeout` (optional) |
218+| `agent` | Run sub-tasks with the AI agent | `prompt` (required) |
81
219
82## Architecture
220## Architecture
83
221
@@ -92,6 +230,101 @@ OpenCode is built with a modular architecture:
92- **internal/logging**: Logging infrastructure
230- **internal/logging**: Logging infrastructure
93- **internal/message**: Message handling
231- **internal/message**: Message handling
94- **internal/session**: Session management
232- **internal/session**: Session management
233+- **internal/lsp**: Language Server Protocol integration
234+
235+## MCP (Model Context Protocol)
236+
237+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.
238+
239+### MCP Features
240+
241+- **External Tool Integration**: Connect to external tools and services via a standardized protocol
242+- **Tool Discovery**: Automatically discover available tools from MCP servers
243+- **Multiple Connection Types**:
244+ - **Stdio**: Communicate with tools via standard input/output
245+ - **SSE**: Communicate with tools via Server-Sent Events
246+- **Security**: Permission system for controlling access to MCP tools
247+
248+### Configuring MCP Servers
249+
250+MCP servers are defined in the configuration file under the `mcpServers` section:
251+
252+```json
253+{
254+ "mcpServers": {
255+ "example": {
256+ "type": "stdio",
257+ "command": "path/to/mcp-server",
258+ "env": [],
259+ "args": []
260+ },
261+ "web-example": {
262+ "type": "sse",
263+ "url": "https://example.com/mcp",
264+ "headers": {
265+ "Authorization": "Bearer token"
266+ }
267+ }
268+ }
269+}
270+```
271+
272+### MCP Tool Usage
273+
274+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.
275+
276+## LSP (Language Server Protocol)
277+
278+OpenCode integrates with Language Server Protocol to provide rich code intelligence features across multiple programming languages.
279+
280+### LSP Features
281+
282+- **Multi-language Support**: Connect to language servers for different programming languages
283+- **Code Intelligence**: Get diagnostics, completions, and navigation assistance
284+- **File Watching**: Automatically notify language servers of file changes
285+- **Diagnostics**: Display errors, warnings, and hints in your code
286+
287+### Supported LSP Features
288+
289+| Feature | Description |
290+| ----------------- | ----------------------------------- |
291+| Diagnostics | Error checking and linting |
292+| Completions | Code suggestions and autocompletion |
293+| Hover | Documentation on hover |
294+| Definition | Go to definition |
295+| References | Find all references |
296+| Document Symbols | Navigate symbols in current file |
297+| Workspace Symbols | Search symbols across workspace |
298+| Formatting | Code formatting |
299+| Code Actions | Quick fixes and refactorings |
300+
301+### Configuring LSP
302+
303+Language servers are configured in the configuration file under the `lsp` section:
304+
305+```json
306+{
307+ "lsp": {
308+ "go": {
309+ "disabled": false,
310+ "command": "gopls"
311+ },
312+ "typescript": {
313+ "disabled": false,
314+ "command": "typescript-language-server",
315+ "args": ["--stdio"]
316+ }
317+ }
318+}
319+```
320+
321+### LSP Integration with AI
322+
323+The AI assistant can access LSP features through the `diagnostics` tool, allowing it to:
324+
325+- Check for errors in your code
326+- Suggest fixes based on diagnostics
327+- Provide intelligent code assistance
95
328
96## Development
329## Development
97
330
@@ -124,8 +357,16 @@ OpenCode builds upon the work of several open source projects and developers:
124
357
125## License
358## License
126
359
127−[License information coming soon]
360+OpenCode is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
128
361
129## Contributing
362## Contributing
130
363
131−[Contribution guidelines coming soon]
364+Contributions are welcome! Here's how you can contribute:
365+
366+1. Fork the repository
367+2. Create a feature branch (`git checkout -b feature/amazing-feature`)
368+3. Commit your changes (`git commit -m 'Add some amazing feature'`)
369+4. Push to the branch (`git push origin feature/amazing-feature`)
370+5. Open a Pull Request
371+
372+Please make sure to update tests as appropriate and follow the existing code style.
internal/config/config.go+3−13
@@ -41,8 +41,9 @@ const (
41
41
42// Agent defines configuration for different LLM models and their token limits.
42// Agent defines configuration for different LLM models and their token limits.
43type Agent struct {
43type Agent struct {
44− Model models.ModelID `json:"model"`
44+ Model models.ModelID `json:"model"`
45− MaxTokens int64 `json:"maxTokens"`
45+ MaxTokens int64 `json:"maxTokens"`
46+ ReasoningEffort string `json:"reasoningEffort"` // For openai models low,medium,heigh
46}
47}
47
48
48// Provider defines configuration for an LLM provider.
49// Provider defines configuration for an LLM provider.
@@ -80,7 +81,6 @@ type Config struct {
80const (
81const (
81 defaultDataDirectory = ".opencode"
82 defaultDataDirectory = ".opencode"
82 defaultLogLevel = "info"
83 defaultLogLevel = "info"
83− defaultMaxTokens = int64(5000)
84 appName = "opencode"
84 appName = "opencode"
85)
85)
86
86
@@ -202,9 +202,7 @@ func setProviderDefaults() {
202 if apiKey := os.Getenv("GROQ_API_KEY"); apiKey != "" {
202 if apiKey := os.Getenv("GROQ_API_KEY"); apiKey != "" {
203 viper.SetDefault("providers.groq.apiKey", apiKey)
203 viper.SetDefault("providers.groq.apiKey", apiKey)
204 viper.SetDefault("agents.coder.model", models.QWENQwq)
204 viper.SetDefault("agents.coder.model", models.QWENQwq)
205− viper.SetDefault("agents.coder.maxTokens", defaultMaxTokens)
206 viper.SetDefault("agents.task.model", models.QWENQwq)
205 viper.SetDefault("agents.task.model", models.QWENQwq)
207− viper.SetDefault("agents.task.maxTokens", defaultMaxTokens)
208 viper.SetDefault("agents.title.model", models.QWENQwq)
206 viper.SetDefault("agents.title.model", models.QWENQwq)
209 }
207 }
210
208
@@ -212,9 +210,7 @@ func setProviderDefaults() {
212 if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
210 if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
213 viper.SetDefault("providers.gemini.apiKey", apiKey)
211 viper.SetDefault("providers.gemini.apiKey", apiKey)
214 viper.SetDefault("agents.coder.model", models.GRMINI20Flash)
212 viper.SetDefault("agents.coder.model", models.GRMINI20Flash)
215− viper.SetDefault("agents.coder.maxTokens", defaultMaxTokens)
216 viper.SetDefault("agents.task.model", models.GRMINI20Flash)
213 viper.SetDefault("agents.task.model", models.GRMINI20Flash)
217− viper.SetDefault("agents.task.maxTokens", defaultMaxTokens)
218 viper.SetDefault("agents.title.model", models.GRMINI20Flash)
214 viper.SetDefault("agents.title.model", models.GRMINI20Flash)
219 }
215 }
220
216
@@ -222,9 +218,7 @@ func setProviderDefaults() {
222 if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
218 if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
223 viper.SetDefault("providers.openai.apiKey", apiKey)
219 viper.SetDefault("providers.openai.apiKey", apiKey)
224 viper.SetDefault("agents.coder.model", models.GPT4o)
220 viper.SetDefault("agents.coder.model", models.GPT4o)
225− viper.SetDefault("agents.coder.maxTokens", defaultMaxTokens)
226 viper.SetDefault("agents.task.model", models.GPT4o)
221 viper.SetDefault("agents.task.model", models.GPT4o)
227− viper.SetDefault("agents.task.maxTokens", defaultMaxTokens)
228 viper.SetDefault("agents.title.model", models.GPT4o)
222 viper.SetDefault("agents.title.model", models.GPT4o)
229
223
230 }
224 }
@@ -233,17 +227,13 @@ func setProviderDefaults() {
233 if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
227 if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
234 viper.SetDefault("providers.anthropic.apiKey", apiKey)
228 viper.SetDefault("providers.anthropic.apiKey", apiKey)
235 viper.SetDefault("agents.coder.model", models.Claude37Sonnet)
229 viper.SetDefault("agents.coder.model", models.Claude37Sonnet)
236− viper.SetDefault("agents.coder.maxTokens", defaultMaxTokens)
237 viper.SetDefault("agents.task.model", models.Claude37Sonnet)
230 viper.SetDefault("agents.task.model", models.Claude37Sonnet)
238− viper.SetDefault("agents.task.maxTokens", defaultMaxTokens)
239 viper.SetDefault("agents.title.model", models.Claude37Sonnet)
231 viper.SetDefault("agents.title.model", models.Claude37Sonnet)
240 }
232 }
241
233
242 if hasAWSCredentials() {
234 if hasAWSCredentials() {
243 viper.SetDefault("agents.coder.model", models.BedrockClaude37Sonnet)
235 viper.SetDefault("agents.coder.model", models.BedrockClaude37Sonnet)
244− viper.SetDefault("agents.coder.maxTokens", defaultMaxTokens)
245 viper.SetDefault("agents.task.model", models.BedrockClaude37Sonnet)
236 viper.SetDefault("agents.task.model", models.BedrockClaude37Sonnet)
246− viper.SetDefault("agents.task.maxTokens", defaultMaxTokens)
247 viper.SetDefault("agents.title.model", models.BedrockClaude37Sonnet)
237 viper.SetDefault("agents.title.model", models.BedrockClaude37Sonnet)
248 }
238 }
249}
239}
internal/diff/diff.go+18−10
@@ -79,8 +79,9 @@ type linePair struct {
79
79
80// StyleConfig defines styling for diff rendering
80// StyleConfig defines styling for diff rendering
81type StyleConfig struct {
81type StyleConfig struct {
82− ShowHeader bool
82+ ShowHeader bool
83+ ShowHunkHeader bool
83− FileNameFg lipgloss.Color
84+ FileNameFg lipgloss.Color
84 // Background colors
85 // Background colors
85 RemovedLineBg lipgloss.Color
86 RemovedLineBg lipgloss.Color
86 AddedLineBg lipgloss.Color
87 AddedLineBg lipgloss.Color
@@ -111,7 +112,8 @@ func NewStyleConfig(opts ...StyleOption) StyleConfig {
111 // Default color scheme
112 // Default color scheme
112 config := StyleConfig{
113 config := StyleConfig{
113 ShowHeader: true,
114 ShowHeader: true,
115+ ShowHunkHeader: true,
114− FileNameFg: lipgloss.Color("#fab283"),
116+ FileNameFg: lipgloss.Color("#a0a0a0"),
115 RemovedLineBg: lipgloss.Color("#3A3030"),
117 RemovedLineBg: lipgloss.Color("#3A3030"),
116 AddedLineBg: lipgloss.Color("#303A30"),
118 AddedLineBg: lipgloss.Color("#303A30"),
117 ContextLineBg: lipgloss.Color("#212121"),
119 ContextLineBg: lipgloss.Color("#212121"),
@@ -204,6 +206,10 @@ func WithShowHeader(show bool) StyleOption {
204 return func(s *StyleConfig) { s.ShowHeader = show }
206 return func(s *StyleConfig) { s.ShowHeader = show }
205}
207}
206
208
209+func WithShowHunkHeader(show bool) StyleOption {
210+ return func(s *StyleConfig) { s.ShowHunkHeader = show }
211+}
212+
207// -------------------------------------------------------------------------
213// -------------------------------------------------------------------------
208// Parse Configuration
214// Parse Configuration
209// -------------------------------------------------------------------------
215// -------------------------------------------------------------------------
@@ -914,13 +920,15 @@ func FormatDiff(diffText string, opts ...SideBySideOption) (string, error) {
914
920
915 for _, h := range diffResult.Hunks {
921 for _, h := range diffResult.Hunks {
916 // Render hunk header
922 // Render hunk header
923+ if config.Style.ShowHunkHeader {
917− sb.WriteString(
924+ sb.WriteString(
918− lipgloss.NewStyle().
925+ lipgloss.NewStyle().
919− Background(config.Style.HunkLineBg).
926+ Background(config.Style.HunkLineBg).
920− Foreground(config.Style.HunkLineFg).
927+ Foreground(config.Style.HunkLineFg).
921− Width(config.TotalWidth).
928+ Width(config.TotalWidth).
922− Render(h.Header) + "\n",
929+ Render(h.Header) + "\n",
923− )
930+ )
931+ }
924 sb.WriteString(RenderSideBySideHunk(diffResult.OldFile, h, opts...))
932 sb.WriteString(RenderSideBySideHunk(diffResult.OldFile, h, opts...))
925 }
933 }
926
934
internal/diff/patch.gonew file+739
@@ -0,0 +1,739 @@
1+package diff
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+)
10+
11+type ActionType string
12+
13+const (
14+ ActionAdd ActionType = "add"
15+ ActionDelete ActionType = "delete"
16+ ActionUpdate ActionType = "update"
17+)
18+
19+type FileChange struct {
20+ Type ActionType
21+ OldContent *string
22+ NewContent *string
23+ MovePath *string
24+}
25+
26+type Commit struct {
27+ Changes map[string]FileChange
28+}
29+
30+type Chunk struct {
31+ OrigIndex int // line index of the first line in the original file
32+ DelLines []string // lines to delete
33+ InsLines []string // lines to insert
34+}
35+
36+type PatchAction struct {
37+ Type ActionType
38+ NewFile *string
39+ Chunks []Chunk
40+ MovePath *string
41+}
42+
43+type Patch struct {
44+ Actions map[string]PatchAction
45+}
46+
47+type DiffError struct {
48+ message string
49+}
50+
51+func (e DiffError) Error() string {
52+ return e.message
53+}
54+
55+// Helper functions for error handling
56+func NewDiffError(message string) DiffError {
57+ return DiffError{message: message}
58+}
59+
60+func fileError(action, reason, path string) DiffError {
61+ return NewDiffError(fmt.Sprintf("%s File Error: %s: %s", action, reason, path))
62+}
63+
64+func contextError(index int, context string, isEOF bool) DiffError {
65+ prefix := "Invalid Context"
66+ if isEOF {
67+ prefix = "Invalid EOF Context"
68+ }
69+ return NewDiffError(fmt.Sprintf("%s %d:\n%s", prefix, index, context))
70+}
71+
72+type Parser struct {
73+ currentFiles map[string]string
74+ lines []string
75+ index int
76+ patch Patch
77+ fuzz int
78+}
79+
80+func NewParser(currentFiles map[string]string, lines []string) *Parser {
81+ return &Parser{
82+ currentFiles: currentFiles,
83+ lines: lines,
84+ index: 0,
85+ patch: Patch{Actions: make(map[string]PatchAction, len(currentFiles))},
86+ fuzz: 0,
87+ }
88+}
89+
90+func (p *Parser) isDone(prefixes []string) bool {
91+ if p.index >= len(p.lines) {
92+ return true
93+ }
94+ if prefixes != nil {
95+ for _, prefix := range prefixes {
96+ if strings.HasPrefix(p.lines[p.index], prefix) {
97+ return true
98+ }
99+ }
100+ }
101+ return false
102+}
103+
104+func (p *Parser) startsWith(prefix any) bool {
105+ var prefixes []string
106+ switch v := prefix.(type) {
107+ case string:
108+ prefixes = []string{v}
109+ case []string:
110+ prefixes = v
111+ }
112+
113+ for _, pfx := range prefixes {
114+ if strings.HasPrefix(p.lines[p.index], pfx) {
115+ return true
116+ }
117+ }
118+ return false
119+}
120+
121+func (p *Parser) readStr(prefix string, returnEverything bool) string {
122+ if p.index >= len(p.lines) {
123+ return "" // Changed from panic to return empty string for safer operation
124+ }
125+ if strings.HasPrefix(p.lines[p.index], prefix) {
126+ var text string
127+ if returnEverything {
128+ text = p.lines[p.index]
129+ } else {
130+ text = p.lines[p.index][len(prefix):]
131+ }
132+ p.index++
133+ return text
134+ }
135+ return ""
136+}
137+
138+func (p *Parser) Parse() error {
139+ endPatchPrefixes := []string{"*** End Patch"}
140+
141+ for !p.isDone(endPatchPrefixes) {
142+ path := p.readStr("*** Update File: ", false)
143+ if path != "" {
144+ if _, exists := p.patch.Actions[path]; exists {
145+ return fileError("Update", "Duplicate Path", path)
146+ }
147+ moveTo := p.readStr("*** Move to: ", false)
148+ if _, exists := p.currentFiles[path]; !exists {
149+ return fileError("Update", "Missing File", path)
150+ }
151+ text := p.currentFiles[path]
152+ action, err := p.parseUpdateFile(text)
153+ if err != nil {
154+ return err
155+ }
156+ if moveTo != "" {
157+ action.MovePath = &moveTo
158+ }
159+ p.patch.Actions[path] = action
160+ continue
161+ }
162+
163+ path = p.readStr("*** Delete File: ", false)
164+ if path != "" {
165+ if _, exists := p.patch.Actions[path]; exists {
166+ return fileError("Delete", "Duplicate Path", path)
167+ }
168+ if _, exists := p.currentFiles[path]; !exists {
169+ return fileError("Delete", "Missing File", path)
170+ }
171+ p.patch.Actions[path] = PatchAction{Type: ActionDelete, Chunks: []Chunk{}}
172+ continue
173+ }
174+
175+ path = p.readStr("*** Add File: ", false)
176+ if path != "" {
177+ if _, exists := p.patch.Actions[path]; exists {
178+ return fileError("Add", "Duplicate Path", path)
179+ }
180+ if _, exists := p.currentFiles[path]; exists {
181+ return fileError("Add", "File already exists", path)
182+ }
183+ action, err := p.parseAddFile()
184+ if err != nil {
185+ return err
186+ }
187+ p.patch.Actions[path] = action
188+ continue
189+ }
190+
191+ return NewDiffError(fmt.Sprintf("Unknown Line: %s", p.lines[p.index]))
192+ }
193+
194+ if !p.startsWith("*** End Patch") {
195+ return NewDiffError("Missing End Patch")
196+ }
197+ p.index++
198+
199+ return nil
200+}
201+
202+func (p *Parser) parseUpdateFile(text string) (PatchAction, error) {
203+ action := PatchAction{Type: ActionUpdate, Chunks: []Chunk{}}
204+ fileLines := strings.Split(text, "\n")
205+ index := 0
206+
207+ endPrefixes := []string{
208+ "*** End Patch",
209+ "*** Update File:",
210+ "*** Delete File:",
211+ "*** Add File:",
212+ "*** End of File",
213+ }
214+
215+ for !p.isDone(endPrefixes) {
216+ defStr := p.readStr("@@ ", false)
217+ sectionStr := ""
218+ if defStr == "" && p.index < len(p.lines) && p.lines[p.index] == "@@" {
219+ sectionStr = p.lines[p.index]
220+ p.index++
221+ }
222+ if !(defStr != "" || sectionStr != "" || index == 0) {
223+ return action, NewDiffError(fmt.Sprintf("Invalid Line:\n%s", p.lines[p.index]))
224+ }
225+ if strings.TrimSpace(defStr) != "" {
226+ found := false
227+ for i := range fileLines[:index] {
228+ if fileLines[i] == defStr {
229+ found = true
230+ break
231+ }
232+ }
233+
234+ if !found {
235+ for i := index; i < len(fileLines); i++ {
236+ if fileLines[i] == defStr {
237+ index = i + 1
238+ found = true
239+ break
240+ }
241+ }
242+ }
243+
244+ if !found {
245+ for i := range fileLines[:index] {
246+ if strings.TrimSpace(fileLines[i]) == strings.TrimSpace(defStr) {
247+ found = true
248+ break
249+ }
250+ }
251+ }
252+
253+ if !found {
254+ for i := index; i < len(fileLines); i++ {
255+ if strings.TrimSpace(fileLines[i]) == strings.TrimSpace(defStr) {
256+ index = i + 1
257+ p.fuzz++
258+ found = true
259+ break
260+ }
261+ }
262+ }
263+ }
264+
265+ nextChunkContext, chunks, endPatchIndex, eof := peekNextSection(p.lines, p.index)
266+ newIndex, fuzz := findContext(fileLines, nextChunkContext, index, eof)
267+ if newIndex == -1 {
268+ ctxText := strings.Join(nextChunkContext, "\n")
269+ return action, contextError(index, ctxText, eof)
270+ }
271+ p.fuzz += fuzz
272+
273+ for _, ch := range chunks {
274+ ch.OrigIndex += newIndex
275+ action.Chunks = append(action.Chunks, ch)
276+ }
277+ index = newIndex + len(nextChunkContext)
278+ p.index = endPatchIndex
279+ }
280+ return action, nil
281+}
282+
283+func (p *Parser) parseAddFile() (PatchAction, error) {
284+ lines := make([]string, 0, 16) // Preallocate space for better performance
285+ endPrefixes := []string{
286+ "*** End Patch",
287+ "*** Update File:",
288+ "*** Delete File:",
289+ "*** Add File:",
290+ }
291+
292+ for !p.isDone(endPrefixes) {
293+ s := p.readStr("", true)
294+ if !strings.HasPrefix(s, "+") {
295+ return PatchAction{}, NewDiffError(fmt.Sprintf("Invalid Add File Line: %s", s))
296+ }
297+ lines = append(lines, s[1:])
298+ }
299+
300+ newFile := strings.Join(lines, "\n")
301+ return PatchAction{
302+ Type: ActionAdd,
303+ NewFile: &newFile,
304+ Chunks: []Chunk{},
305+ }, nil
306+}
307+
308+// Refactored to use a matcher function for each comparison type
309+func findContextCore(lines []string, context []string, start int) (int, int) {
310+ if len(context) == 0 {
311+ return start, 0
312+ }
313+
314+ // Try exact match
315+ if idx, fuzz := tryFindMatch(lines, context, start, func(a, b string) bool {
316+ return a == b
317+ }); idx >= 0 {
318+ return idx, fuzz
319+ }
320+
321+ // Try trimming right whitespace
322+ if idx, fuzz := tryFindMatch(lines, context, start, func(a, b string) bool {
323+ return strings.TrimRight(a, " \t") == strings.TrimRight(b, " \t")
324+ }); idx >= 0 {
325+ return idx, fuzz
326+ }
327+
328+ // Try trimming all whitespace
329+ if idx, fuzz := tryFindMatch(lines, context, start, func(a, b string) bool {
330+ return strings.TrimSpace(a) == strings.TrimSpace(b)
331+ }); idx >= 0 {
332+ return idx, fuzz
333+ }
334+
335+ return -1, 0
336+}
337+
338+// Helper function to DRY up the match logic
339+func tryFindMatch(lines []string, context []string, start int,
340+ compareFunc func(string, string) bool,
341+) (int, int) {
342+ for i := start; i < len(lines); i++ {
343+ if i+len(context) <= len(lines) {
344+ match := true
345+ for j := range context {
346+ if !compareFunc(lines[i+j], context[j]) {
347+ match = false
348+ break
349+ }
350+ }
351+ if match {
352+ // Return fuzz level: 0 for exact, 1 for trimRight, 100 for trimSpace
353+ var fuzz int
354+ if compareFunc("a ", "a") && !compareFunc("a", "b") {
355+ fuzz = 1
356+ } else if compareFunc("a ", "a") {
357+ fuzz = 100
358+ }
359+ return i, fuzz
360+ }
361+ }
362+ }
363+ return -1, 0
364+}
365+
366+func findContext(lines []string, context []string, start int, eof bool) (int, int) {
367+ if eof {
368+ newIndex, fuzz := findContextCore(lines, context, len(lines)-len(context))
369+ if newIndex != -1 {
370+ return newIndex, fuzz
371+ }
372+ newIndex, fuzz = findContextCore(lines, context, start)
373+ return newIndex, fuzz + 10000
374+ }
375+ return findContextCore(lines, context, start)
376+}
377+
378+func peekNextSection(lines []string, initialIndex int) ([]string, []Chunk, int, bool) {
379+ index := initialIndex
380+ old := make([]string, 0, 32) // Preallocate for better performance
381+ delLines := make([]string, 0, 8)
382+ insLines := make([]string, 0, 8)
383+ chunks := make([]Chunk, 0, 4)
384+ mode := "keep"
385+
386+ // End conditions for the section
387+ endSectionConditions := func(s string) bool {
388+ return strings.HasPrefix(s, "@@") ||
389+ strings.HasPrefix(s, "*** End Patch") ||
390+ strings.HasPrefix(s, "*** Update File:") ||
391+ strings.HasPrefix(s, "*** Delete File:") ||
392+ strings.HasPrefix(s, "*** Add File:") ||
393+ strings.HasPrefix(s, "*** End of File") ||
394+ s == "***" ||
395+ strings.HasPrefix(s, "***")
396+ }
397+
398+ for index < len(lines) {
399+ s := lines[index]
400+ if endSectionConditions(s) {
401+ break
402+ }
403+ index++
404+ lastMode := mode
405+ line := s
406+
407+ if len(line) > 0 {
408+ switch line[0] {
409+ case '+':
410+ mode = "add"
411+ case '-':
412+ mode = "delete"
413+ case ' ':
414+ mode = "keep"
415+ default:
416+ mode = "keep"
417+ line = " " + line
418+ }
419+ } else {
420+ mode = "keep"
421+ line = " "
422+ }
423+
424+ line = line[1:]
425+ if mode == "keep" && lastMode != mode {
426+ if len(insLines) > 0 || len(delLines) > 0 {
427+ chunks = append(chunks, Chunk{
428+ OrigIndex: len(old) - len(delLines),
429+ DelLines: delLines,
430+ InsLines: insLines,
431+ })
432+ }
433+ delLines = make([]string, 0, 8)
434+ insLines = make([]string, 0, 8)
435+ }
436+ if mode == "delete" {
437+ delLines = append(delLines, line)
438+ old = append(old, line)
439+ } else if mode == "add" {
440+ insLines = append(insLines, line)
441+ } else {
442+ old = append(old, line)
443+ }
444+ }
445+
446+ if len(insLines) > 0 || len(delLines) > 0 {
447+ chunks = append(chunks, Chunk{
448+ OrigIndex: len(old) - len(delLines),
449+ DelLines: delLines,
450+ InsLines: insLines,
451+ })
452+ }
453+
454+ if index < len(lines) && lines[index] == "*** End of File" {
455+ index++
456+ return old, chunks, index, true
457+ }
458+ return old, chunks, index, false
459+}
460+
461+func TextToPatch(text string, orig map[string]string) (Patch, int, error) {
462+ text = strings.TrimSpace(text)
463+ lines := strings.Split(text, "\n")
464+ if len(lines) < 2 || !strings.HasPrefix(lines[0], "*** Begin Patch") || lines[len(lines)-1] != "*** End Patch" {
465+ return Patch{}, 0, NewDiffError("Invalid patch text")
466+ }
467+ parser := NewParser(orig, lines)
468+ parser.index = 1
469+ if err := parser.Parse(); err != nil {
470+ return Patch{}, 0, err
471+ }
472+ return parser.patch, parser.fuzz, nil
473+}
474+
475+func IdentifyFilesNeeded(text string) []string {
476+ text = strings.TrimSpace(text)
477+ lines := strings.Split(text, "\n")
478+ result := make(map[string]bool)
479+
480+ for _, line := range lines {
481+ if strings.HasPrefix(line, "*** Update File: ") {
482+ result[line[len("*** Update File: "):]] = true
483+ }
484+ if strings.HasPrefix(line, "*** Delete File: ") {
485+ result[line[len("*** Delete File: "):]] = true
486+ }
487+ }
488+
489+ files := make([]string, 0, len(result))
490+ for file := range result {
491+ files = append(files, file)
492+ }
493+ return files
494+}
495+
496+func IdentifyFilesAdded(text string) []string {
497+ text = strings.TrimSpace(text)
498+ lines := strings.Split(text, "\n")
499+ result := make(map[string]bool)
500+
501+ for _, line := range lines {
502+ if strings.HasPrefix(line, "*** Add File: ") {
503+ result[line[len("*** Add File: "):]] = true
504+ }
505+ }
506+
507+ files := make([]string, 0, len(result))
508+ for file := range result {
509+ files = append(files, file)
510+ }
511+ return files
512+}
513+
514+func getUpdatedFile(text string, action PatchAction, path string) (string, error) {
515+ if action.Type != ActionUpdate {
516+ return "", errors.New("Expected UPDATE action")
517+ }
518+ origLines := strings.Split(text, "\n")
519+ destLines := make([]string, 0, len(origLines)) // Preallocate with capacity
520+ origIndex := 0
521+
522+ for _, chunk := range action.Chunks {
523+ if chunk.OrigIndex > len(origLines) {
524+ return "", NewDiffError(fmt.Sprintf("%s: chunk.orig_index %d > len(lines) %d", path, chunk.OrigIndex, len(origLines)))
525+ }
526+ if origIndex > chunk.OrigIndex {
527+ return "", NewDiffError(fmt.Sprintf("%s: orig_index %d > chunk.orig_index %d", path, origIndex, chunk.OrigIndex))
528+ }
529+ destLines = append(destLines, origLines[origIndex:chunk.OrigIndex]...)
530+ delta := chunk.OrigIndex - origIndex
531+ origIndex += delta
532+
533+ if len(chunk.InsLines) > 0 {
534+ destLines = append(destLines, chunk.InsLines...)
535+ }
536+ origIndex += len(chunk.DelLines)
537+ }
538+
539+ destLines = append(destLines, origLines[origIndex:]...)
540+ return strings.Join(destLines, "\n"), nil
541+}
542+
543+func PatchToCommit(patch Patch, orig map[string]string) (Commit, error) {
544+ commit := Commit{Changes: make(map[string]FileChange, len(patch.Actions))}
545+ for pathKey, action := range patch.Actions {
546+ if action.Type == ActionDelete {
547+ oldContent := orig[pathKey]
548+ commit.Changes[pathKey] = FileChange{
549+ Type: ActionDelete,
550+ OldContent: &oldContent,
551+ }
552+ } else if action.Type == ActionAdd {
553+ commit.Changes[pathKey] = FileChange{
554+ Type: ActionAdd,
555+ NewContent: action.NewFile,
556+ }
557+ } else if action.Type == ActionUpdate {
558+ newContent, err := getUpdatedFile(orig[pathKey], action, pathKey)
559+ if err != nil {
560+ return Commit{}, err
561+ }
562+ oldContent := orig[pathKey]
563+ fileChange := FileChange{
564+ Type: ActionUpdate,
565+ OldContent: &oldContent,
566+ NewContent: &newContent,
567+ }
568+ if action.MovePath != nil {
569+ fileChange.MovePath = action.MovePath
570+ }
571+ commit.Changes[pathKey] = fileChange
572+ }
573+ }
574+ return commit, nil
575+}
576+
577+func AssembleChanges(orig map[string]string, updatedFiles map[string]string) Commit {
578+ commit := Commit{Changes: make(map[string]FileChange, len(updatedFiles))}
579+ for p, newContent := range updatedFiles {
580+ oldContent, exists := orig[p]
581+ if exists && oldContent == newContent {
582+ continue
583+ }
584+
585+ if exists && newContent != "" {
586+ commit.Changes[p] = FileChange{
587+ Type: ActionUpdate,
588+ OldContent: &oldContent,
589+ NewContent: &newContent,
590+ }
591+ } else if newContent != "" {
592+ commit.Changes[p] = FileChange{
593+ Type: ActionAdd,
594+ NewContent: &newContent,
595+ }
596+ } else if exists {
597+ commit.Changes[p] = FileChange{
598+ Type: ActionDelete,
599+ OldContent: &oldContent,
600+ }
601+ } else {
602+ return commit // Changed from panic to simply return current commit
603+ }
604+ }
605+ return commit
606+}
607+
608+func LoadFiles(paths []string, openFn func(string) (string, error)) (map[string]string, error) {
609+ orig := make(map[string]string, len(paths))
610+ for _, p := range paths {
611+ content, err := openFn(p)
612+ if err != nil {
613+ return nil, fileError("Open", "File not found", p)
614+ }
615+ orig[p] = content
616+ }
617+ return orig, nil
618+}
619+
620+func ApplyCommit(commit Commit, writeFn func(string, string) error, removeFn func(string) error) error {
621+ for p, change := range commit.Changes {
622+ if change.Type == ActionDelete {
623+ if err := removeFn(p); err != nil {
624+ return err
625+ }
626+ } else if change.Type == ActionAdd {
627+ if change.NewContent == nil {
628+ return NewDiffError(fmt.Sprintf("Add action for %s has nil new_content", p))
629+ }
630+ if err := writeFn(p, *change.NewContent); err != nil {
631+ return err
632+ }
633+ } else if change.Type == ActionUpdate {
634+ if change.NewContent == nil {
635+ return NewDiffError(fmt.Sprintf("Update action for %s has nil new_content", p))
636+ }
637+ if change.MovePath != nil {
638+ if err := writeFn(*change.MovePath, *change.NewContent); err != nil {
639+ return err
640+ }
641+ if err := removeFn(p); err != nil {
642+ return err
643+ }
644+ } else {
645+ if err := writeFn(p, *change.NewContent); err != nil {
646+ return err
647+ }
648+ }
649+ }
650+ }
651+ return nil
652+}
653+
654+func ProcessPatch(text string, openFn func(string) (string, error), writeFn func(string, string) error, removeFn func(string) error) (string, error) {
655+ if !strings.HasPrefix(text, "*** Begin Patch") {
656+ return "", NewDiffError("Patch must start with *** Begin Patch")
657+ }
658+ paths := IdentifyFilesNeeded(text)
659+ orig, err := LoadFiles(paths, openFn)
660+ if err != nil {
661+ return "", err
662+ }
663+
664+ patch, fuzz, err := TextToPatch(text, orig)
665+ if err != nil {
666+ return "", err
667+ }
668+
669+ if fuzz > 0 {
670+ return "", NewDiffError(fmt.Sprintf("Patch contains fuzzy matches (fuzz level: %d)", fuzz))
671+ }
672+
673+ commit, err := PatchToCommit(patch, orig)
674+ if err != nil {
675+ return "", err
676+ }
677+
678+ if err := ApplyCommit(commit, writeFn, removeFn); err != nil {
679+ return "", err
680+ }
681+
682+ return "Patch applied successfully", nil
683+}
684+
685+func OpenFile(p string) (string, error) {
686+ data, err := os.ReadFile(p)
687+ if err != nil {
688+ return "", err
689+ }
690+ return string(data), nil
691+}
692+
693+func WriteFile(p string, content string) error {
694+ if filepath.IsAbs(p) {
695+ return NewDiffError("We do not support absolute paths.")
696+ }
697+
698+ dir := filepath.Dir(p)
699+ if dir != "." {
700+ if err := os.MkdirAll(dir, 0o755); err != nil {
701+ return err
702+ }
703+ }
704+
705+ return os.WriteFile(p, []byte(content), 0o644)
706+}
707+
708+func RemoveFile(p string) error {
709+ return os.Remove(p)
710+}
711+
712+func ValidatePatch(patchText string, files map[string]string) (bool, string, error) {
713+ if !strings.HasPrefix(patchText, "*** Begin Patch") {
714+ return false, "Patch must start with *** Begin Patch", nil
715+ }
716+
717+ neededFiles := IdentifyFilesNeeded(patchText)
718+ for _, filePath := range neededFiles {
719+ if _, exists := files[filePath]; !exists {
720+ return false, fmt.Sprintf("File not found: %s", filePath), nil
721+ }
722+ }
723+
724+ patch, fuzz, err := TextToPatch(patchText, files)
725+ if err != nil {
726+ return false, err.Error(), nil
727+ }
728+
729+ if fuzz > 0 {
730+ return false, fmt.Sprintf("Patch contains fuzzy matches (fuzz level: %d)", fuzz), nil
731+ }
732+
733+ _, err = PatchToCommit(patch, files)
734+ if err != nil {
735+ return false, err.Error(), nil
736+ }
737+
738+ return true, "Patch is valid", nil
739+}
internal/history/file.go+11−10
@@ -50,6 +50,7 @@ func NewService(q *db.Queries, db *sql.DB) Service {
50 return &service{
50 return &service{
51 Broker: pubsub.NewBroker[File](),
51 Broker: pubsub.NewBroker[File](),
52 q: q,
52 q: q,
53+ db: db,
53 }
54 }
54}
55}
55
56
@@ -100,30 +101,30 @@ func (s *service) createWithVersion(ctx context.Context, sessionID, path, conten
100 var err error
101 var err error
101
102
102 // Retry loop for transaction conflicts
103 // Retry loop for transaction conflicts
103− for attempt := 0; attempt < maxRetries; attempt++ {
104+ for attempt := range maxRetries {
104 // Start a transaction
105 // Start a transaction
105− tx, err := s.db.BeginTx(ctx, nil)
106+ tx, txErr := s.db.Begin()
106− if err != nil {
107+ if txErr != nil {
107− return File{}, fmt.Errorf("failed to begin transaction: %w", err)
108+ return File{}, fmt.Errorf("failed to begin transaction: %w", txErr)
108 }
109 }
109
110
110 // Create a new queries instance with the transaction
111 // Create a new queries instance with the transaction
111 qtx := s.q.WithTx(tx)
112 qtx := s.q.WithTx(tx)
112
113
113 // Try to create the file within the transaction
114 // Try to create the file within the transaction
114− dbFile, err := qtx.CreateFile(ctx, db.CreateFileParams{
115+ dbFile, txErr := qtx.CreateFile(ctx, db.CreateFileParams{
115 ID: uuid.New().String(),
116 ID: uuid.New().String(),
116 SessionID: sessionID,
117 SessionID: sessionID,
117 Path: path,
118 Path: path,
118 Content: content,
119 Content: content,
119 Version: version,
120 Version: version,
120 })
121 })
121− if err != nil {
122+ if txErr != nil {
122 // Rollback the transaction
123 // Rollback the transaction
123 tx.Rollback()
124 tx.Rollback()
124
125
125 // Check if this is a uniqueness constraint violation
126 // Check if this is a uniqueness constraint violation
126− if strings.Contains(err.Error(), "UNIQUE constraint failed") {
127+ if strings.Contains(txErr.Error(), "UNIQUE constraint failed") {
127 if attempt < maxRetries-1 {
128 if attempt < maxRetries-1 {
128 // If we have retries left, generate a new version and try again
129 // If we have retries left, generate a new version and try again
129 if strings.HasPrefix(version, "v") {
130 if strings.HasPrefix(version, "v") {
@@ -138,12 +139,12 @@ func (s *service) createWithVersion(ctx context.Context, sessionID, path, conten
138 continue
139 continue
139 }
140 }
140 }
141 }
141− return File{}, err
142+ return File{}, txErr
142 }
143 }
143
144
144 // Commit the transaction
145 // Commit the transaction
145− if err = tx.Commit(); err != nil {
146+ if txErr = tx.Commit(); txErr != nil {
146− return File{}, fmt.Errorf("failed to commit transaction: %w", err)
147+ return File{}, fmt.Errorf("failed to commit transaction: %w", txErr)
147 }
148 }
148
149
149 file = s.fromDBItem(dbFile)
150 file = s.fromDBItem(dbFile)
internal/llm/agent/agent.go+34−19
@@ -41,6 +41,7 @@ type Service interface {
41 Run(ctx context.Context, sessionID string, content string) (<-chan AgentEvent, error)
41 Run(ctx context.Context, sessionID string, content string) (<-chan AgentEvent, error)
42 Cancel(sessionID string)
42 Cancel(sessionID string)
43 IsSessionBusy(sessionID string) bool
43 IsSessionBusy(sessionID string) bool
44+ IsBusy() bool
44}
45}
45
46
46type agent struct {
47type agent struct {
@@ -95,6 +96,20 @@ func (a *agent) Cancel(sessionID string) {
95 }
96 }
96}
97}
97
98
99+func (a *agent) IsBusy() bool {
100+ busy := false
101+ a.activeRequests.Range(func(key, value interface{}) bool {
102+ if cancelFunc, ok := value.(context.CancelFunc); ok {
103+ if cancelFunc != nil {
104+ busy = true
105+ return false // Stop iterating
106+ }
107+ }
108+ return true // Continue iterating
109+ })
110+ return busy
111+}
112+
98func (a *agent) IsSessionBusy(sessionID string) bool {
113func (a *agent) IsSessionBusy(sessionID string) bool {
99 _, busy := a.activeRequests.Load(sessionID)
114 _, busy := a.activeRequests.Load(sessionID)
100 return busy
115 return busy
@@ -313,23 +328,8 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg
313 }
328 }
314 }
329 }
315 a.finishMessage(ctx, &assistantMsg, message.FinishReasonPermissionDenied)
330 a.finishMessage(ctx, &assistantMsg, message.FinishReasonPermissionDenied)
316− } else {
331+ break
317− toolResults[i] = message.ToolResult{
318− ToolCallID: toolCall.ID,
319− Content: toolErr.Error(),
320− IsError: true,
321− }
322− for j := i; j < len(toolCalls); j++ {
323− toolResults[j] = message.ToolResult{
324− ToolCallID: toolCalls[j].ID,
325− Content: "Previous tool failed",
326− IsError: true,
327− }
328− }
329− a.finishMessage(ctx, &assistantMsg, message.FinishReasonError)
330 }
332 }
331− // If permission is denied or an error happens we cancel all the following tools
332− break
333 }
333 }
334 toolResults[i] = message.ToolResult{
334 toolResults[i] = message.ToolResult{
335 ToolCallID: toolCall.ID,
335 ToolCallID: toolCall.ID,
@@ -437,12 +437,27 @@ func createAgentProvider(agentName config.AgentName) (provider.Provider, error)
437 if providerCfg.Disabled {
437 if providerCfg.Disabled {
438 return nil, fmt.Errorf("provider %s is not enabled", model.Provider)
438 return nil, fmt.Errorf("provider %s is not enabled", model.Provider)
439 }
439 }
440− agentProvider, err := provider.NewProvider(
440+ maxTokens := model.DefaultMaxTokens
441− model.Provider,
441+ if agentConfig.MaxTokens > 0 {
442+ maxTokens = agentConfig.MaxTokens
443+ }
444+ opts := []provider.ProviderClientOption{
442 provider.WithAPIKey(providerCfg.APIKey),
445 provider.WithAPIKey(providerCfg.APIKey),
443 provider.WithModel(model),
446 provider.WithModel(model),
444 provider.WithSystemMessage(prompt.GetAgentPrompt(agentName, model.Provider)),
447 provider.WithSystemMessage(prompt.GetAgentPrompt(agentName, model.Provider)),
445− provider.WithMaxTokens(agentConfig.MaxTokens),
448+ provider.WithMaxTokens(maxTokens),
449+ }
450+ if model.Provider == models.ProviderOpenAI && model.CanReason {
451+ opts = append(
452+ opts,
453+ provider.WithOpenAIOptions(
454+ provider.WithReasoningEffort(agentConfig.ReasoningEffort),
455+ ),
456+ )
457+ }
458+ agentProvider, err := provider.NewProvider(
459+ model.Provider,
460+ opts...,
446 )
461 )
447 if err != nil {
462 if err != nil {
448 return nil, fmt.Errorf("could not create provider: %v", err)
463 return nil, fmt.Errorf("could not create provider: %v", err)
internal/llm/agent/tools.go+1−2
@@ -31,10 +31,9 @@ func CoderAgentTools(
31 tools.NewGlobTool(),
31 tools.NewGlobTool(),
32 tools.NewGrepTool(),
32 tools.NewGrepTool(),
33 tools.NewLsTool(),
33 tools.NewLsTool(),
34− // TODO: see if we want to use this tool
35− // tools.NewPatchTool(lspClients, permissions, history),
36 tools.NewSourcegraphTool(),
34 tools.NewSourcegraphTool(),
37 tools.NewViewTool(lspClients),
35 tools.NewViewTool(lspClients),
36+ tools.NewPatchTool(lspClients, permissions, history),
38 tools.NewWriteTool(lspClients, permissions, history),
37 tools.NewWriteTool(lspClients, permissions, history),
39 NewAgentTool(sessions, messages, lspClients),
38 NewAgentTool(sessions, messages, lspClients),
40 }, otherTools...,
39 }, otherTools...,
internal/llm/models/anthropic.go+6
@@ -23,6 +23,7 @@ var AnthropicModels = map[ModelID]Model{
23 CostPer1MOutCached: 0.30,
23 CostPer1MOutCached: 0.30,
24 CostPer1MOut: 15.0,
24 CostPer1MOut: 15.0,
25 ContextWindow: 200000,
25 ContextWindow: 200000,
26+ DefaultMaxTokens: 5000,
26 },
27 },
27 Claude3Haiku: {
28 Claude3Haiku: {
28 ID: Claude3Haiku,
29 ID: Claude3Haiku,
@@ -34,6 +35,7 @@ var AnthropicModels = map[ModelID]Model{
34 CostPer1MOutCached: 0.03,
35 CostPer1MOutCached: 0.03,
35 CostPer1MOut: 1.25,
36 CostPer1MOut: 1.25,
36 ContextWindow: 200000,
37 ContextWindow: 200000,
38+ DefaultMaxTokens: 5000,
37 },
39 },
38 Claude37Sonnet: {
40 Claude37Sonnet: {
39 ID: Claude37Sonnet,
41 ID: Claude37Sonnet,
@@ -45,6 +47,8 @@ var AnthropicModels = map[ModelID]Model{
45 CostPer1MOutCached: 0.30,
47 CostPer1MOutCached: 0.30,
46 CostPer1MOut: 15.0,
48 CostPer1MOut: 15.0,
47 ContextWindow: 200000,
49 ContextWindow: 200000,
50+ DefaultMaxTokens: 50000,
51+ CanReason: true,
48 },
52 },
49 Claude35Haiku: {
53 Claude35Haiku: {
50 ID: Claude35Haiku,
54 ID: Claude35Haiku,
@@ -56,6 +60,7 @@ var AnthropicModels = map[ModelID]Model{
56 CostPer1MOutCached: 0.08,
60 CostPer1MOutCached: 0.08,
57 CostPer1MOut: 4.0,
61 CostPer1MOut: 4.0,
58 ContextWindow: 200000,
62 ContextWindow: 200000,
63+ DefaultMaxTokens: 4096,
59 },
64 },
60 Claude3Opus: {
65 Claude3Opus: {
61 ID: Claude3Opus,
66 ID: Claude3Opus,
@@ -67,5 +72,6 @@ var AnthropicModels = map[ModelID]Model{
67 CostPer1MOutCached: 1.50,
72 CostPer1MOutCached: 1.50,
68 CostPer1MOut: 75.0,
73 CostPer1MOut: 75.0,
69 ContextWindow: 200000,
74 ContextWindow: 200000,
75+ DefaultMaxTokens: 4096,
70 },
76 },
71}
77}
internal/llm/models/models.go+4−60
@@ -17,15 +17,12 @@ type Model struct {
17 CostPer1MInCached float64 `json:"cost_per_1m_in_cached"`
17 CostPer1MInCached float64 `json:"cost_per_1m_in_cached"`
18 CostPer1MOutCached float64 `json:"cost_per_1m_out_cached"`
18 CostPer1MOutCached float64 `json:"cost_per_1m_out_cached"`
19 ContextWindow int64 `json:"context_window"`
19 ContextWindow int64 `json:"context_window"`
20+ DefaultMaxTokens int64 `json:"default_max_tokens"`
21+ CanReason bool `json:"can_reason"`
20}
22}
21
23
22// Model IDs
24// Model IDs
23−const (
24− // OpenAI
25− GPT4o ModelID = "gpt-4o"
26− GPT41 ModelID = "gpt-4.1"
27−
28− // GEMINI
25+const ( // GEMINI
29 GEMINI25 ModelID = "gemini-2.5"
26 GEMINI25 ModelID = "gemini-2.5"
30 GRMINI20Flash ModelID = "gemini-2.0-flash"
27 GRMINI20Flash ModelID = "gemini-2.0-flash"
31
28
@@ -37,7 +34,6 @@ const (
37)
34)
38
35
39const (
36const (
40− ProviderOpenAI ModelProvider = "openai"
41 ProviderBedrock ModelProvider = "bedrock"
37 ProviderBedrock ModelProvider = "bedrock"
42 ProviderGemini ModelProvider = "gemini"
38 ProviderGemini ModelProvider = "gemini"
43 ProviderGROQ ModelProvider = "groq"
39 ProviderGROQ ModelProvider = "groq"
@@ -47,59 +43,6 @@ const (
47)
43)
48
44
49var SupportedModels = map[ModelID]Model{
45var SupportedModels = map[ModelID]Model{
50− // // Anthropic
51− // Claude35Sonnet: {
52− // ID: Claude35Sonnet,
53− // Name: "Claude 3.5 Sonnet",
54− // Provider: ProviderAnthropic,
55− // APIModel: "claude-3-5-sonnet-latest",
56− // CostPer1MIn: 3.0,
57− // CostPer1MInCached: 3.75,
58− // CostPer1MOutCached: 0.30,
59− // CostPer1MOut: 15.0,
60− // },
61− // Claude3Haiku: {
62− // ID: Claude3Haiku,
63− // Name: "Claude 3 Haiku",
64− // Provider: ProviderAnthropic,
65− // APIModel: "claude-3-haiku-latest",
66− // CostPer1MIn: 0.80,
67− // CostPer1MInCached: 1,
68− // CostPer1MOutCached: 0.08,
69− // CostPer1MOut: 4,
70− // },
71− // Claude37Sonnet: {
72− // ID: Claude37Sonnet,
73− // Name: "Claude 3.7 Sonnet",
74− // Provider: ProviderAnthropic,
75− // APIModel: "claude-3-7-sonnet-latest",
76− // CostPer1MIn: 3.0,
77− // CostPer1MInCached: 3.75,
78− // CostPer1MOutCached: 0.30,
79− // CostPer1MOut: 15.0,
80− // },
81− //
82− // // OpenAI
83− GPT4o: {
84− ID: GPT4o,
85− Name: "GPT-4o",
86− Provider: ProviderOpenAI,
87− APIModel: "gpt-4.1",
88− CostPer1MIn: 2.00,
89− CostPer1MInCached: 0.50,
90− CostPer1MOutCached: 0,
91− CostPer1MOut: 8.00,
92− },
93− GPT41: {
94− ID: GPT41,
95− Name: "GPT-4.1",
96− Provider: ProviderOpenAI,
97− APIModel: "gpt-4.1",
98− CostPer1MIn: 2.00,
99− CostPer1MInCached: 0.50,
100− CostPer1MOutCached: 0,
101− CostPer1MOut: 8.00,
102− },
103 //
46 //
104 // // GEMINI
47 // // GEMINI
105 // GEMINI25: {
48 // GEMINI25: {
@@ -151,4 +94,5 @@ var SupportedModels = map[ModelID]Model{
151
94
152func init() {
95func init() {
153 maps.Copy(SupportedModels, AnthropicModels)
96 maps.Copy(SupportedModels, AnthropicModels)
97+ maps.Copy(SupportedModels, OpenAIModels)
154}
98}
internal/llm/models/openai.gonew file+169
@@ -0,0 +1,169 @@
1+package models
2+
3+const (
4+ ProviderOpenAI ModelProvider = "openai"
5+
6+ GPT41 ModelID = "gpt-4.1"
7+ GPT41Mini ModelID = "gpt-4.1-mini"
8+ GPT41Nano ModelID = "gpt-4.1-nano"
9+ GPT45Preview ModelID = "gpt-4.5-preview"
10+ GPT4o ModelID = "gpt-4o"
11+ GPT4oMini ModelID = "gpt-4o-mini"
12+ O1 ModelID = "o1"
13+ O1Pro ModelID = "o1-pro"
14+ O1Mini ModelID = "o1-mini"
15+ O3 ModelID = "o3"
16+ O3Mini ModelID = "o3-mini"
17+ O4Mini ModelID = "o4-mini"
18+)
19+
20+var OpenAIModels = map[ModelID]Model{
21+ GPT41: {
22+ ID: GPT41,
23+ Name: "GPT 4.1",
24+ Provider: ProviderOpenAI,
25+ APIModel: "gpt-4.1",
26+ CostPer1MIn: 2.00,
27+ CostPer1MInCached: 0.50,
28+ CostPer1MOutCached: 0.0,
29+ CostPer1MOut: 8.00,
30+ ContextWindow: 1_047_576,
31+ DefaultMaxTokens: 20000,
32+ },
33+ GPT41Mini: {
34+ ID: GPT41Mini,
35+ Name: "GPT 4.1 mini",
36+ Provider: ProviderOpenAI,
37+ APIModel: "gpt-4.1",
38+ CostPer1MIn: 0.40,
39+ CostPer1MInCached: 0.10,
40+ CostPer1MOutCached: 0.0,
41+ CostPer1MOut: 1.60,
42+ ContextWindow: 200_000,
43+ DefaultMaxTokens: 20000,
44+ },
45+ GPT41Nano: {
46+ ID: GPT41Nano,
47+ Name: "GPT 4.1 nano",
48+ Provider: ProviderOpenAI,
49+ APIModel: "gpt-4.1-nano",
50+ CostPer1MIn: 0.10,
51+ CostPer1MInCached: 0.025,
52+ CostPer1MOutCached: 0.0,
53+ CostPer1MOut: 0.40,
54+ ContextWindow: 1_047_576,
55+ DefaultMaxTokens: 20000,
56+ },
57+ GPT45Preview: {
58+ ID: GPT45Preview,
59+ Name: "GPT 4.5 preview",
60+ Provider: ProviderOpenAI,
61+ APIModel: "gpt-4.5-preview",
62+ CostPer1MIn: 75.00,
63+ CostPer1MInCached: 37.50,
64+ CostPer1MOutCached: 0.0,
65+ CostPer1MOut: 150.00,
66+ ContextWindow: 128_000,
67+ DefaultMaxTokens: 15000,
68+ },
69+ GPT4o: {
70+ ID: GPT4o,
71+ Name: "GPT 4o",
72+ Provider: ProviderOpenAI,
73+ APIModel: "gpt-4o",
74+ CostPer1MIn: 2.50,
75+ CostPer1MInCached: 1.25,
76+ CostPer1MOutCached: 0.0,
77+ CostPer1MOut: 10.00,
78+ ContextWindow: 128_000,
79+ DefaultMaxTokens: 4096,
80+ },
81+ GPT4oMini: {
82+ ID: GPT4oMini,
83+ Name: "GPT 4o mini",
84+ Provider: ProviderOpenAI,
85+ APIModel: "gpt-4o-mini",
86+ CostPer1MIn: 0.15,
87+ CostPer1MInCached: 0.075,
88+ CostPer1MOutCached: 0.0,
89+ CostPer1MOut: 0.60,
90+ ContextWindow: 128_000,
91+ },
92+ O1: {
93+ ID: O1,
94+ Name: "O1",
95+ Provider: ProviderOpenAI,
96+ APIModel: "o1",
97+ CostPer1MIn: 15.00,
98+ CostPer1MInCached: 7.50,
99+ CostPer1MOutCached: 0.0,
100+ CostPer1MOut: 60.00,
101+ ContextWindow: 200_000,
102+ DefaultMaxTokens: 50000,
103+ CanReason: true,
104+ },
105+ O1Pro: {
106+ ID: O1Pro,
107+ Name: "o1 pro",
108+ Provider: ProviderOpenAI,
109+ APIModel: "o1-pro",
110+ CostPer1MIn: 150.00,
111+ CostPer1MInCached: 0.0,
112+ CostPer1MOutCached: 0.0,
113+ CostPer1MOut: 600.00,
114+ ContextWindow: 200_000,
115+ DefaultMaxTokens: 50000,
116+ CanReason: true,
117+ },
118+ O1Mini: {
119+ ID: O1Mini,
120+ Name: "o1 mini",
121+ Provider: ProviderOpenAI,
122+ APIModel: "o1-mini",
123+ CostPer1MIn: 1.10,
124+ CostPer1MInCached: 0.55,
125+ CostPer1MOutCached: 0.0,
126+ CostPer1MOut: 4.40,
127+ ContextWindow: 128_000,
128+ DefaultMaxTokens: 50000,
129+ CanReason: true,
130+ },
131+ O3: {
132+ ID: O3,
133+ Name: "o3",
134+ Provider: ProviderOpenAI,
135+ APIModel: "o3",
136+ CostPer1MIn: 10.00,
137+ CostPer1MInCached: 2.50,
138+ CostPer1MOutCached: 0.0,
139+ CostPer1MOut: 40.00,
140+ ContextWindow: 200_000,
141+ CanReason: true,
142+ },
143+ O3Mini: {
144+ ID: O3Mini,
145+ Name: "o3 mini",
146+ Provider: ProviderOpenAI,
147+ APIModel: "o3-mini",
148+ CostPer1MIn: 1.10,
149+ CostPer1MInCached: 0.55,
150+ CostPer1MOutCached: 0.0,
151+ CostPer1MOut: 4.40,
152+ ContextWindow: 200_000,
153+ DefaultMaxTokens: 50000,
154+ CanReason: true,
155+ },
156+ O4Mini: {
157+ ID: O4Mini,
158+ Name: "o4 mini",
159+ Provider: ProviderOpenAI,
160+ APIModel: "o4-mini",
161+ CostPer1MIn: 1.10,
162+ CostPer1MInCached: 0.275,
163+ CostPer1MOutCached: 0.0,
164+ CostPer1MOut: 4.40,
165+ ContextWindow: 128_000,
166+ DefaultMaxTokens: 50000,
167+ CanReason: true,
168+ },
169+}
internal/llm/prompt/coder.go+44−39
@@ -25,44 +25,49 @@ func CoderPrompt(provider models.ModelProvider) string {
25}
25}
26
26
27const baseOpenAICoderPrompt = `
27const baseOpenAICoderPrompt = `
28−You are **OpenCode**, an autonomous CLI assistant for software‑engineering tasks.
28+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.
29−
29+
30−### ── INTERNAL REFLECTION ──
30+You can:
31−• Silently think step‑by‑step about the user request, directory layout, and tool calls (never reveal this).
31+- Receive user prompts, project context, and files.
32−• Formulate a plan, then execute without further approval unless a blocker triggers the Ask‑Only‑If rules.
32+- Stream responses and emit function calls (e.g., shell commands, code edits).
33−
33+- Apply patches, run commands, and manage user approvals based on policy.
34−### ── PUBLIC RESPONSE RULES ──
34+- Work inside a sandboxed, git-backed workspace with rollback support.
35−• Visible reply ≤ 4 lines; no fluff, preamble, or postamble.
35+- Log telemetry so sessions can be replayed or inspected later.
36−• Use GitHub‑flavored Markdown.
36+- More details on your functionality are available at "opencode --help"
37−• When running a non‑trivial shell command, add ≤ 1 brief purpose sentence.
37+
38−
38+
39−### ── CONTEXT & MEMORY ──
39+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.
40−• Infer file intent from directory structure before editing.
40+
41−• Auto‑load 'OpenCode.md'; ask once before writing new reusable commands or style notes.
41+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.
42−
42+
43−### ── AUTONOMY PRIORITY ──
43+You MUST adhere to the following criteria when executing the task:
44−**Ask‑Only‑If Decision Tree:**
44+- Working on the repo(s) in the current environment is allowed, even if they are proprietary.
45−1. **Safety risk?** (e.g., destructive command, secret exposure) → ask.
45+- Analyzing code for vulnerabilities is allowed.
46−2. **Critical unknown?** (no docs/tests; cannot infer) → ask.
46+- Showing user code and tool call details is allowed.
47−3. **Tool failure after two self‑attempts?** → ask.
47+- User instructions may overwrite the *CODING GUIDELINES* section in this developer message.
48−Otherwise, proceed autonomously.
48+- If completing the user's task requires writing or modifying files:
49−
49+ - Your code and final answer should follow these *CODING GUIDELINES*:
50−### ── SAFETY & STYLE ──
50+ - Fix the problem at the root cause rather than applying surface-level patches, when possible.
51−• Mimic existing code style; verify libraries exist before import.
51+ - Avoid unneeded complexity in your solution.
52−• Never commit unless explicitly told.
52+ - Ignore unrelated bugs or broken tests; it is not your responsibility to fix them.
53−• After edits, run lint & type‑check (ask for commands once, then offer to store in 'OpenCode.md').
53+ - Update documentation as necessary.
54−• Protect secrets; follow standard security practices :contentReference[oaicite:2]{index=2}.
54+ - Keep changes consistent with the style of the existing codebase. Changes should be minimal and focused on the task.
55−
55+ - Use "git log" and "git blame" to search the history of the codebase if additional context is required; internet access is disabled.
56−### ── TOOL USAGE ──
56+ - NEVER add copyright or license headers unless specifically requested.
57−• Batch independent Agent search/file calls in one block for efficiency :contentReference[oaicite:3]{index=3}.
57+ - You do not need to "git commit" your changes; this will be done automatically for you.
58−• Communicate with the user only via visible text; do not expose tool output or internal reasoning.
58+ - Once you finish coding, you must
59−
59+ - Check "git status" to sanity check your changes; revert any scratch files or changes.
60−### ── EXAMPLES ──
60+ - 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.
61−user: list files
61+ - Check if you accidentally add copyright or license headers. If so, remove them.
62−assistant: ls
62+ - For smaller tasks, describe in brief bullet points
63−
63+ - For more complex tasks, include brief high-level description, use bullet points, and include details that would be relevant to a code reviewer.
64−user: write tests for new feature
64+- If completing the user's task DOES NOT require writing or modifying files (e.g., the user asks a question about the code base):
65−assistant: [searches & edits autonomously, no extra chit‑chat]
65+ - Respond in a friendly tune as a remote teammate, who is knowledgeable, capable and eager to help with coding.
66+- When your task involves writing or modifying files:
67+ - 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.
68+ - Do NOT show the full contents of large files you have already written, unless the user explicitly asks for them.
69+- 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.
70+- If you send a path not including the working dir, the working dir will be prepended to it.
66`
71`
67
72
68const 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.
73const 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
125
130
126<example>
131<example>
127user: write tests for new feature
132user: write tests for new feature
128−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]
133+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]
129</example>
134</example>
130
135
131# Proactiveness
136# Proactiveness
internal/llm/provider/openai.go+41−8
@@ -14,11 +14,13 @@ import (
14 "github.com/kujtimiihoxha/opencode/internal/message"
14 "github.com/kujtimiihoxha/opencode/internal/message"
15 "github.com/openai/openai-go"
15 "github.com/openai/openai-go"
16 "github.com/openai/openai-go/option"
16 "github.com/openai/openai-go/option"
17+ "github.com/openai/openai-go/shared"
17)
18)
18
19
19type openaiOptions struct {
20type openaiOptions struct {
20− baseURL string
21+ baseURL string
21− disableCache bool
22+ disableCache bool
23+ reasoningEffort string
22}
24}
23
25
24type OpenAIOption func(*openaiOptions)
26type OpenAIOption func(*openaiOptions)
@@ -32,7 +34,9 @@ type openaiClient struct {
32type OpenAIClient ProviderClient
34type OpenAIClient ProviderClient
33
35
34func newOpenAIClient(opts providerClientOptions) OpenAIClient {
36func newOpenAIClient(opts providerClientOptions) OpenAIClient {
35− openaiOpts := openaiOptions{}
37+ openaiOpts := openaiOptions{
38+ reasoningEffort: "medium",
39+ }
36 for _, o := range opts.openaiOptions {
40 for _, o := range opts.openaiOptions {
37 o(&openaiOpts)
41 o(&openaiOpts)
38 }
42 }
@@ -138,12 +142,29 @@ func (o *openaiClient) finishReason(reason string) message.FinishReason {
138}
142}
139
143
140func (o *openaiClient) preparedParams(messages []openai.ChatCompletionMessageParamUnion, tools []openai.ChatCompletionToolParam) openai.ChatCompletionNewParams {
144func (o *openaiClient) preparedParams(messages []openai.ChatCompletionMessageParamUnion, tools []openai.ChatCompletionToolParam) openai.ChatCompletionNewParams {
141− return openai.ChatCompletionNewParams{
145+ params := openai.ChatCompletionNewParams{
142− Model: openai.ChatModel(o.providerOptions.model.APIModel),
146+ Model: openai.ChatModel(o.providerOptions.model.APIModel),
143− Messages: messages,
147+ Messages: messages,
144− MaxTokens: openai.Int(o.providerOptions.maxTokens),
145− Tools: tools,
148+ Tools: tools,
146 }
149 }
150+
151+ if o.providerOptions.model.CanReason == true {
152+ params.MaxCompletionTokens = openai.Int(o.providerOptions.maxTokens)
153+ switch o.options.reasoningEffort {
154+ case "low":
155+ params.ReasoningEffort = shared.ReasoningEffortLow
156+ case "medium":
157+ params.ReasoningEffort = shared.ReasoningEffortMedium
158+ case "high":
159+ params.ReasoningEffort = shared.ReasoningEffortHigh
160+ default:
161+ params.ReasoningEffort = shared.ReasoningEffortMedium
162+ }
163+ } else {
164+ params.MaxTokens = openai.Int(o.providerOptions.maxTokens)
165+ }
166+
167+ return params
147}
168}
148
169
149func (o *openaiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) {
170func (o *openaiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) {
@@ -359,3 +380,15 @@ func WithOpenAIDisableCache() OpenAIOption {
359 }
380 }
360}
381}
361
382
383+func WithReasoningEffort(effort string) OpenAIOption {
384+ return func(options *openaiOptions) {
385+ defaultReasoningEffort := "medium"
386+ switch effort {
387+ case "low", "medium", "high":
388+ defaultReasoningEffort = effort
389+ default:
390+ logging.Warn("Invalid reasoning effort, using default: medium")
391+ }
392+ options.reasoningEffort = defaultReasoningEffort
393+ }
394+}
internal/llm/tools/glob.go+37−1
@@ -192,6 +192,42 @@ func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) {
192}
192}
193
193
194func skipHidden(path string) bool {
194func skipHidden(path string) bool {
195+ // Check for hidden files (starting with a dot)
195 base := filepath.Base(path)
196 base := filepath.Base(path)
196− return base != "." && strings.HasPrefix(base, ".")
197+ if base != "." && strings.HasPrefix(base, ".") {
198+ return true
199+ }
200+
201+ // List of commonly ignored directories in development projects
202+ commonIgnoredDirs := map[string]bool{
203+ "node_modules": true,
204+ "vendor": true,
205+ "dist": true,
206+ "build": true,
207+ "target": true,
208+ ".git": true,
209+ ".idea": true,
210+ ".vscode": true,
211+ "__pycache__": true,
212+ "bin": true,
213+ "obj": true,
214+ "out": true,
215+ "coverage": true,
216+ "tmp": true,
217+ "temp": true,
218+ "logs": true,
219+ "generated": true,
220+ "bower_components": true,
221+ "jspm_packages": true,
222+ }
223+
224+ // Check if any path component is in our ignore list
225+ parts := strings.SplitSeq(path, string(os.PathSeparator))
226+ for part := range parts {
227+ if commonIgnoredDirs[part] {
228+ return true
229+ }
230+ }
231+
232+ return false
197}
233}
internal/llm/tools/grep.go+31−6
@@ -17,9 +17,10 @@ import (
17)
17)
18
18
19type GrepParams struct {
19type GrepParams struct {
20− Pattern string `json:"pattern"`
20+ Pattern string `json:"pattern"`
21− Path string `json:"path"`
21+ Path string `json:"path"`
22− Include string `json:"include"`
22+ Include string `json:"include"`
23+ LiteralText bool `json:"literal_text"`
23}
24}
24
25
25type grepMatch struct {
26type grepMatch struct {
@@ -45,11 +46,12 @@ WHEN TO USE THIS TOOL:
45
46
46HOW TO USE:
47HOW TO USE:
47- Provide a regex pattern to search for within file contents
48- Provide a regex pattern to search for within file contents
49+- Set literal_text=true if you want to search for the exact text with special characters (recommended for non-regex users)
48- Optionally specify a starting directory (defaults to current working directory)
50- Optionally specify a starting directory (defaults to current working directory)
49- Optionally provide an include pattern to filter which files to search
51- Optionally provide an include pattern to filter which files to search
50- Results are sorted with most recently modified files first
52- Results are sorted with most recently modified files first
51
53
52−REGEX PATTERN SYNTAX:
54+REGEX PATTERN SYNTAX (when literal_text=false):
53- Supports standard regular expression syntax
55- Supports standard regular expression syntax
54- 'function' searches for the literal text "function"
56- 'function' searches for the literal text "function"
55- 'log\..*Error' finds text starting with "log." and ending with "Error"
57- 'log\..*Error' finds text starting with "log." and ending with "Error"
@@ -69,7 +71,8 @@ LIMITATIONS:
69TIPS:
71TIPS:
70- For faster, more targeted searches, first use Glob to find relevant files, then use Grep
72- For faster, more targeted searches, first use Glob to find relevant files, then use Grep
71- When doing iterative exploration that may require multiple rounds of searching, consider using the Agent tool instead
73- When doing iterative exploration that may require multiple rounds of searching, consider using the Agent tool instead
72−- Always check if results are truncated and refine your search pattern if needed`
74+- Always check if results are truncated and refine your search pattern if needed
75+- Use literal_text=true when searching for exact text containing special characters like dots, parentheses, etc.`
73)
76)
74
77
75func NewGrepTool() BaseTool {
78func NewGrepTool() BaseTool {
@@ -93,11 +96,27 @@ func (g *grepTool) Info() ToolInfo {
93 "type": "string",
96 "type": "string",
94 "description": "File pattern to include in the search (e.g. \"*.js\", \"*.{ts,tsx}\")",
97 "description": "File pattern to include in the search (e.g. \"*.js\", \"*.{ts,tsx}\")",
95 },
98 },
99+ "literal_text": map[string]any{
100+ "type": "boolean",
101+ "description": "If true, the pattern will be treated as literal text with special regex characters escaped. Default is false.",
102+ },
96 },
103 },
97 Required: []string{"pattern"},
104 Required: []string{"pattern"},
98 }
105 }
99}
106}
100
107
108+// escapeRegexPattern escapes special regex characters so they're treated as literal characters
109+func escapeRegexPattern(pattern string) string {
110+ specialChars := []string{"\\", ".", "+", "*", "?", "(", ")", "[", "]", "{", "}", "^", "$", "|"}
111+ escaped := pattern
112+
113+ for _, char := range specialChars {
114+ escaped = strings.ReplaceAll(escaped, char, "\\"+char)
115+ }
116+
117+ return escaped
118+}
119+
101func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
120func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
102 var params GrepParams
121 var params GrepParams
103 if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
122 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)
108 return NewTextErrorResponse("pattern is required"), nil
127 return NewTextErrorResponse("pattern is required"), nil
109 }
128 }
110
129
130+ // If literal_text is true, escape the pattern
131+ searchPattern := params.Pattern
132+ if params.LiteralText {
133+ searchPattern = escapeRegexPattern(params.Pattern)
134+ }
135+
111 searchPath := params.Path
136 searchPath := params.Path
112 if searchPath == "" {
137 if searchPath == "" {
113 searchPath = config.WorkingDirectory()
138 searchPath = config.WorkingDirectory()
114 }
139 }
115
140
116− matches, truncated, err := searchFiles(params.Pattern, searchPath, params.Include, 100)
141+ matches, truncated, err := searchFiles(searchPattern, searchPath, params.Include, 100)
117 if err != nil {
142 if err != nil {
118 return ToolResponse{}, fmt.Errorf("error searching files: %w", err)
143 return ToolResponse{}, fmt.Errorf("error searching files: %w", err)
119 }
144 }
internal/llm/tools/patch.go+259−191
@@ -6,7 +6,6 @@ import (
6 "fmt"
6 "fmt"
7 "os"
7 "os"
8 "path/filepath"
8 "path/filepath"
9− "strings"
10 "time"
9 "time"
11
10
12 "github.com/kujtimiihoxha/opencode/internal/config"
11 "github.com/kujtimiihoxha/opencode/internal/config"
@@ -17,19 +16,13 @@ import (
17)
16)
18
17
19type PatchParams struct {
18type PatchParams struct {
20− FilePath string `json:"file_path"`
19+ PatchText string `json:"patch_text"`
21− Patch string `json:"patch"`
22−}
23−
24−type PatchPermissionsParams struct {
25− FilePath string `json:"file_path"`
26− Diff string `json:"diff"`
27}
20}
28
21
29type PatchResponseMetadata struct {
22type PatchResponseMetadata struct {
30− Diff string `json:"diff"`
23+ FilesChanged []string `json:"files_changed"`
31− Additions int `json:"additions"`
24+ Additions int `json:"additions"`
32− Removals int `json:"removals"`
25+ Removals int `json:"removals"`
33}
26}
34
27
35type patchTool struct {
28type patchTool struct {
@@ -39,47 +32,35 @@ type patchTool struct {
39}
32}
40
33
41const (
34const (
42− // TODO: test if this works as expected
43 PatchToolName = "patch"
35 PatchToolName = "patch"
44− 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.
36+ patchDescription = `Applies a patch to multiple files in one operation. This tool is useful for making coordinated changes across multiple files.
37+
38+The patch text must follow this format:
39+*** Begin Patch
40+*** Update File: /path/to/file
41+@@ Context line (unique within the file)
42+ Line to keep
43+-Line to remove
44++Line to add
45+ Line to keep
46+*** Add File: /path/to/new/file
47++Content of the new file
48++More content
49+*** Delete File: /path/to/file/to/delete
50+*** End Patch
45
51
46Before using this tool:
52Before using this tool:
47−
48−1. Use the FileRead tool to understand the file's contents and context
53+1. Use the FileRead tool to understand the files' contents and context
49−
54+2. Verify all file paths are correct (use the LS tool)
50−2. Verify the directory path is correct:
51− - Use the LS tool to verify the parent directory exists and is the correct location
52−
53−To apply a patch, provide the following:
54−1. file_path: The absolute path to the file to modify (must be absolute, not relative)
55−2. patch: A unified diff patch to apply to the file
56−
57−The tool will apply the patch to the specified file. The patch must be in unified diff format.
58
55
59CRITICAL REQUIREMENTS FOR USING THIS TOOL:
56CRITICAL REQUIREMENTS FOR USING THIS TOOL:
60
57
61−1. PATCH FORMAT: The patch must be in unified diff format, which includes:
58+1. UNIQUENESS: Context lines MUST uniquely identify the specific sections you want to change
62− - File headers (--- a/file_path, +++ b/file_path)
59+2. PRECISION: All whitespace, indentation, and surrounding code must match exactly
63− - Hunk headers (@@ -start,count +start,count @@)
64− - Added lines (prefixed with +)
65− - Removed lines (prefixed with -)
66−
67−2. CONTEXT: The patch must include sufficient context around the changes to ensure it applies correctly.
68−
69−3. VERIFICATION: Before using this tool:
70− - Ensure the patch applies cleanly to the current state of the file
71− - Check that the file exists and you have read it first
72−
73−WARNING: If you do not follow these requirements:
74− - The tool will fail if the patch doesn't apply cleanly
75− - You may change the wrong parts of the file if the context is insufficient
76−
77−When applying patches:
78− - Ensure the patch results in idiomatic, correct code
60+3. VALIDATION: Ensure edits result in idiomatic, correct code
79− - Do not leave the code in a broken state
80− - Always use absolute file paths (starting with /)
61+4. PATHS: Always use absolute file paths (starting with /)
81
62
82−Remember: patches are a powerful way to make multiple related changes at once, but they require careful preparation.`
63+The tool will apply all changes in a single atomic operation.`
83)
64)
84
65
85func NewPatchTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service) BaseTool {
66func NewPatchTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service) BaseTool {
@@ -95,16 +76,12 @@ func (p *patchTool) Info() ToolInfo {
95 Name: PatchToolName,
76 Name: PatchToolName,
96 Description: patchDescription,
77 Description: patchDescription,
97 Parameters: map[string]any{
78 Parameters: map[string]any{
98− "file_path": map[string]any{
79+ "patch_text": map[string]any{
99 "type": "string",
80 "type": "string",
100− "description": "The absolute path to the file to modify",
81+ "description": "The full patch text that describes all changes to be made",
101− },
102− "patch": map[string]any{
103− "type": "string",
104− "description": "The unified diff patch to apply",
105 },
82 },
106 },
83 },
107− Required: []string{"file_path", "patch"},
84+ Required: []string{"patch_text"},
108 }
85 }
109}
86}
110
87
@@ -114,187 +91,278 @@ func (p *patchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
114 return NewTextErrorResponse("invalid parameters"), nil
91 return NewTextErrorResponse("invalid parameters"), nil
115 }
92 }
116
93
117− if params.FilePath == "" {
94+ if params.PatchText == "" {
118− return NewTextErrorResponse("file_path is required"), nil
95+ return NewTextErrorResponse("patch_text is required"), nil
119 }
96 }
120
97
121− if params.Patch == "" {
98+ // Identify all files needed for the patch and verify they've been read
122− return NewTextErrorResponse("patch is required"), nil
99+ filesToRead := diff.IdentifyFilesNeeded(params.PatchText)
100+ for _, filePath := range filesToRead {
101+ absPath := filePath
102+ if !filepath.IsAbs(absPath) {
103+ wd := config.WorkingDirectory()
104+ absPath = filepath.Join(wd, absPath)
123− }
105+ }
124
106
125− if !filepath.IsAbs(params.FilePath) {
107+ if getLastReadTime(absPath).IsZero() {
126− wd := config.WorkingDirectory()
108+ return NewTextErrorResponse(fmt.Sprintf("you must read the file %s before patching it. Use the FileRead tool first", filePath)), nil
127− params.FilePath = filepath.Join(wd, params.FilePath)
128− }
109+ }
129
110
130− // Check if file exists
131− fileInfo, err := os.Stat(params.FilePath)
111+ fileInfo, err := os.Stat(absPath)
132− if err != nil {
112+ if err != nil {
133− if os.IsNotExist(err) {
113+ if os.IsNotExist(err) {
134− return NewTextErrorResponse(fmt.Sprintf("file not found: %s", params.FilePath)), nil
114+ return NewTextErrorResponse(fmt.Sprintf("file not found: %s", absPath)), nil
115+ }
116+ return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
135 }
117 }
136− return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
137− }
138
118
139− if fileInfo.IsDir() {
119+ if fileInfo.IsDir() {
140− return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil
120+ return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", absPath)), nil
141− }
121+ }
142
122
143− if getLastReadTime(params.FilePath).IsZero() {
123+ modTime := fileInfo.ModTime()
124+ lastRead := getLastReadTime(absPath)
125+ if modTime.After(lastRead) {
144− return NewTextErrorResponse("you must read the file before patching it. Use the View tool first"), nil
126+ return NewTextErrorResponse(
127+ fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
128+ absPath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
129+ )), nil
130+ }
145 }
131 }
146
132
147− modTime := fileInfo.ModTime()
133+ // Check for new files to ensure they don't already exist
148− lastRead := getLastReadTime(params.FilePath)
134+ filesToAdd := diff.IdentifyFilesAdded(params.PatchText)
149− if modTime.After(lastRead) {
135+ for _, filePath := range filesToAdd {
150− return NewTextErrorResponse(
136+ absPath := filePath
151− fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
137+ if !filepath.IsAbs(absPath) {
152− params.FilePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
138+ wd := config.WorkingDirectory()
153− )), nil
139+ absPath = filepath.Join(wd, absPath)
154− }
140+ }
155
141
156− // Read the current file content
142+ _, err := os.Stat(absPath)
157− content, err := os.ReadFile(params.FilePath)
158− if err != nil {
143+ if err == nil {
144+ return NewTextErrorResponse(fmt.Sprintf("file already exists and cannot be added: %s", absPath)), nil
145+ } else if !os.IsNotExist(err) {
159− return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
146+ return ToolResponse{}, fmt.Errorf("failed to check file: %w", err)
147+ }
160 }
148 }
161
149
162− oldContent := string(content)
150+ // Load all required files
151+ currentFiles := make(map[string]string)
152+ for _, filePath := range filesToRead {
153+ absPath := filePath
154+ if !filepath.IsAbs(absPath) {
155+ wd := config.WorkingDirectory()
156+ absPath = filepath.Join(wd, absPath)
157+ }
163
158
164− // Parse and apply the patch
159+ content, err := os.ReadFile(absPath)
165− diffResult, err := diff.ParseUnifiedDiff(params.Patch)
166− if err != nil {
160+ if err != nil {
167− return NewTextErrorResponse(fmt.Sprintf("failed to parse patch: %v", err)), nil
161+ return ToolResponse{}, fmt.Errorf("failed to read file %s: %w", absPath, err)
162+ }
163+ currentFiles[filePath] = string(content)
168 }
164 }
169
165
170− // Apply the patch to get the new content
166+ // Process the patch
171− newContent, err := applyPatch(oldContent, diffResult)
167+ patch, fuzz, err := diff.TextToPatch(params.PatchText, currentFiles)
172 if err != nil {
168 if err != nil {
173− return NewTextErrorResponse(fmt.Sprintf("failed to apply patch: %v", err)), nil
169+ return NewTextErrorResponse(fmt.Sprintf("failed to parse patch: %s", err)), nil
174 }
170 }
175
171
176− if oldContent == newContent {
172+ if fuzz > 0 {
177− return NewTextErrorResponse("patch did not result in any changes to the file"), nil
173+ return NewTextErrorResponse(fmt.Sprintf("patch contains fuzzy matches (fuzz level: %d). Please make your context lines more precise", fuzz)), nil
178 }
174 }
179
175
176+ // Convert patch to commit
177+ commit, err := diff.PatchToCommit(patch, currentFiles)
178+ if err != nil {
179+ return NewTextErrorResponse(fmt.Sprintf("failed to create commit from patch: %s", err)), nil
180+ }
181+
182+ // Get session ID and message ID
180 sessionID, messageID := GetContextValues(ctx)
183 sessionID, messageID := GetContextValues(ctx)
181 if sessionID == "" || messageID == "" {
184 if sessionID == "" || messageID == "" {
182− return ToolResponse{}, fmt.Errorf("session ID and message ID are required for patching a file")
185+ return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a patch")
183 }
186 }
184
187
185− // Generate a diff for permission request and metadata
188+ // Request permission for all changes
186− diffText, additions, removals := diff.GenerateDiff(
189+ for path, change := range commit.Changes {
187− oldContent,
190+ switch change.Type {
188− newContent,
191+ case diff.ActionAdd:
189− params.FilePath,
192+ dir := filepath.Dir(path)
190− )
193+ patchDiff, _, _ := diff.GenerateDiff("", *change.NewContent, path)
191−
192− // Request permission to apply the patch
193− p.permissions.Request(
194+ p := p.permissions.Request(
194− permission.CreatePermissionRequest{
195+ permission.CreatePermissionRequest{
195− Path: filepath.Dir(params.FilePath),
196+ Path: dir,
196− ToolName: PatchToolName,
197+ ToolName: PatchToolName,
197− Action: "patch",
198+ Action: "create",
198− Description: fmt.Sprintf("Apply patch to file %s", params.FilePath),
199+ Description: fmt.Sprintf("Create file %s", path),
199− Params: PatchPermissionsParams{
200+ Params: EditPermissionsParams{
200− FilePath: params.FilePath,
201+ FilePath: path,
201− Diff: diffText,
202+ Diff: patchDiff,
202− },
203+ },
203− },
204+ },
204− )
205+ )
205−
206+ if !p {
206− // Write the new content to the file
207+ return ToolResponse{}, permission.ErrorPermissionDenied
207− err = os.WriteFile(params.FilePath, []byte(newContent), 0o644)
208+ }
208− if err != nil {
209+ case diff.ActionUpdate:
209− return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
210+ currentContent := ""
211+ if change.OldContent != nil {
212+ currentContent = *change.OldContent
213+ }
214+ newContent := ""
215+ if change.NewContent != nil {
216+ newContent = *change.NewContent
217+ }
218+ patchDiff, _, _ := diff.GenerateDiff(currentContent, newContent, path)
219+ dir := filepath.Dir(path)
220+ p := p.permissions.Request(
221+ permission.CreatePermissionRequest{
222+ Path: dir,
223+ ToolName: PatchToolName,
224+ Action: "update",
225+ Description: fmt.Sprintf("Update file %s", path),
226+ Params: EditPermissionsParams{
227+ FilePath: path,
228+ Diff: patchDiff,
229+ },
230+ },
231+ )
232+ if !p {
233+ return ToolResponse{}, permission.ErrorPermissionDenied
234+ }
235+ case diff.ActionDelete:
236+ dir := filepath.Dir(path)
237+ patchDiff, _, _ := diff.GenerateDiff(*change.OldContent, "", path)
238+ p := p.permissions.Request(
239+ permission.CreatePermissionRequest{
240+ Path: dir,
241+ ToolName: PatchToolName,
242+ Action: "delete",
243+ Description: fmt.Sprintf("Delete file %s", path),
244+ Params: EditPermissionsParams{
245+ FilePath: path,
246+ Diff: patchDiff,
247+ },
248+ },
249+ )
250+ if !p {
251+ return ToolResponse{}, permission.ErrorPermissionDenied
252+ }
253+ }
210 }
254 }
211
255
212− // Update file history
256+ // Apply the changes to the filesystem
213− file, err := p.files.GetByPathAndSession(ctx, params.FilePath, sessionID)
257+ err = diff.ApplyCommit(commit, func(path string, content string) error {
214− if err != nil {
258+ absPath := path
215− _, err = p.files.Create(ctx, sessionID, params.FilePath, oldContent)
259+ if !filepath.IsAbs(absPath) {
216− if err != nil {
260+ wd := config.WorkingDirectory()
217− return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
261+ absPath = filepath.Join(wd, absPath)
218 }
262 }
219− }
263+
220− if file.Content != oldContent {
264+ // Create parent directories if needed
221− // User manually changed the content, store an intermediate version
265+ dir := filepath.Dir(absPath)
222− _, err = p.files.CreateVersion(ctx, sessionID, params.FilePath, oldContent)
266+ if err := os.MkdirAll(dir, 0o755); err != nil {
223− if err != nil {
267+ return fmt.Errorf("failed to create parent directories for %s: %w", absPath, err)
224− fmt.Printf("Error creating file history version: %v\n", err)
225 }
268 }
226− }
269+
227− // Store the new version
270+ return os.WriteFile(absPath, []byte(content), 0o644)
228− _, err = p.files.CreateVersion(ctx, sessionID, params.FilePath, newContent)
271+ }, func(path string) error {
272+ absPath := path
273+ if !filepath.IsAbs(absPath) {
274+ wd := config.WorkingDirectory()
275+ absPath = filepath.Join(wd, absPath)
276+ }
277+ return os.Remove(absPath)
278+ })
229 if err != nil {
279 if err != nil {
230− fmt.Printf("Error creating file history version: %v\n", err)
280+ return NewTextErrorResponse(fmt.Sprintf("failed to apply patch: %s", err)), nil
231 }
281 }
232
282
233− recordFileWrite(params.FilePath)
283+ // Update file history for all modified files
234− recordFileRead(params.FilePath)
284+ changedFiles := []string{}
285+ totalAdditions := 0
286+ totalRemovals := 0
235
287
236− // Wait for LSP diagnostics and include them in the response
288+ for path, change := range commit.Changes {
237− waitForLspDiagnostics(ctx, params.FilePath, p.lspClients)
289+ absPath := path
238− text := fmt.Sprintf("<r>\nPatch applied to file: %s\n</r>\n", params.FilePath)
290+ if !filepath.IsAbs(absPath) {
239− text += getDiagnostics(params.FilePath, p.lspClients)
291+ wd := config.WorkingDirectory()
292+ absPath = filepath.Join(wd, absPath)
293+ }
294+ changedFiles = append(changedFiles, absPath)
240
295
241− return WithResponseMetadata(
296+ oldContent := ""
242− NewTextResponse(text),
297+ if change.OldContent != nil {
243− PatchResponseMetadata{
298+ oldContent = *change.OldContent
244− Diff: diffText,
245− Additions: additions,
246− Removals: removals,
247− }), nil
248−}
299+ }
249
300
250−// applyPatch applies a parsed diff to a string and returns the resulting content
301+ newContent := ""
251−func applyPatch(content string, diffResult diff.DiffResult) (string, error) {
302+ if change.NewContent != nil {
252− lines := strings.Split(content, "\n")
303+ newContent = *change.NewContent
304+ }
253
305
254− // Process each hunk in the diff
306+ // Calculate diff statistics
255− for _, hunk := range diffResult.Hunks {
307+ _, additions, removals := diff.GenerateDiff(oldContent, newContent, path)
256− // Parse the hunk header to get line numbers
308+ totalAdditions += additions
257− var oldStart, oldCount, newStart, newCount int
309+ totalRemovals += removals
258− _, err := fmt.Sscanf(hunk.Header, "@@ -%d,%d +%d,%d @@", &oldStart, &oldCount, &newStart, &newCount)
310+
311+ // Update history
312+ file, err := p.files.GetByPathAndSession(ctx, absPath, sessionID)
259− if err != nil {
313+ if err != nil && change.Type != diff.ActionAdd {
260− // Try alternative format with single line counts
314+ // If not adding a file, create history entry for existing file
261− _, err = fmt.Sscanf(hunk.Header, "@@ -%d +%d @@", &oldStart, &newStart)
315+ _, err = p.files.Create(ctx, sessionID, absPath, oldContent)
262 if err != nil {
316 if err != nil {
263− return "", fmt.Errorf("invalid hunk header format: %s", hunk.Header)
317+ fmt.Printf("Error creating file history: %v\n", err)
264 }
318 }
265− oldCount = 1
266− newCount = 1
267 }
319 }
268
320
269− // Adjust for 0-based array indexing
321+ if err == nil && change.Type != diff.ActionAdd && file.Content != oldContent {
270− oldStart--
322+ // User manually changed content, store intermediate version
271− newStart--
323+ _, err = p.files.CreateVersion(ctx, sessionID, absPath, oldContent)
272−
324+ if err != nil {
273− // Apply the changes
325+ fmt.Printf("Error creating file history version: %v\n", err)
274− newLines := make([]string, 0)
275− newLines = append(newLines, lines[:oldStart]...)
276−
277− // Process the hunk lines in order
278− currentOldLine := oldStart
279− for _, line := range hunk.Lines {
280− switch line.Kind {
281− case diff.LineContext:
282− newLines = append(newLines, line.Content)
283− currentOldLine++
284− case diff.LineRemoved:
285− // Skip this line in the output (it's being removed)
286− currentOldLine++
287− case diff.LineAdded:
288− // Add the new line
289− newLines = append(newLines, line.Content)
290 }
326 }
291 }
327 }
292
328
293− // Append the rest of the file
329+ // Store new version
294− newLines = append(newLines, lines[currentOldLine:]...)
330+ if change.Type == diff.ActionDelete {
295− lines = newLines
331+ _, err = p.files.CreateVersion(ctx, sessionID, absPath, "")
332+ } else {
333+ _, err = p.files.CreateVersion(ctx, sessionID, absPath, newContent)
334+ }
335+ if err != nil {
336+ fmt.Printf("Error creating file history version: %v\n", err)
337+ }
338+
339+ // Record file operations
340+ recordFileWrite(absPath)
341+ recordFileRead(absPath)
296 }
342 }
297
343
298− return strings.Join(lines, "\n"), nil
344+ // Run LSP diagnostics on all changed files
345+ for _, filePath := range changedFiles {
346+ waitForLspDiagnostics(ctx, filePath, p.lspClients)
299−}
347+ }
300
348
349+ result := fmt.Sprintf("Patch applied successfully. %d files changed, %d additions, %d removals",
350+ len(changedFiles), totalAdditions, totalRemovals)
351+
352+ diagnosticsText := ""
353+ for _, filePath := range changedFiles {
354+ diagnosticsText += getDiagnostics(filePath, p.lspClients)
355+ }
356+
357+ if diagnosticsText != "" {
358+ result += "\n\nDiagnostics:\n" + diagnosticsText
359+ }
360+
361+ return WithResponseMetadata(
362+ NewTextResponse(result),
363+ PatchResponseMetadata{
364+ FilesChanged: changedFiles,
365+ Additions: totalAdditions,
366+ Removals: totalRemovals,
367+ }), nil
368+}
internal/llm/tools/view.go+12−1
@@ -24,6 +24,11 @@ type viewTool struct {
24 lspClients map[string]*lsp.Client
24 lspClients map[string]*lsp.Client
25}
25}
26
26
27+type ViewResponseMetadata struct {
28+ FilePath string `json:"file_path"`
29+ Content string `json:"content"`
30+}
31+
27const (
32const (
28 ViewToolName = "view"
33 ViewToolName = "view"
29 MaxReadSize = 250 * 1024
34 MaxReadSize = 250 * 1024
@@ -180,7 +185,13 @@ func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
180 output += "\n</file>\n"
185 output += "\n</file>\n"
181 output += getDiagnostics(filePath, v.lspClients)
186 output += getDiagnostics(filePath, v.lspClients)
182 recordFileRead(filePath)
187 recordFileRead(filePath)
188+ return WithResponseMetadata(
183− return NewTextResponse(output), nil
189+ NewTextResponse(output),
190+ ViewResponseMetadata{
191+ FilePath: filePath,
192+ Content: content,
193+ },
194+ ), nil
184}
195}
185
196
186func addLineNumbers(content string, startLine int) string {
197func addLineNumbers(content string, startLine int) string {
internal/tui/components/chat/editor.go+53−5
@@ -1,6 +1,9 @@
1package chat
1package chat
2
2
3import (
3import (
4+ "os"
5+ "os/exec"
6+
4 "github.com/charmbracelet/bubbles/key"
7 "github.com/charmbracelet/bubbles/key"
5 "github.com/charmbracelet/bubbles/textarea"
8 "github.com/charmbracelet/bubbles/textarea"
6 tea "github.com/charmbracelet/bubbletea"
9 tea "github.com/charmbracelet/bubbletea"
@@ -19,13 +22,15 @@ type editorCmp struct {
19}
22}
20
23
21type focusedEditorKeyMaps struct {
24type focusedEditorKeyMaps struct {
25+ Send key.Binding
22− Send key.Binding
26+ OpenEditor key.Binding
23− Blur key.Binding
27+ Blur key.Binding
24}
28}
25
29
26type bluredEditorKeyMaps struct {
30type bluredEditorKeyMaps struct {
27− Send key.Binding
31+ Send key.Binding
32+ Focus key.Binding
28− Focus key.Binding
33+ OpenEditor key.Binding
29}
34}
30
35
31var focusedKeyMaps = focusedEditorKeyMaps{
36var focusedKeyMaps = focusedEditorKeyMaps{
@@ -37,6 +42,10 @@ var focusedKeyMaps = focusedEditorKeyMaps{
37 key.WithKeys("esc"),
42 key.WithKeys("esc"),
38 key.WithHelp("esc", "focus messages"),
43 key.WithHelp("esc", "focus messages"),
39 ),
44 ),
45+ OpenEditor: key.NewBinding(
46+ key.WithKeys("ctrl+e"),
47+ key.WithHelp("ctrl+e", "open editor"),
48+ ),
40}
49}
41
50
42var bluredKeyMaps = bluredEditorKeyMaps{
51var bluredKeyMaps = bluredEditorKeyMaps{
@@ -48,6 +57,40 @@ var bluredKeyMaps = bluredEditorKeyMaps{
48 key.WithKeys("i"),
57 key.WithKeys("i"),
49 key.WithHelp("i", "focus editor"),
58 key.WithHelp("i", "focus editor"),
50 ),
59 ),
60+ OpenEditor: key.NewBinding(
61+ key.WithKeys("ctrl+e"),
62+ key.WithHelp("ctrl+e", "open editor"),
63+ ),
64+}
65+
66+func openEditor() tea.Cmd {
67+ editor := os.Getenv("EDITOR")
68+ if editor == "" {
69+ editor = "nvim"
70+ }
71+
72+ tmpfile, err := os.CreateTemp("", "msg_*.md")
73+ if err != nil {
74+ return util.ReportError(err)
75+ }
76+ tmpfile.Close()
77+ c := exec.Command(editor, tmpfile.Name()) //nolint:gosec
78+ c.Stdin = os.Stdin
79+ c.Stdout = os.Stdout
80+ c.Stderr = os.Stderr
81+ return tea.ExecProcess(c, func(err error) tea.Msg {
82+ if err != nil {
83+ return util.ReportError(err)
84+ }
85+ content, err := os.ReadFile(tmpfile.Name())
86+ if err != nil {
87+ return util.ReportError(err)
88+ }
89+ os.Remove(tmpfile.Name())
90+ return SendMsg{
91+ Text: string(content),
92+ }
93+ })
51}
94}
52
95
53func (m *editorCmp) Init() tea.Cmd {
96func (m *editorCmp) Init() tea.Cmd {
@@ -82,6 +125,10 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
82 }
125 }
83 return m, nil
126 return m, nil
84 case tea.KeyMsg:
127 case tea.KeyMsg:
128+ if key.Matches(msg, focusedKeyMaps.OpenEditor) {
129+ m.textarea.Blur()
130+ return m, openEditor()
131+ }
85 // if the key does not match any binding, return
132 // if the key does not match any binding, return
86 if m.textarea.Focused() && key.Matches(msg, focusedKeyMaps.Send) {
133 if m.textarea.Focused() && key.Matches(msg, focusedKeyMaps.Send) {
87 return m, m.send()
134 return m, m.send()
@@ -108,9 +155,10 @@ func (m *editorCmp) View() string {
108 return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View())
155 return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View())
109}
156}
110
157
111−func (m *editorCmp) SetSize(width, height int) {
158+func (m *editorCmp) SetSize(width, height int) tea.Cmd {
112 m.textarea.SetWidth(width - 3) // account for the prompt and padding right
159 m.textarea.SetWidth(width - 3) // account for the prompt and padding right
113 m.textarea.SetHeight(height)
160 m.textarea.SetHeight(height)
161+ return nil
114}
162}
115
163
116func (m *editorCmp) GetSize() (int, int) {
164func (m *editorCmp) GetSize() (int, int) {
internal/tui/components/chat/list.gonew file+463
@@ -0,0 +1,463 @@
1+package chat
2+
3+import (
4+ "context"
5+ "fmt"
6+ "math"
7+ "sync"
8+ "time"
9+
10+ "github.com/charmbracelet/bubbles/key"
11+ "github.com/charmbracelet/bubbles/spinner"
12+ "github.com/charmbracelet/bubbles/viewport"
13+ tea "github.com/charmbracelet/bubbletea"
14+ "github.com/charmbracelet/lipgloss"
15+ "github.com/kujtimiihoxha/opencode/internal/app"
16+ "github.com/kujtimiihoxha/opencode/internal/logging"
17+ "github.com/kujtimiihoxha/opencode/internal/message"
18+ "github.com/kujtimiihoxha/opencode/internal/pubsub"
19+ "github.com/kujtimiihoxha/opencode/internal/session"
20+ "github.com/kujtimiihoxha/opencode/internal/tui/layout"
21+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
22+ "github.com/kujtimiihoxha/opencode/internal/tui/util"
23+)
24+
25+type messagesCmp struct {
26+ app *app.App
27+ width, height int
28+ writingMode bool
29+ viewport viewport.Model
30+ session session.Session
31+ messages []message.Message
32+ uiMessages []uiMessage
33+ currentMsgID string
34+ mutex sync.Mutex
35+ cachedContent map[string][]uiMessage
36+ spinner spinner.Model
37+ rendering bool
38+}
39+type renderFinishedMsg struct{}
40+
41+func (m *messagesCmp) Init() tea.Cmd {
42+ return tea.Batch(m.viewport.Init())
43+}
44+
45+func (m *messagesCmp) preloadSessions() tea.Cmd {
46+ return func() tea.Msg {
47+ sessions, err := m.app.Sessions.List(context.Background())
48+ if err != nil {
49+ return util.ReportError(err)()
50+ }
51+ if len(sessions) == 0 {
52+ return nil
53+ }
54+ if len(sessions) > 20 {
55+ sessions = sessions[:20]
56+ }
57+ for _, s := range sessions {
58+ messages, err := m.app.Messages.List(context.Background(), s.ID)
59+ if err != nil {
60+ return util.ReportError(err)()
61+ }
62+ if len(messages) == 0 {
63+ continue
64+ }
65+ m.cacheSessionMessages(messages, m.width)
66+
67+ }
68+ logging.Debug("preloaded sessions")
69+
70+ return nil
71+ }
72+}
73+
74+func (m *messagesCmp) cacheSessionMessages(messages []message.Message, width int) {
75+ m.mutex.Lock()
76+ defer m.mutex.Unlock()
77+ pos := 0
78+ if m.width == 0 {
79+ return
80+ }
81+ for inx, msg := range messages {
82+ switch msg.Role {
83+ case message.User:
84+ userMsg := renderUserMessage(
85+ msg,
86+ false,
87+ width,
88+ pos,
89+ )
90+ m.cachedContent[msg.ID] = []uiMessage{userMsg}
91+ pos += userMsg.height + 1 // + 1 for spacing
92+ case message.Assistant:
93+ assistantMessages := renderAssistantMessage(
94+ msg,
95+ inx,
96+ messages,
97+ m.app.Messages,
98+ "",
99+ width,
100+ pos,
101+ )
102+ for _, msg := range assistantMessages {
103+ pos += msg.height + 1 // + 1 for spacing
104+ }
105+ m.cachedContent[msg.ID] = assistantMessages
106+ }
107+ }
108+}
109+
110+func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
111+ var cmds []tea.Cmd
112+ switch msg := msg.(type) {
113+ case EditorFocusMsg:
114+ m.writingMode = bool(msg)
115+ case SessionSelectedMsg:
116+ if msg.ID != m.session.ID {
117+ cmd := m.SetSession(msg)
118+ return m, cmd
119+ }
120+ return m, nil
121+ case SessionClearedMsg:
122+ m.session = session.Session{}
123+ m.messages = make([]message.Message, 0)
124+ m.currentMsgID = ""
125+ m.rendering = false
126+ return m, nil
127+
128+ case renderFinishedMsg:
129+ m.rendering = false
130+ m.viewport.GotoBottom()
131+ case tea.KeyMsg:
132+ if m.writingMode {
133+ return m, nil
134+ }
135+ case pubsub.Event[message.Message]:
136+ needsRerender := false
137+ if msg.Type == pubsub.CreatedEvent {
138+ if msg.Payload.SessionID == m.session.ID {
139+
140+ messageExists := false
141+ for _, v := range m.messages {
142+ if v.ID == msg.Payload.ID {
143+ messageExists = true
144+ break
145+ }
146+ }
147+
148+ if !messageExists {
149+ if len(m.messages) > 0 {
150+ lastMsgID := m.messages[len(m.messages)-1].ID
151+ delete(m.cachedContent, lastMsgID)
152+ }
153+
154+ m.messages = append(m.messages, msg.Payload)
155+ delete(m.cachedContent, m.currentMsgID)
156+ m.currentMsgID = msg.Payload.ID
157+ needsRerender = true
158+ }
159+ }
160+ // There are tool calls from the child task
161+ for _, v := range m.messages {
162+ for _, c := range v.ToolCalls() {
163+ if c.ID == msg.Payload.SessionID {
164+ delete(m.cachedContent, v.ID)
165+ needsRerender = true
166+ }
167+ }
168+ }
169+ } else if msg.Type == pubsub.UpdatedEvent && msg.Payload.SessionID == m.session.ID {
170+ for i, v := range m.messages {
171+ if v.ID == msg.Payload.ID {
172+ m.messages[i] = msg.Payload
173+ delete(m.cachedContent, msg.Payload.ID)
174+ needsRerender = true
175+ break
176+ }
177+ }
178+ }
179+ if needsRerender {
180+ m.renderView()
181+ if len(m.messages) > 0 {
182+ if (msg.Type == pubsub.CreatedEvent) ||
183+ (msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == m.messages[len(m.messages)-1].ID) {
184+ m.viewport.GotoBottom()
185+ }
186+ }
187+ }
188+ }
189+
190+ u, cmd := m.viewport.Update(msg)
191+ m.viewport = u
192+ cmds = append(cmds, cmd)
193+
194+ spinner, cmd := m.spinner.Update(msg)
195+ m.spinner = spinner
196+ cmds = append(cmds, cmd)
197+ return m, tea.Batch(cmds...)
198+}
199+
200+func (m *messagesCmp) IsAgentWorking() bool {
201+ return m.app.CoderAgent.IsSessionBusy(m.session.ID)
202+}
203+
204+func formatTimeDifference(unixTime1, unixTime2 int64) string {
205+ diffSeconds := float64(math.Abs(float64(unixTime2 - unixTime1)))
206+
207+ if diffSeconds < 60 {
208+ return fmt.Sprintf("%.1fs", diffSeconds)
209+ }
210+
211+ minutes := int(diffSeconds / 60)
212+ seconds := int(diffSeconds) % 60
213+ return fmt.Sprintf("%dm%ds", minutes, seconds)
214+}
215+
216+func (m *messagesCmp) renderView() {
217+ m.uiMessages = make([]uiMessage, 0)
218+ pos := 0
219+
220+ if m.width == 0 {
221+ return
222+ }
223+ for inx, msg := range m.messages {
224+ switch msg.Role {
225+ case message.User:
226+ if messages, ok := m.cachedContent[msg.ID]; ok {
227+ m.uiMessages = append(m.uiMessages, messages...)
228+ continue
229+ }
230+ userMsg := renderUserMessage(
231+ msg,
232+ msg.ID == m.currentMsgID,
233+ m.width,
234+ pos,
235+ )
236+ m.uiMessages = append(m.uiMessages, userMsg)
237+ m.cachedContent[msg.ID] = []uiMessage{userMsg}
238+ pos += userMsg.height + 1 // + 1 for spacing
239+ case message.Assistant:
240+ if messages, ok := m.cachedContent[msg.ID]; ok {
241+ m.uiMessages = append(m.uiMessages, messages...)
242+ continue
243+ }
244+ assistantMessages := renderAssistantMessage(
245+ msg,
246+ inx,
247+ m.messages,
248+ m.app.Messages,
249+ m.currentMsgID,
250+ m.width,
251+ pos,
252+ )
253+ for _, msg := range assistantMessages {
254+ m.uiMessages = append(m.uiMessages, msg)
255+ pos += msg.height + 1 // + 1 for spacing
256+ }
257+ m.cachedContent[msg.ID] = assistantMessages
258+ }
259+ }
260+
261+ messages := make([]string, 0)
262+ for _, v := range m.uiMessages {
263+ messages = append(messages, v.content,
264+ styles.BaseStyle.
265+ Width(m.width).
266+ Render(
267+ "",
268+ ),
269+ )
270+ }
271+ m.viewport.SetContent(
272+ styles.BaseStyle.
273+ Width(m.width).
274+ Render(
275+ lipgloss.JoinVertical(
276+ lipgloss.Top,
277+ messages...,
278+ ),
279+ ),
280+ )
281+}
282+
283+func (m *messagesCmp) View() string {
284+ if m.rendering {
285+ return styles.BaseStyle.
286+ Width(m.width).
287+ Render(
288+ lipgloss.JoinVertical(
289+ lipgloss.Top,
290+ "Loading...",
291+ m.working(),
292+ m.help(),
293+ ),
294+ )
295+ }
296+ if len(m.messages) == 0 {
297+ content := styles.BaseStyle.
298+ Width(m.width).
299+ Height(m.height - 1).
300+ Render(
301+ m.initialScreen(),
302+ )
303+
304+ return styles.BaseStyle.
305+ Width(m.width).
306+ Render(
307+ lipgloss.JoinVertical(
308+ lipgloss.Top,
309+ content,
310+ "",
311+ m.help(),
312+ ),
313+ )
314+ }
315+
316+ return styles.BaseStyle.
317+ Width(m.width).
318+ Render(
319+ lipgloss.JoinVertical(
320+ lipgloss.Top,
321+ m.viewport.View(),
322+ m.working(),
323+ m.help(),
324+ ),
325+ )
326+}
327+
328+func hasToolsWithoutResponse(messages []message.Message) bool {
329+ toolCalls := make([]message.ToolCall, 0)
330+ toolResults := make([]message.ToolResult, 0)
331+ for _, m := range messages {
332+ toolCalls = append(toolCalls, m.ToolCalls()...)
333+ toolResults = append(toolResults, m.ToolResults()...)
334+ }
335+
336+ for _, v := range toolCalls {
337+ found := false
338+ for _, r := range toolResults {
339+ if v.ID == r.ToolCallID {
340+ found = true
341+ break
342+ }
343+ }
344+ if !found {
345+ return true
346+ }
347+ }
348+
349+ return false
350+}
351+
352+func (m *messagesCmp) working() string {
353+ text := ""
354+ if m.IsAgentWorking() {
355+ task := "Thinking..."
356+ lastMessage := m.messages[len(m.messages)-1]
357+ if hasToolsWithoutResponse(m.messages) {
358+ task = "Waiting for tool response..."
359+ } else if !lastMessage.IsFinished() {
360+ lastUpdate := lastMessage.UpdatedAt
361+ currentTime := time.Now().Unix()
362+ if lastMessage.Content().String() != "" && lastUpdate != 0 && currentTime-lastUpdate > 5 {
363+ task = "Building tool call..."
364+ } else if lastMessage.Content().String() == "" {
365+ task = "Generating..."
366+ }
367+ task = ""
368+ }
369+ if task != "" {
370+ text += styles.BaseStyle.Width(m.width).Foreground(styles.PrimaryColor).Bold(true).Render(
371+ fmt.Sprintf("%s %s ", m.spinner.View(), task),
372+ )
373+ }
374+ }
375+ return text
376+}
377+
378+func (m *messagesCmp) help() string {
379+ text := ""
380+
381+ if m.writingMode {
382+ text += lipgloss.JoinHorizontal(
383+ lipgloss.Left,
384+ styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "),
385+ styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("esc"),
386+ styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render(" to exit writing mode"),
387+ )
388+ } else {
389+ text += lipgloss.JoinHorizontal(
390+ lipgloss.Left,
391+ styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "),
392+ styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("i"),
393+ styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render(" to start writing"),
394+ )
395+ }
396+
397+ return styles.BaseStyle.
398+ Width(m.width).
399+ Render(text)
400+}
401+
402+func (m *messagesCmp) initialScreen() string {
403+ return styles.BaseStyle.Width(m.width).Render(
404+ lipgloss.JoinVertical(
405+ lipgloss.Top,
406+ header(m.width),
407+ "",
408+ lspsConfigured(m.width),
409+ ),
410+ )
411+}
412+
413+func (m *messagesCmp) SetSize(width, height int) tea.Cmd {
414+ if m.width == width && m.height == height {
415+ return nil
416+ }
417+ m.width = width
418+ m.height = height
419+ m.viewport.Width = width
420+ m.viewport.Height = height - 2
421+ m.renderView()
422+ return m.preloadSessions()
423+}
424+
425+func (m *messagesCmp) GetSize() (int, int) {
426+ return m.width, m.height
427+}
428+
429+func (m *messagesCmp) SetSession(session session.Session) tea.Cmd {
430+ if m.session.ID == session.ID {
431+ return nil
432+ }
433+ m.rendering = true
434+ return func() tea.Msg {
435+ m.session = session
436+ messages, err := m.app.Messages.List(context.Background(), session.ID)
437+ if err != nil {
438+ return util.ReportError(err)
439+ }
440+ m.messages = messages
441+ m.currentMsgID = m.messages[len(m.messages)-1].ID
442+ delete(m.cachedContent, m.currentMsgID)
443+ m.renderView()
444+ return renderFinishedMsg{}
445+ }
446+}
447+
448+func (m *messagesCmp) BindingKeys() []key.Binding {
449+ bindings := layout.KeyMapToSlice(m.viewport.KeyMap)
450+ return bindings
451+}
452+
453+func NewMessagesCmp(app *app.App) tea.Model {
454+ s := spinner.New()
455+ s.Spinner = spinner.Pulse
456+ return &messagesCmp{
457+ app: app,
458+ writingMode: true,
459+ cachedContent: make(map[string][]uiMessage),
460+ viewport: viewport.New(0, 0),
461+ spinner: s,
462+ }
463+}
internal/tui/components/chat/message.gonew file+561
@@ -0,0 +1,561 @@
1+package chat
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "path/filepath"
8+ "strings"
9+ "sync"
10+ "time"
11+
12+ "github.com/charmbracelet/glamour"
13+ "github.com/charmbracelet/lipgloss"
14+ "github.com/charmbracelet/x/ansi"
15+ "github.com/kujtimiihoxha/opencode/internal/config"
16+ "github.com/kujtimiihoxha/opencode/internal/diff"
17+ "github.com/kujtimiihoxha/opencode/internal/llm/agent"
18+ "github.com/kujtimiihoxha/opencode/internal/llm/models"
19+ "github.com/kujtimiihoxha/opencode/internal/llm/tools"
20+ "github.com/kujtimiihoxha/opencode/internal/message"
21+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
22+)
23+
24+type uiMessageType int
25+
26+const (
27+ userMessageType uiMessageType = iota
28+ assistantMessageType
29+ toolMessageType
30+
31+ maxResultHeight = 15
32+)
33+
34+var diffStyle = diff.NewStyleConfig(diff.WithShowHeader(false), diff.WithShowHunkHeader(false))
35+
36+type uiMessage struct {
37+ ID string
38+ messageType uiMessageType
39+ position int
40+ height int
41+ content string
42+}
43+
44+type renderCache struct {
45+ mutex sync.Mutex
46+ cache map[string][]uiMessage
47+}
48+
49+func toMarkdown(content string, focused bool, width int) string {
50+ r, _ := glamour.NewTermRenderer(
51+ glamour.WithStyles(styles.MarkdownTheme(false)),
52+ glamour.WithWordWrap(width),
53+ )
54+ if focused {
55+ r, _ = glamour.NewTermRenderer(
56+ glamour.WithStyles(styles.MarkdownTheme(true)),
57+ glamour.WithWordWrap(width),
58+ )
59+ }
60+ rendered, _ := r.Render(content)
61+ return rendered
62+}
63+
64+func renderMessage(msg string, isUser bool, isFocused bool, width int, info ...string) string {
65+ style := styles.BaseStyle.
66+ Width(width - 1).
67+ BorderLeft(true).
68+ Foreground(styles.ForgroundDim).
69+ BorderForeground(styles.PrimaryColor).
70+ BorderStyle(lipgloss.ThickBorder())
71+ if isUser {
72+ style = style.
73+ BorderForeground(styles.Blue)
74+ }
75+ parts := []string{
76+ styles.ForceReplaceBackgroundWithLipgloss(toMarkdown(msg, isFocused, width), styles.Background),
77+ }
78+
79+ // remove newline at the end
80+ parts[0] = strings.TrimSuffix(parts[0], "\n")
81+ if len(info) > 0 {
82+ parts = append(parts, info...)
83+ }
84+ rendered := style.Render(
85+ lipgloss.JoinVertical(
86+ lipgloss.Left,
87+ parts...,
88+ ),
89+ )
90+
91+ return rendered
92+}
93+
94+func renderUserMessage(msg message.Message, isFocused bool, width int, position int) uiMessage {
95+ content := renderMessage(msg.Content().String(), true, isFocused, width)
96+ userMsg := uiMessage{
97+ ID: msg.ID,
98+ messageType: userMessageType,
99+ position: position,
100+ height: lipgloss.Height(content),
101+ content: content,
102+ }
103+ return userMsg
104+}
105+
106+// Returns multiple uiMessages because of the tool calls
107+func renderAssistantMessage(
108+ msg message.Message,
109+ msgIndex int,
110+ allMessages []message.Message, // we need this to get tool results and the user message
111+ messagesService message.Service, // We need this to get the task tool messages
112+ focusedUIMessageId string,
113+ width int,
114+ position int,
115+) []uiMessage {
116+ // find the user message that is before this assistant message
117+ var userMsg message.Message
118+ for i := msgIndex - 1; i >= 0; i-- {
119+ msg := allMessages[i]
120+ if msg.Role == message.User {
121+ userMsg = allMessages[i]
122+ break
123+ }
124+ }
125+
126+ messages := []uiMessage{}
127+ content := msg.Content().String()
128+ finished := msg.IsFinished()
129+ finishData := msg.FinishPart()
130+ info := []string{}
131+
132+ // Add finish info if available
133+ if finished {
134+ switch finishData.Reason {
135+ case message.FinishReasonEndTurn:
136+ took := formatTimeDifference(userMsg.CreatedAt, finishData.Time)
137+ info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
138+ fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, took),
139+ ))
140+ case message.FinishReasonCanceled:
141+ info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
142+ fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "canceled"),
143+ ))
144+ case message.FinishReasonError:
145+ info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
146+ fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "error"),
147+ ))
148+ case message.FinishReasonPermissionDenied:
149+ info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
150+ fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "permission denied"),
151+ ))
152+ }
153+ }
154+ if content != "" {
155+ content = renderMessage(content, false, msg.ID == focusedUIMessageId, width, info...)
156+ messages = append(messages, uiMessage{
157+ ID: msg.ID,
158+ messageType: assistantMessageType,
159+ position: position,
160+ height: lipgloss.Height(content),
161+ content: content,
162+ })
163+ position += messages[0].height
164+ position++ // for the space
165+ }
166+
167+ for i, toolCall := range msg.ToolCalls() {
168+ toolCallContent := renderToolMessage(
169+ toolCall,
170+ allMessages,
171+ messagesService,
172+ focusedUIMessageId,
173+ false,
174+ width,
175+ i+1,
176+ )
177+ messages = append(messages, toolCallContent)
178+ position += toolCallContent.height
179+ position++ // for the space
180+ }
181+ return messages
182+}
183+
184+func findToolResponse(toolCallID string, futureMessages []message.Message) *message.ToolResult {
185+ for _, msg := range futureMessages {
186+ for _, result := range msg.ToolResults() {
187+ if result.ToolCallID == toolCallID {
188+ return &result
189+ }
190+ }
191+ }
192+ return nil
193+}
194+
195+func toolName(name string) string {
196+ switch name {
197+ case agent.AgentToolName:
198+ return "Task"
199+ case tools.BashToolName:
200+ return "Bash"
201+ case tools.EditToolName:
202+ return "Edit"
203+ case tools.FetchToolName:
204+ return "Fetch"
205+ case tools.GlobToolName:
206+ return "Glob"
207+ case tools.GrepToolName:
208+ return "Grep"
209+ case tools.LSToolName:
210+ return "List"
211+ case tools.SourcegraphToolName:
212+ return "Sourcegraph"
213+ case tools.ViewToolName:
214+ return "View"
215+ case tools.WriteToolName:
216+ return "Write"
217+ }
218+ return name
219+}
220+
221+// renders params, params[0] (params[1]=params[2] ....)
222+func renderParams(paramsWidth int, params ...string) string {
223+ if len(params) == 0 {
224+ return ""
225+ }
226+ mainParam := params[0]
227+ if len(mainParam) > paramsWidth {
228+ mainParam = mainParam[:paramsWidth-3] + "..."
229+ }
230+
231+ if len(params) == 1 {
232+ return mainParam
233+ }
234+ otherParams := params[1:]
235+ // create pairs of key/value
236+ // if odd number of params, the last one is a key without value
237+ if len(otherParams)%2 != 0 {
238+ otherParams = append(otherParams, "")
239+ }
240+ parts := make([]string, 0, len(otherParams)/2)
241+ for i := 0; i < len(otherParams); i += 2 {
242+ key := otherParams[i]
243+ value := otherParams[i+1]
244+ if value == "" {
245+ continue
246+ }
247+ parts = append(parts, fmt.Sprintf("%s=%s", key, value))
248+ }
249+
250+ partsRendered := strings.Join(parts, ", ")
251+ remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 5 // for the space
252+ if remainingWidth < 30 {
253+ // No space for the params, just show the main
254+ return mainParam
255+ }
256+
257+ if len(parts) > 0 {
258+ mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
259+ }
260+
261+ return ansi.Truncate(mainParam, paramsWidth, "...")
262+}
263+
264+func removeWorkingDirPrefix(path string) string {
265+ wd := config.WorkingDirectory()
266+ if strings.HasPrefix(path, wd) {
267+ path = strings.TrimPrefix(path, wd)
268+ }
269+ if strings.HasPrefix(path, "/") {
270+ path = strings.TrimPrefix(path, "/")
271+ }
272+ if strings.HasPrefix(path, "./") {
273+ path = strings.TrimPrefix(path, "./")
274+ }
275+ if strings.HasPrefix(path, "../") {
276+ path = strings.TrimPrefix(path, "../")
277+ }
278+ return path
279+}
280+
281+func renderToolParams(paramWidth int, toolCall message.ToolCall) string {
282+ params := ""
283+ switch toolCall.Name {
284+ case agent.AgentToolName:
285+ var params agent.AgentParams
286+ json.Unmarshal([]byte(toolCall.Input), &params)
287+ prompt := strings.ReplaceAll(params.Prompt, "\n", " ")
288+ return renderParams(paramWidth, prompt)
289+ case tools.BashToolName:
290+ var params tools.BashParams
291+ json.Unmarshal([]byte(toolCall.Input), &params)
292+ command := strings.ReplaceAll(params.Command, "\n", " ")
293+ return renderParams(paramWidth, command)
294+ case tools.EditToolName:
295+ var params tools.EditParams
296+ json.Unmarshal([]byte(toolCall.Input), &params)
297+ filePath := removeWorkingDirPrefix(params.FilePath)
298+ return renderParams(paramWidth, filePath)
299+ case tools.FetchToolName:
300+ var params tools.FetchParams
301+ json.Unmarshal([]byte(toolCall.Input), &params)
302+ url := params.URL
303+ toolParams := []string{
304+ url,
305+ }
306+ if params.Format != "" {
307+ toolParams = append(toolParams, "format", params.Format)
308+ }
309+ if params.Timeout != 0 {
310+ toolParams = append(toolParams, "timeout", (time.Duration(params.Timeout) * time.Second).String())
311+ }
312+ return renderParams(paramWidth, toolParams...)
313+ case tools.GlobToolName:
314+ var params tools.GlobParams
315+ json.Unmarshal([]byte(toolCall.Input), &params)
316+ pattern := params.Pattern
317+ toolParams := []string{
318+ pattern,
319+ }
320+ if params.Path != "" {
321+ toolParams = append(toolParams, "path", params.Path)
322+ }
323+ return renderParams(paramWidth, toolParams...)
324+ case tools.GrepToolName:
325+ var params tools.GrepParams
326+ json.Unmarshal([]byte(toolCall.Input), &params)
327+ pattern := params.Pattern
328+ toolParams := []string{
329+ pattern,
330+ }
331+ if params.Path != "" {
332+ toolParams = append(toolParams, "path", params.Path)
333+ }
334+ if params.Include != "" {
335+ toolParams = append(toolParams, "include", params.Include)
336+ }
337+ if params.LiteralText {
338+ toolParams = append(toolParams, "literal", "true")
339+ }
340+ return renderParams(paramWidth, toolParams...)
341+ case tools.LSToolName:
342+ var params tools.LSParams
343+ json.Unmarshal([]byte(toolCall.Input), &params)
344+ path := params.Path
345+ if path == "" {
346+ path = "."
347+ }
348+ return renderParams(paramWidth, path)
349+ case tools.SourcegraphToolName:
350+ var params tools.SourcegraphParams
351+ json.Unmarshal([]byte(toolCall.Input), &params)
352+ return renderParams(paramWidth, params.Query)
353+ case tools.ViewToolName:
354+ var params tools.ViewParams
355+ json.Unmarshal([]byte(toolCall.Input), &params)
356+ filePath := removeWorkingDirPrefix(params.FilePath)
357+ toolParams := []string{
358+ filePath,
359+ }
360+ if params.Limit != 0 {
361+ toolParams = append(toolParams, "limit", fmt.Sprintf("%d", params.Limit))
362+ }
363+ if params.Offset != 0 {
364+ toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset))
365+ }
366+ return renderParams(paramWidth, toolParams...)
367+ case tools.WriteToolName:
368+ var params tools.WriteParams
369+ json.Unmarshal([]byte(toolCall.Input), &params)
370+ filePath := removeWorkingDirPrefix(params.FilePath)
371+ return renderParams(paramWidth, filePath)
372+ default:
373+ input := strings.ReplaceAll(toolCall.Input, "\n", " ")
374+ params = renderParams(paramWidth, input)
375+ }
376+ return params
377+}
378+
379+func truncateHeight(content string, height int) string {
380+ lines := strings.Split(content, "\n")
381+ if len(lines) > height {
382+ return strings.Join(lines[:height], "\n")
383+ }
384+ return content
385+}
386+
387+func renderToolResponse(toolCall message.ToolCall, response message.ToolResult, width int) string {
388+ if response.IsError {
389+ errContent := fmt.Sprintf("Error: %s", strings.ReplaceAll(response.Content, "\n", " "))
390+ errContent = ansi.Truncate(errContent, width-1, "...")
391+ return styles.BaseStyle.
392+ Foreground(styles.Error).
393+ Render(errContent)
394+ }
395+ resultContent := truncateHeight(response.Content, maxResultHeight)
396+ switch toolCall.Name {
397+ case agent.AgentToolName:
398+ return styles.ForceReplaceBackgroundWithLipgloss(
399+ toMarkdown(resultContent, false, width),
400+ styles.Background,
401+ )
402+ case tools.BashToolName:
403+ resultContent = fmt.Sprintf("```bash\n%s\n```", resultContent)
404+ return styles.ForceReplaceBackgroundWithLipgloss(
405+ toMarkdown(resultContent, true, width),
406+ styles.Background,
407+ )
408+ case tools.EditToolName:
409+ metadata := tools.EditResponseMetadata{}
410+ json.Unmarshal([]byte(response.Metadata), &metadata)
411+ truncDiff := truncateHeight(metadata.Diff, maxResultHeight)
412+ formattedDiff, _ := diff.FormatDiff(truncDiff, diff.WithTotalWidth(width), diff.WithStyle(diffStyle))
413+ return formattedDiff
414+ case tools.FetchToolName:
415+ var params tools.FetchParams
416+ json.Unmarshal([]byte(toolCall.Input), &params)
417+ mdFormat := "markdown"
418+ switch params.Format {
419+ case "text":
420+ mdFormat = "text"
421+ case "html":
422+ mdFormat = "html"
423+ }
424+ resultContent = fmt.Sprintf("```%s\n%s\n```", mdFormat, resultContent)
425+ return styles.ForceReplaceBackgroundWithLipgloss(
426+ toMarkdown(resultContent, true, width),
427+ styles.Background,
428+ )
429+ case tools.GlobToolName:
430+ return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
431+ case tools.GrepToolName:
432+ return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
433+ case tools.LSToolName:
434+ return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
435+ case tools.SourcegraphToolName:
436+ return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
437+ case tools.ViewToolName:
438+ metadata := tools.ViewResponseMetadata{}
439+ json.Unmarshal([]byte(response.Metadata), &metadata)
440+ ext := filepath.Ext(metadata.FilePath)
441+ if ext == "" {
442+ ext = ""
443+ } else {
444+ ext = strings.ToLower(ext[1:])
445+ }
446+ resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(metadata.Content, maxResultHeight))
447+ return styles.ForceReplaceBackgroundWithLipgloss(
448+ toMarkdown(resultContent, true, width),
449+ styles.Background,
450+ )
451+ case tools.WriteToolName:
452+ params := tools.WriteParams{}
453+ json.Unmarshal([]byte(toolCall.Input), &params)
454+ metadata := tools.WriteResponseMetadata{}
455+ json.Unmarshal([]byte(response.Metadata), &metadata)
456+ ext := filepath.Ext(params.FilePath)
457+ if ext == "" {
458+ ext = ""
459+ } else {
460+ ext = strings.ToLower(ext[1:])
461+ }
462+ resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(params.Content, maxResultHeight))
463+ return styles.ForceReplaceBackgroundWithLipgloss(
464+ toMarkdown(resultContent, true, width),
465+ styles.Background,
466+ )
467+ default:
468+ resultContent = fmt.Sprintf("```text\n%s\n```", resultContent)
469+ return styles.ForceReplaceBackgroundWithLipgloss(
470+ toMarkdown(resultContent, true, width),
471+ styles.Background,
472+ )
473+ }
474+}
475+
476+func renderToolMessage(
477+ toolCall message.ToolCall,
478+ allMessages []message.Message,
479+ messagesService message.Service,
480+ focusedUIMessageId string,
481+ nested bool,
482+ width int,
483+ position int,
484+) uiMessage {
485+ if nested {
486+ width = width - 3
487+ }
488+ response := findToolResponse(toolCall.ID, allMessages)
489+ toolName := styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf("%s: ", toolName(toolCall.Name)))
490+ params := renderToolParams(width-2-lipgloss.Width(toolName), toolCall)
491+ responseContent := ""
492+ if response != nil {
493+ responseContent = renderToolResponse(toolCall, *response, width-2)
494+ responseContent = strings.TrimSuffix(responseContent, "\n")
495+ } else {
496+ responseContent = styles.BaseStyle.
497+ Italic(true).
498+ Width(width - 2).
499+ Foreground(styles.ForgroundDim).
500+ Render("Waiting for response...")
501+ }
502+ style := styles.BaseStyle.
503+ Width(width - 1).
504+ BorderLeft(true).
505+ BorderStyle(lipgloss.ThickBorder()).
506+ PaddingLeft(1).
507+ BorderForeground(styles.ForgroundDim)
508+
509+ parts := []string{}
510+ if !nested {
511+ params := styles.BaseStyle.
512+ Width(width - 2 - lipgloss.Width(toolName)).
513+ Foreground(styles.ForgroundDim).
514+ Render(params)
515+
516+ parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, toolName, params))
517+ } else {
518+ prefix := styles.BaseStyle.
519+ Foreground(styles.ForgroundDim).
520+ Render(" └ ")
521+ params := styles.BaseStyle.
522+ Width(width - 2 - lipgloss.Width(toolName)).
523+ Foreground(styles.ForgroundMid).
524+ Render(params)
525+ parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, prefix, toolName, params))
526+ }
527+ if toolCall.Name == agent.AgentToolName {
528+ taskMessages, _ := messagesService.List(context.Background(), toolCall.ID)
529+ toolCalls := []message.ToolCall{}
530+ for _, v := range taskMessages {
531+ toolCalls = append(toolCalls, v.ToolCalls()...)
532+ }
533+ for _, call := range toolCalls {
534+ rendered := renderToolMessage(call, []message.Message{}, messagesService, focusedUIMessageId, true, width, 0)
535+ parts = append(parts, rendered.content)
536+ }
537+ }
538+ if responseContent != "" && !nested {
539+ parts = append(parts, responseContent)
540+ }
541+
542+ content := style.Render(
543+ lipgloss.JoinVertical(
544+ lipgloss.Left,
545+ parts...,
546+ ),
547+ )
548+ if nested {
549+ content = lipgloss.JoinVertical(
550+ lipgloss.Left,
551+ parts...,
552+ )
553+ }
554+ toolMsg := uiMessage{
555+ messageType: toolMessageType,
556+ position: position,
557+ height: lipgloss.Height(content),
558+ content: content,
559+ }
560+ return toolMsg
561+}
internal/tui/components/chat/messages.godeleted−742
@@ -1,742 +0,0 @@
1−package chat
2−
3−import (
4− "context"
5− "encoding/json"
6− "fmt"
7− "math"
8− "strings"
9− "time"
10−
11− "github.com/charmbracelet/bubbles/key"
12− "github.com/charmbracelet/bubbles/spinner"
13− "github.com/charmbracelet/bubbles/viewport"
14− tea "github.com/charmbracelet/bubbletea"
15− "github.com/charmbracelet/glamour"
16− "github.com/charmbracelet/lipgloss"
17− "github.com/charmbracelet/x/ansi"
18− "github.com/kujtimiihoxha/opencode/internal/app"
19− "github.com/kujtimiihoxha/opencode/internal/llm/agent"
20− "github.com/kujtimiihoxha/opencode/internal/llm/models"
21− "github.com/kujtimiihoxha/opencode/internal/llm/tools"
22− "github.com/kujtimiihoxha/opencode/internal/logging"
23− "github.com/kujtimiihoxha/opencode/internal/message"
24− "github.com/kujtimiihoxha/opencode/internal/pubsub"
25− "github.com/kujtimiihoxha/opencode/internal/session"
26− "github.com/kujtimiihoxha/opencode/internal/tui/layout"
27− "github.com/kujtimiihoxha/opencode/internal/tui/styles"
28− "github.com/kujtimiihoxha/opencode/internal/tui/util"
29−)
30−
31−type uiMessageType int
32−
33−const (
34− userMessageType uiMessageType = iota
35− assistantMessageType
36− toolMessageType
37−)
38−
39−// messagesTickMsg is a message sent by the timer to refresh messages
40−type messagesTickMsg time.Time
41−
42−type uiMessage struct {
43− ID string
44− messageType uiMessageType
45− position int
46− height int
47− content string
48−}
49−
50−type messagesCmp struct {
51− app *app.App
52− width, height int
53− writingMode bool
54− viewport viewport.Model
55− session session.Session
56− messages []message.Message
57− uiMessages []uiMessage
58− currentMsgID string
59− renderer *glamour.TermRenderer
60− focusRenderer *glamour.TermRenderer
61− cachedContent map[string]string
62− spinner spinner.Model
63− needsRerender bool
64−}
65−
66−func (m *messagesCmp) Init() tea.Cmd {
67− return tea.Batch(m.viewport.Init(), m.spinner.Tick, m.tickMessages())
68−}
69−
70−func (m *messagesCmp) tickMessages() tea.Cmd {
71− return tea.Tick(time.Second, func(t time.Time) tea.Msg {
72− return messagesTickMsg(t)
73− })
74−}
75−
76−func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
77− var cmds []tea.Cmd
78− switch msg := msg.(type) {
79− case messagesTickMsg:
80− // Refresh messages if we have an active session
81− if m.session.ID != "" {
82− messages, err := m.app.Messages.List(context.Background(), m.session.ID)
83− if err == nil {
84− m.messages = messages
85− m.needsRerender = true
86− }
87− }
88− // Continue ticking
89− cmds = append(cmds, m.tickMessages())
90− case EditorFocusMsg:
91− m.writingMode = bool(msg)
92− case SessionSelectedMsg:
93− if msg.ID != m.session.ID {
94− cmd := m.SetSession(msg)
95− m.needsRerender = true
96− return m, cmd
97− }
98− return m, nil
99− case SessionClearedMsg:
100− m.session = session.Session{}
101− m.messages = make([]message.Message, 0)
102− m.currentMsgID = ""
103− m.needsRerender = true
104− m.cachedContent = make(map[string]string)
105− return m, nil
106−
107− case tea.KeyMsg:
108− if m.writingMode {
109− return m, nil
110− }
111− case pubsub.Event[message.Message]:
112− if msg.Type == pubsub.CreatedEvent {
113− if msg.Payload.SessionID == m.session.ID {
114− // check if message exists
115−
116− messageExists := false
117− for _, v := range m.messages {
118− if v.ID == msg.Payload.ID {
119− messageExists = true
120− break
121− }
122− }
123−
124− if !messageExists {
125− // If we have messages, ensure the previous last message is not cached
126− if len(m.messages) > 0 {
127− lastMsgID := m.messages[len(m.messages)-1].ID
128− delete(m.cachedContent, lastMsgID)
129− }
130−
131− m.messages = append(m.messages, msg.Payload)
132− delete(m.cachedContent, m.currentMsgID)
133− m.currentMsgID = msg.Payload.ID
134− m.needsRerender = true
135− }
136− }
137− for _, v := range m.messages {
138− for _, c := range v.ToolCalls() {
139− if c.ID == msg.Payload.SessionID {
140− m.needsRerender = true
141− }
142− }
143− }
144− } else if msg.Type == pubsub.UpdatedEvent && msg.Payload.SessionID == m.session.ID {
145− logging.Debug("Message", "finish", msg.Payload.FinishReason())
146− for i, v := range m.messages {
147− if v.ID == msg.Payload.ID {
148− m.messages[i] = msg.Payload
149− delete(m.cachedContent, msg.Payload.ID)
150−
151− // If this is the last message, ensure it's not cached
152− if i == len(m.messages)-1 {
153− delete(m.cachedContent, msg.Payload.ID)
154− }
155−
156− m.needsRerender = true
157− break
158− }
159− }
160− }
161− }
162−
163− oldPos := m.viewport.YPosition
164− u, cmd := m.viewport.Update(msg)
165− m.viewport = u
166− m.needsRerender = m.needsRerender || m.viewport.YPosition != oldPos
167− cmds = append(cmds, cmd)
168−
169− spinner, cmd := m.spinner.Update(msg)
170− m.spinner = spinner
171− cmds = append(cmds, cmd)
172−
173− if m.needsRerender {
174− m.renderView()
175− if len(m.messages) > 0 {
176− if msg, ok := msg.(pubsub.Event[message.Message]); ok {
177− if (msg.Type == pubsub.CreatedEvent) ||
178− (msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == m.messages[len(m.messages)-1].ID) {
179− m.viewport.GotoBottom()
180− }
181− }
182− }
183− m.needsRerender = false
184− }
185− return m, tea.Batch(cmds...)
186−}
187−
188−func (m *messagesCmp) IsAgentWorking() bool {
189− return m.app.CoderAgent.IsSessionBusy(m.session.ID)
190−}
191−
192−func (m *messagesCmp) renderSimpleMessage(msg message.Message, info ...string) string {
193− // Check if this is the last message in the list
194− isLastMessage := len(m.messages) > 0 && m.messages[len(m.messages)-1].ID == msg.ID
195−
196− // Only use cache for non-last messages
197− if !isLastMessage {
198− if v, ok := m.cachedContent[msg.ID]; ok {
199− return v
200− }
201− }
202−
203− style := styles.BaseStyle.
204− Width(m.width).
205− BorderLeft(true).
206− Foreground(styles.ForgroundDim).
207− BorderForeground(styles.ForgroundDim).
208− BorderStyle(lipgloss.ThickBorder())
209−
210− renderer := m.renderer
211− if msg.ID == m.currentMsgID {
212− style = style.
213− Foreground(styles.Forground).
214− BorderForeground(styles.Blue).
215− BorderStyle(lipgloss.ThickBorder())
216− renderer = m.focusRenderer
217− }
218− c, _ := renderer.Render(msg.Content().String())
219− parts := []string{
220− styles.ForceReplaceBackgroundWithLipgloss(c, styles.Background),
221− }
222− // remove newline at the end
223− parts[0] = strings.TrimSuffix(parts[0], "\n")
224− if len(info) > 0 {
225− parts = append(parts, info...)
226− }
227− rendered := style.Render(
228− lipgloss.JoinVertical(
229− lipgloss.Left,
230− parts...,
231− ),
232− )
233−
234− // Only cache if it's not the last message
235− if !isLastMessage {
236− m.cachedContent[msg.ID] = rendered
237− }
238−
239− return rendered
240−}
241−
242−func formatTimeDifference(unixTime1, unixTime2 int64) string {
243− diffSeconds := float64(math.Abs(float64(unixTime2 - unixTime1)))
244−
245− if diffSeconds < 60 {
246− return fmt.Sprintf("%.1fs", diffSeconds)
247− }
248−
249− minutes := int(diffSeconds / 60)
250− seconds := int(diffSeconds) % 60
251− return fmt.Sprintf("%dm%ds", minutes, seconds)
252−}
253−
254−func (m *messagesCmp) findToolResponse(callID string) *message.ToolResult {
255− for _, v := range m.messages {
256− for _, c := range v.ToolResults() {
257− if c.ToolCallID == callID {
258− return &c
259− }
260− }
261− }
262− return nil
263−}
264−
265−func (m *messagesCmp) renderToolCall(toolCall message.ToolCall, isNested bool) string {
266− key := ""
267− value := ""
268− result := styles.BaseStyle.Foreground(styles.PrimaryColor).Render(m.spinner.View() + " waiting for response...")
269−
270− response := m.findToolResponse(toolCall.ID)
271− if response != nil && response.IsError {
272− // Clean up error message for display by removing newlines
273− // This ensures error messages display properly in the UI
274− errMsg := strings.ReplaceAll(response.Content, "\n", " ")
275− result = styles.BaseStyle.Foreground(styles.Error).Render(ansi.Truncate(errMsg, 40, "..."))
276− } else if response != nil {
277− result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render("Done")
278− }
279− switch toolCall.Name {
280− // TODO: add result data to the tools
281− case agent.AgentToolName:
282− key = "Task"
283− var params agent.AgentParams
284− json.Unmarshal([]byte(toolCall.Input), &params)
285− value = strings.ReplaceAll(params.Prompt, "\n", " ")
286− if response != nil && !response.IsError {
287− firstRow := strings.ReplaceAll(response.Content, "\n", " ")
288− result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(ansi.Truncate(firstRow, 40, "..."))
289− }
290− case tools.BashToolName:
291− key = "Bash"
292− var params tools.BashParams
293− json.Unmarshal([]byte(toolCall.Input), &params)
294− value = params.Command
295− if response != nil && !response.IsError {
296− metadata := tools.BashResponseMetadata{}
297− json.Unmarshal([]byte(response.Metadata), &metadata)
298− result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("Took %s", formatTimeDifference(metadata.StartTime, metadata.EndTime)))
299− }
300−
301− case tools.EditToolName:
302− key = "Edit"
303− var params tools.EditParams
304− json.Unmarshal([]byte(toolCall.Input), &params)
305− value = params.FilePath
306− if response != nil && !response.IsError {
307− metadata := tools.EditResponseMetadata{}
308− json.Unmarshal([]byte(response.Metadata), &metadata)
309− result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d Additions %d Removals", metadata.Additions, metadata.Removals))
310− }
311− case tools.FetchToolName:
312− key = "Fetch"
313− var params tools.FetchParams
314− json.Unmarshal([]byte(toolCall.Input), &params)
315− value = params.URL
316− if response != nil && !response.IsError {
317− result = styles.BaseStyle.Foreground(styles.Error).Render(response.Content)
318− }
319− case tools.GlobToolName:
320− key = "Glob"
321− var params tools.GlobParams
322− json.Unmarshal([]byte(toolCall.Input), &params)
323− if params.Path == "" {
324− params.Path = "."
325− }
326− value = fmt.Sprintf("%s (%s)", params.Pattern, params.Path)
327− if response != nil && !response.IsError {
328− metadata := tools.GlobResponseMetadata{}
329− json.Unmarshal([]byte(response.Metadata), &metadata)
330− if metadata.Truncated {
331− result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found (truncated)", metadata.NumberOfFiles))
332− } else {
333− result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found", metadata.NumberOfFiles))
334− }
335− }
336− case tools.GrepToolName:
337− key = "Grep"
338− var params tools.GrepParams
339− json.Unmarshal([]byte(toolCall.Input), &params)
340− if params.Path == "" {
341− params.Path = "."
342− }
343− value = fmt.Sprintf("%s (%s)", params.Pattern, params.Path)
344− if response != nil && !response.IsError {
345− metadata := tools.GrepResponseMetadata{}
346− json.Unmarshal([]byte(response.Metadata), &metadata)
347− if metadata.Truncated {
348− result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found (truncated)", metadata.NumberOfMatches))
349− } else {
350− result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found", metadata.NumberOfMatches))
351− }
352− }
353− case tools.LSToolName:
354− key = "ls"
355− var params tools.LSParams
356− json.Unmarshal([]byte(toolCall.Input), &params)
357− if params.Path == "" {
358− params.Path = "."
359− }
360− value = params.Path
361− if response != nil && !response.IsError {
362− metadata := tools.LSResponseMetadata{}
363− json.Unmarshal([]byte(response.Metadata), &metadata)
364− if metadata.Truncated {
365− result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found (truncated)", metadata.NumberOfFiles))
366− } else {
367− result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found", metadata.NumberOfFiles))
368− }
369− }
370− case tools.SourcegraphToolName:
371− key = "Sourcegraph"
372− var params tools.SourcegraphParams
373− json.Unmarshal([]byte(toolCall.Input), &params)
374− value = params.Query
375− if response != nil && !response.IsError {
376− metadata := tools.SourcegraphResponseMetadata{}
377− json.Unmarshal([]byte(response.Metadata), &metadata)
378− if metadata.Truncated {
379− result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d matches found (truncated)", metadata.NumberOfMatches))
380− } else {
381− result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d matches found", metadata.NumberOfMatches))
382− }
383− }
384− case tools.ViewToolName:
385− key = "View"
386− var params tools.ViewParams
387− json.Unmarshal([]byte(toolCall.Input), &params)
388− value = params.FilePath
389− case tools.WriteToolName:
390− key = "Write"
391− var params tools.WriteParams
392− json.Unmarshal([]byte(toolCall.Input), &params)
393− value = params.FilePath
394− if response != nil && !response.IsError {
395− metadata := tools.WriteResponseMetadata{}
396− json.Unmarshal([]byte(response.Metadata), &metadata)
397−
398− result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d Additions %d Removals", metadata.Additions, metadata.Removals))
399− }
400− default:
401− key = toolCall.Name
402− var params map[string]any
403− json.Unmarshal([]byte(toolCall.Input), &params)
404− jsonData, _ := json.Marshal(params)
405− value = string(jsonData)
406− }
407−
408− style := styles.BaseStyle.
409− Width(m.width).
410− BorderLeft(true).
411− BorderStyle(lipgloss.ThickBorder()).
412− PaddingLeft(1).
413− BorderForeground(styles.Yellow)
414−
415− keyStyle := styles.BaseStyle.
416− Foreground(styles.ForgroundDim)
417− valyeStyle := styles.BaseStyle.
418− Foreground(styles.Forground)
419−
420− if isNested {
421− valyeStyle = valyeStyle.Foreground(styles.ForgroundMid)
422− }
423− keyValye := keyStyle.Render(
424− fmt.Sprintf("%s: ", key),
425− )
426− if !isNested {
427− value = valyeStyle.
428− Render(
429− ansi.Truncate(
430− value+" ",
431− m.width-lipgloss.Width(keyValye)-2-lipgloss.Width(result),
432− "...",
433− ),
434− )
435− value += result
436−
437− } else {
438− keyValye = keyStyle.Render(
439− fmt.Sprintf(" └ %s: ", key),
440− )
441− value = valyeStyle.
442− Width(m.width - lipgloss.Width(keyValye) - 2).
443− Render(
444− ansi.Truncate(
445− value,
446− m.width-lipgloss.Width(keyValye)-2,
447− "...",
448− ),
449− )
450− }
451−
452− innerToolCalls := make([]string, 0)
453− if toolCall.Name == agent.AgentToolName {
454− messages, _ := m.app.Messages.List(context.Background(), toolCall.ID)
455− toolCalls := make([]message.ToolCall, 0)
456− for _, v := range messages {
457− toolCalls = append(toolCalls, v.ToolCalls()...)
458− }
459− for _, v := range toolCalls {
460− call := m.renderToolCall(v, true)
461− innerToolCalls = append(innerToolCalls, call)
462− }
463− }
464−
465− if isNested {
466− return lipgloss.JoinHorizontal(
467− lipgloss.Left,
468− keyValye,
469− value,
470− )
471− }
472− callContent := lipgloss.JoinHorizontal(
473− lipgloss.Left,
474− keyValye,
475− value,
476− )
477− callContent = strings.ReplaceAll(callContent, "\n", "")
478− if len(innerToolCalls) > 0 {
479− callContent = lipgloss.JoinVertical(
480− lipgloss.Left,
481− callContent,
482− lipgloss.JoinVertical(
483− lipgloss.Left,
484− innerToolCalls...,
485− ),
486− )
487− }
488− return style.Render(callContent)
489−}
490−
491−func (m *messagesCmp) renderAssistantMessage(msg message.Message) []uiMessage {
492− // find the user message that is before this assistant message
493− var userMsg message.Message
494− for i := len(m.messages) - 1; i >= 0; i-- {
495− if m.messages[i].Role == message.User {
496− userMsg = m.messages[i]
497− break
498− }
499− }
500− messages := make([]uiMessage, 0)
501− if msg.Content().String() != "" {
502− info := make([]string, 0)
503− if msg.IsFinished() && msg.FinishReason() == "end_turn" {
504− finish := msg.FinishPart()
505− took := formatTimeDifference(userMsg.CreatedAt, finish.Time)
506−
507− info = append(info, styles.BaseStyle.Width(m.width-1).Foreground(styles.ForgroundDim).Render(
508− fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, took),
509− ))
510− }
511− content := m.renderSimpleMessage(msg, info...)
512− messages = append(messages, uiMessage{
513− messageType: assistantMessageType,
514− position: 0, // gets updated in renderView
515− height: lipgloss.Height(content),
516− content: content,
517− })
518− }
519− for _, v := range msg.ToolCalls() {
520− content := m.renderToolCall(v, false)
521− messages = append(messages,
522− uiMessage{
523− messageType: toolMessageType,
524− position: 0, // gets updated in renderView
525− height: lipgloss.Height(content),
526− content: content,
527− },
528− )
529− }
530−
531− return messages
532−}
533−
534−func (m *messagesCmp) renderView() {
535− m.uiMessages = make([]uiMessage, 0)
536− pos := 0
537−
538− // If we have messages, ensure the last message is not cached
539− // This ensures we always render the latest content for the most recent message
540− // which may be actively updating (e.g., during generation)
541− if len(m.messages) > 0 {
542− lastMsgID := m.messages[len(m.messages)-1].ID
543− delete(m.cachedContent, lastMsgID)
544− }
545−
546− // Limit cache to 10 messages
547− if len(m.cachedContent) > 15 {
548− // Create a list of keys to delete (oldest messages first)
549− keys := make([]string, 0, len(m.cachedContent))
550− for k := range m.cachedContent {
551− keys = append(keys, k)
552− }
553− // Delete oldest messages until we have 10 or fewer
554− for i := 0; i < len(keys)-15; i++ {
555− delete(m.cachedContent, keys[i])
556− }
557− }
558−
559− for _, v := range m.messages {
560− switch v.Role {
561− case message.User:
562− content := m.renderSimpleMessage(v)
563− m.uiMessages = append(m.uiMessages, uiMessage{
564− messageType: userMessageType,
565− position: pos,
566− height: lipgloss.Height(content),
567− content: content,
568− })
569− pos += lipgloss.Height(content) + 1 // + 1 for spacing
570− case message.Assistant:
571− assistantMessages := m.renderAssistantMessage(v)
572− for _, msg := range assistantMessages {
573− msg.position = pos
574− m.uiMessages = append(m.uiMessages, msg)
575− pos += msg.height + 1 // + 1 for spacing
576− }
577−
578− }
579− }
580−
581− messages := make([]string, 0)
582− for _, v := range m.uiMessages {
583− messages = append(messages, v.content,
584− styles.BaseStyle.
585− Width(m.width).
586− Render(
587− "",
588− ),
589− )
590− }
591− m.viewport.SetContent(
592− styles.BaseStyle.
593− Width(m.width).
594− Render(
595− lipgloss.JoinVertical(
596− lipgloss.Top,
597− messages...,
598− ),
599− ),
600− )
601−}
602−
603−func (m *messagesCmp) View() string {
604− if len(m.messages) == 0 {
605− content := styles.BaseStyle.
606− Width(m.width).
607− Height(m.height - 1).
608− Render(
609− m.initialScreen(),
610− )
611−
612− return styles.BaseStyle.
613− Width(m.width).
614− Render(
615− lipgloss.JoinVertical(
616− lipgloss.Top,
617− content,
618− m.help(),
619− ),
620− )
621− }
622−
623− return styles.BaseStyle.
624− Width(m.width).
625− Render(
626− lipgloss.JoinVertical(
627− lipgloss.Top,
628− m.viewport.View(),
629− m.help(),
630− ),
631− )
632−}
633−
634−func (m *messagesCmp) help() string {
635− text := ""
636−
637− if m.IsAgentWorking() {
638− text += styles.BaseStyle.Foreground(styles.PrimaryColor).Bold(true).Render(
639− fmt.Sprintf("%s %s ", m.spinner.View(), "Generating..."),
640− )
641− }
642− if m.writingMode {
643− text += lipgloss.JoinHorizontal(
644− lipgloss.Left,
645− styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "),
646− styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("esc"),
647− styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render(" to exit writing mode"),
648− )
649− } else {
650− text += lipgloss.JoinHorizontal(
651− lipgloss.Left,
652− styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "),
653− styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("i"),
654− styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render(" to start writing"),
655− )
656− }
657−
658− return styles.BaseStyle.
659− Width(m.width).
660− Render(text)
661−}
662−
663−func (m *messagesCmp) initialScreen() string {
664− return styles.BaseStyle.Width(m.width).Render(
665− lipgloss.JoinVertical(
666− lipgloss.Top,
667− header(m.width),
668− "",
669− lspsConfigured(m.width),
670− ),
671− )
672−}
673−
674−func (m *messagesCmp) SetSize(width, height int) {
675− m.width = width
676− m.height = height
677− m.viewport.Width = width
678− m.viewport.Height = height - 1
679− focusRenderer, _ := glamour.NewTermRenderer(
680− glamour.WithStyles(styles.MarkdownTheme(true)),
681− glamour.WithWordWrap(width-1),
682− )
683− renderer, _ := glamour.NewTermRenderer(
684− glamour.WithStyles(styles.MarkdownTheme(false)),
685− glamour.WithWordWrap(width-1),
686− )
687− m.focusRenderer = focusRenderer
688− // clear the cached content
689− for k := range m.cachedContent {
690− delete(m.cachedContent, k)
691− }
692− m.renderer = renderer
693− if len(m.messages) > 0 {
694− m.renderView()
695− m.viewport.GotoBottom()
696− }
697−}
698−
699−func (m *messagesCmp) GetSize() (int, int) {
700− return m.width, m.height
701−}
702−
703−func (m *messagesCmp) SetSession(session session.Session) tea.Cmd {
704− m.session = session
705− messages, err := m.app.Messages.List(context.Background(), session.ID)
706− if err != nil {
707− return util.ReportError(err)
708− }
709− m.messages = messages
710− m.currentMsgID = m.messages[len(m.messages)-1].ID
711− m.needsRerender = true
712− m.cachedContent = make(map[string]string)
713− return nil
714−}
715−
716−func (m *messagesCmp) BindingKeys() []key.Binding {
717− bindings := layout.KeyMapToSlice(m.viewport.KeyMap)
718− return bindings
719−}
720−
721−func NewMessagesCmp(app *app.App) tea.Model {
722− focusRenderer, _ := glamour.NewTermRenderer(
723− glamour.WithStyles(styles.MarkdownTheme(true)),
724− glamour.WithWordWrap(80),
725− )
726− renderer, _ := glamour.NewTermRenderer(
727− glamour.WithStyles(styles.MarkdownTheme(false)),
728− glamour.WithWordWrap(80),
729− )
730−
731− s := spinner.New()
732− s.Spinner = spinner.Pulse
733− return &messagesCmp{
734− app: app,
735− writingMode: true,
736− cachedContent: make(map[string]string),
737− viewport: viewport.New(0, 0),
738− focusRenderer: focusRenderer,
739− renderer: renderer,
740− spinner: s,
741− }
742−}
internal/tui/components/chat/sidebar.go+67−27
@@ -51,6 +51,12 @@ func (m *sidebarCmp) Init() tea.Cmd {
51
51
52func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
52func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
53 switch msg := msg.(type) {
53 switch msg := msg.(type) {
54+ case SessionSelectedMsg:
55+ if msg.ID != m.session.ID {
56+ m.session = msg
57+ ctx := context.Background()
58+ m.loadModifiedFiles(ctx)
59+ }
54 case pubsub.Event[session.Session]:
60 case pubsub.Event[session.Session]:
55 if msg.Type == pubsub.UpdatedEvent {
61 if msg.Type == pubsub.UpdatedEvent {
56 if m.session.ID == msg.Payload.ID {
62 if m.session.ID == msg.Payload.ID {
@@ -59,10 +65,16 @@ func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
59 }
65 }
60 case pubsub.Event[history.File]:
66 case pubsub.Event[history.File]:
61 if msg.Payload.SessionID == m.session.ID {
67 if msg.Payload.SessionID == m.session.ID {
62− // When a file changes, reload all modified files
68+ // Process the individual file change instead of reloading all files
63− // This ensures we have the complete and accurate list
64 ctx := context.Background()
69 ctx := context.Background()
65− m.loadModifiedFiles(ctx)
70+ m.processFileChanges(ctx, msg.Payload)
71+
72+ // Return a command to continue receiving events
73+ return m, func() tea.Msg {
74+ ctx := context.Background()
75+ filesCh := m.history.Subscribe(ctx)
76+ return <-filesCh
77+ }
66 }
78 }
67 }
79 }
68 return m, nil
80 return m, nil
@@ -71,6 +83,8 @@ func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
71func (m *sidebarCmp) View() string {
83func (m *sidebarCmp) View() string {
72 return styles.BaseStyle.
84 return styles.BaseStyle.
73 Width(m.width).
85 Width(m.width).
86+ PaddingLeft(4).
87+ PaddingRight(2).
74 Height(m.height - 1).
88 Height(m.height - 1).
75 Render(
89 Render(
76 lipgloss.JoinVertical(
90 lipgloss.JoinVertical(
@@ -79,9 +93,9 @@ func (m *sidebarCmp) View() string {
79 " ",
93 " ",
80 m.sessionSection(),
94 m.sessionSection(),
81 " ",
95 " ",
82− m.modifiedFiles(),
83− " ",
84 lspsConfigured(m.width),
96 lspsConfigured(m.width),
97+ " ",
98+ m.modifiedFiles(),
85 ),
99 ),
86 )
100 )
87}
101}
@@ -170,9 +184,10 @@ func (m *sidebarCmp) modifiedFiles() string {
170 )
184 )
171}
185}
172
186
173−func (m *sidebarCmp) SetSize(width, height int) {
187+func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
174 m.width = width
188 m.width = width
175 m.height = height
189 m.height = height
190+ return nil
176}
191}
177
192
178func (m *sidebarCmp) GetSize() (int, int) {
193func (m *sidebarCmp) GetSize() (int, int) {
@@ -203,6 +218,12 @@ func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) {
203 return
218 return
204 }
219 }
205
220
221+ // Clear the existing map to rebuild it
222+ m.modFiles = make(map[string]struct {
223+ additions int
224+ removals int
225+ })
226+
206 // Process each latest file
227 // Process each latest file
207 for _, file := range latestFiles {
228 for _, file := range latestFiles {
208 // Skip if this is the initial version (no changes to show)
229 // Skip if this is the initial version (no changes to show)
@@ -250,28 +271,23 @@ func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) {
250}
271}
251
272
252func (m *sidebarCmp) processFileChanges(ctx context.Context, file history.File) {
273func (m *sidebarCmp) processFileChanges(ctx context.Context, file history.File) {
253− // Skip if not the latest version
274+ // Skip if this is the initial version (no changes to show)
254 if file.Version == history.InitialVersion {
275 if file.Version == history.InitialVersion {
255 return
276 return
256 }
277 }
257
278
258− // Get all versions of this file
279+ // Find the initial version for this file
259− fileVersions, err := m.history.ListBySession(ctx, m.session.ID)
280+ initialVersion, err := m.findInitialVersion(ctx, file.Path)
260− if err != nil {
281+ if err != nil || initialVersion.ID == "" {
261 return
282 return
262 }
283 }
263
284
264− // Find the initial version
285+ // Skip if content hasn't changed
265− var initialVersion history.File
266− for _, v := range fileVersions {
267− if v.Path == file.Path && v.Version == history.InitialVersion {
268− initialVersion = v
269− break
270− }
271− }
272−
273− // Skip if we can't find the initial version
274− if initialVersion.ID == "" {
286+ if initialVersion.Content == file.Content {
287+ // If this file was previously modified but now matches the initial version,
288+ // remove it from the modified files list
289+ displayPath := getDisplayPath(file.Path)
290+ delete(m.modFiles, displayPath)
275 return
291 return
276 }
292 }
277
293
@@ -280,12 +296,7 @@ func (m *sidebarCmp) processFileChanges(ctx context.Context, file history.File)
280
296
281 // Only add to modified files if there are changes
297 // Only add to modified files if there are changes
282 if additions > 0 || removals > 0 {
298 if additions > 0 || removals > 0 {
283− // Remove working directory prefix from file path
284− displayPath := file.Path
299+ displayPath := getDisplayPath(file.Path)
285− workingDir := config.WorkingDirectory()
286− displayPath = strings.TrimPrefix(displayPath, workingDir)
287− displayPath = strings.TrimPrefix(displayPath, "/")
288−
289 m.modFiles[displayPath] = struct {
300 m.modFiles[displayPath] = struct {
290 additions int
301 additions int
291 removals int
302 removals int
@@ -293,5 +304,34 @@ func (m *sidebarCmp) processFileChanges(ctx context.Context, file history.File)
293 additions: additions,
304 additions: additions,
294 removals: removals,
305 removals: removals,
295 }
306 }
307+ } else {
308+ // If no changes, remove from modified files
309+ displayPath := getDisplayPath(file.Path)
310+ delete(m.modFiles, displayPath)
311+ }
312+}
313+
314+// Helper function to find the initial version of a file
315+func (m *sidebarCmp) findInitialVersion(ctx context.Context, path string) (history.File, error) {
316+ // Get all versions of this file for the session
317+ fileVersions, err := m.history.ListBySession(ctx, m.session.ID)
318+ if err != nil {
319+ return history.File{}, err
296 }
320 }
321+
322+ // Find the initial version
323+ for _, v := range fileVersions {
324+ if v.Path == path && v.Version == history.InitialVersion {
325+ return v, nil
326+ }
327+ }
328+
329+ return history.File{}, fmt.Errorf("initial version not found")
330+}
331+
332+// Helper function to get the display path for a file
333+func getDisplayPath(path string) string {
334+ workingDir := config.WorkingDirectory()
335+ displayPath := strings.TrimPrefix(path, workingDir)
336+ return strings.TrimPrefix(displayPath, "/")
297}
337}
internal/tui/components/core/status.go+19−5
@@ -166,19 +166,31 @@ func (m *statusCmp) projectDiagnostics() string {
166 diagnostics := []string{}
166 diagnostics := []string{}
167
167
168 if len(errorDiagnostics) > 0 {
168 if len(errorDiagnostics) > 0 {
169+ errStr := lipgloss.NewStyle().
170+ Background(styles.BackgroundDarker).
171+ Foreground(styles.Error).
169− errStr := lipgloss.NewStyle().Foreground(styles.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, len(errorDiagnostics)))
172+ Render(fmt.Sprintf("%s %d", styles.ErrorIcon, len(errorDiagnostics)))
170 diagnostics = append(diagnostics, errStr)
173 diagnostics = append(diagnostics, errStr)
171 }
174 }
172 if len(warnDiagnostics) > 0 {
175 if len(warnDiagnostics) > 0 {
176+ warnStr := lipgloss.NewStyle().
177+ Background(styles.BackgroundDarker).
178+ Foreground(styles.Warning).
173− warnStr := lipgloss.NewStyle().Foreground(styles.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, len(warnDiagnostics)))
179+ Render(fmt.Sprintf("%s %d", styles.WarningIcon, len(warnDiagnostics)))
174 diagnostics = append(diagnostics, warnStr)
180 diagnostics = append(diagnostics, warnStr)
175 }
181 }
176 if len(hintDiagnostics) > 0 {
182 if len(hintDiagnostics) > 0 {
183+ hintStr := lipgloss.NewStyle().
184+ Background(styles.BackgroundDarker).
185+ Foreground(styles.Text).
177− hintStr := lipgloss.NewStyle().Foreground(styles.Text).Render(fmt.Sprintf("%s %d", styles.HintIcon, len(hintDiagnostics)))
186+ Render(fmt.Sprintf("%s %d", styles.HintIcon, len(hintDiagnostics)))
178 diagnostics = append(diagnostics, hintStr)
187 diagnostics = append(diagnostics, hintStr)
179 }
188 }
180 if len(infoDiagnostics) > 0 {
189 if len(infoDiagnostics) > 0 {
190+ infoStr := lipgloss.NewStyle().
191+ Background(styles.BackgroundDarker).
192+ Foreground(styles.Peach).
181− infoStr := lipgloss.NewStyle().Foreground(styles.Peach).Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics)))
193+ Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics)))
182 diagnostics = append(diagnostics, infoStr)
194 diagnostics = append(diagnostics, infoStr)
183 }
195 }
184
196
@@ -187,10 +199,12 @@ func (m *statusCmp) projectDiagnostics() string {
187
199
188func (m statusCmp) availableFooterMsgWidth(diagnostics string) int {
200func (m statusCmp) availableFooterMsgWidth(diagnostics string) int {
189 tokens := ""
201 tokens := ""
202+ tokensWidth := 0
190 if m.session.ID != "" {
203 if m.session.ID != "" {
191 tokens = formatTokensAndCost(m.session.PromptTokens+m.session.CompletionTokens, m.session.Cost)
204 tokens = formatTokensAndCost(m.session.PromptTokens+m.session.CompletionTokens, m.session.Cost)
205+ tokensWidth = lipgloss.Width(tokens) + 2
192 }
206 }
193− return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-lipgloss.Width(tokens))
207+ return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-tokensWidth)
194}
208}
195
209
196func (m statusCmp) model() string {
210func (m statusCmp) model() string {
internal/tui/components/dialog/permission.go+9−7
@@ -36,7 +36,7 @@ type PermissionResponseMsg struct {
36type PermissionDialogCmp interface {
36type PermissionDialogCmp interface {
37 tea.Model
37 tea.Model
38 layout.Bindings
38 layout.Bindings
39− SetPermissions(permission permission.PermissionRequest)
39+ SetPermissions(permission permission.PermissionRequest) tea.Cmd
40}
40}
41
41
42type permissionsMapping struct {
42type permissionsMapping struct {
@@ -98,7 +98,8 @@ func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
98 switch msg := msg.(type) {
98 switch msg := msg.(type) {
99 case tea.WindowSizeMsg:
99 case tea.WindowSizeMsg:
100 p.windowSize = msg
100 p.windowSize = msg
101− p.SetSize()
101+ cmd := p.SetSize()
102+ cmds = append(cmds, cmd)
102 p.markdownCache = make(map[string]string)
103 p.markdownCache = make(map[string]string)
103 p.diffCache = make(map[string]string)
104 p.diffCache = make(map[string]string)
104 case tea.KeyMsg:
105 case tea.KeyMsg:
@@ -267,7 +268,7 @@ func (p *permissionDialogCmp) renderEditContent() string {
267}
268}
268
269
269func (p *permissionDialogCmp) renderPatchContent() string {
270func (p *permissionDialogCmp) renderPatchContent() string {
270− if pr, ok := p.permission.Params.(tools.PatchPermissionsParams); ok {
271+ if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
271 diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
272 diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
272 return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
273 return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
273 })
274 })
@@ -401,9 +402,9 @@ func (p *permissionDialogCmp) BindingKeys() []key.Binding {
401 return layout.KeyMapToSlice(helpKeys)
402 return layout.KeyMapToSlice(helpKeys)
402}
403}
403
404
404−func (p *permissionDialogCmp) SetSize() {
405+func (p *permissionDialogCmp) SetSize() tea.Cmd {
405 if p.permission.ID == "" {
406 if p.permission.ID == "" {
406− return
407+ return nil
407 }
408 }
408 switch p.permission.ToolName {
409 switch p.permission.ToolName {
409 case tools.BashToolName:
410 case tools.BashToolName:
@@ -422,11 +423,12 @@ func (p *permissionDialogCmp) SetSize() {
422 p.width = int(float64(p.windowSize.Width) * 0.7)
423 p.width = int(float64(p.windowSize.Width) * 0.7)
423 p.height = int(float64(p.windowSize.Height) * 0.5)
424 p.height = int(float64(p.windowSize.Height) * 0.5)
424 }
425 }
426+ return nil
425}
427}
426
428
427−func (p *permissionDialogCmp) SetPermissions(permission permission.PermissionRequest) {
429+func (p *permissionDialogCmp) SetPermissions(permission permission.PermissionRequest) tea.Cmd {
428 p.permission = permission
430 p.permission = permission
429− p.SetSize()
431+ return p.SetSize()
430}
432}
431
433
432// Helper to get or set cached diff content
434// Helper to get or set cached diff content
internal/tui/components/dialog/session.gonew file+224
@@ -0,0 +1,224 @@
1+package dialog
2+
3+import (
4+ "github.com/charmbracelet/bubbles/key"
5+ tea "github.com/charmbracelet/bubbletea"
6+ "github.com/charmbracelet/lipgloss"
7+ "github.com/kujtimiihoxha/opencode/internal/session"
8+ "github.com/kujtimiihoxha/opencode/internal/tui/layout"
9+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
10+ "github.com/kujtimiihoxha/opencode/internal/tui/util"
11+)
12+
13+// SessionSelectedMsg is sent when a session is selected
14+type SessionSelectedMsg struct {
15+ Session session.Session
16+}
17+
18+// CloseSessionDialogMsg is sent when the session dialog is closed
19+type CloseSessionDialogMsg struct{}
20+
21+// SessionDialog interface for the session switching dialog
22+type SessionDialog interface {
23+ tea.Model
24+ layout.Bindings
25+ SetSessions(sessions []session.Session)
26+ SetSelectedSession(sessionID string)
27+}
28+
29+type sessionDialogCmp struct {
30+ sessions []session.Session
31+ selectedIdx int
32+ width int
33+ height int
34+ selectedSessionID string
35+}
36+
37+type sessionKeyMap struct {
38+ Up key.Binding
39+ Down key.Binding
40+ Enter key.Binding
41+ Escape key.Binding
42+ J key.Binding
43+ K key.Binding
44+}
45+
46+var sessionKeys = sessionKeyMap{
47+ Up: key.NewBinding(
48+ key.WithKeys("up"),
49+ key.WithHelp("↑", "previous session"),
50+ ),
51+ Down: key.NewBinding(
52+ key.WithKeys("down"),
53+ key.WithHelp("↓", "next session"),
54+ ),
55+ Enter: key.NewBinding(
56+ key.WithKeys("enter"),
57+ key.WithHelp("enter", "select session"),
58+ ),
59+ Escape: key.NewBinding(
60+ key.WithKeys("esc"),
61+ key.WithHelp("esc", "close"),
62+ ),
63+ J: key.NewBinding(
64+ key.WithKeys("j"),
65+ key.WithHelp("j", "next session"),
66+ ),
67+ K: key.NewBinding(
68+ key.WithKeys("k"),
69+ key.WithHelp("k", "previous session"),
70+ ),
71+}
72+
73+func (s *sessionDialogCmp) Init() tea.Cmd {
74+ return nil
75+}
76+
77+func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
78+ switch msg := msg.(type) {
79+ case tea.KeyMsg:
80+ switch {
81+ case key.Matches(msg, sessionKeys.Up) || key.Matches(msg, sessionKeys.K):
82+ if s.selectedIdx > 0 {
83+ s.selectedIdx--
84+ }
85+ return s, nil
86+ case key.Matches(msg, sessionKeys.Down) || key.Matches(msg, sessionKeys.J):
87+ if s.selectedIdx < len(s.sessions)-1 {
88+ s.selectedIdx++
89+ }
90+ return s, nil
91+ case key.Matches(msg, sessionKeys.Enter):
92+ if len(s.sessions) > 0 {
93+ return s, util.CmdHandler(SessionSelectedMsg{
94+ Session: s.sessions[s.selectedIdx],
95+ })
96+ }
97+ case key.Matches(msg, sessionKeys.Escape):
98+ return s, util.CmdHandler(CloseSessionDialogMsg{})
99+ }
100+ case tea.WindowSizeMsg:
101+ s.width = msg.Width
102+ s.height = msg.Height
103+ }
104+ return s, nil
105+}
106+
107+func (s *sessionDialogCmp) View() string {
108+ if len(s.sessions) == 0 {
109+ return styles.BaseStyle.Padding(1, 2).
110+ Border(lipgloss.RoundedBorder()).
111+ BorderBackground(styles.Background).
112+ BorderForeground(styles.ForgroundDim).
113+ Width(40).
114+ Render("No sessions available")
115+ }
116+
117+ // Calculate max width needed for session titles
118+ maxWidth := 40 // Minimum width
119+ for _, sess := range s.sessions {
120+ if len(sess.Title) > maxWidth-4 { // Account for padding
121+ maxWidth = len(sess.Title) + 4
122+ }
123+ }
124+
125+ // Limit height to avoid taking up too much screen space
126+ maxVisibleSessions := min(10, len(s.sessions))
127+
128+ // Build the session list
129+ sessionItems := make([]string, 0, maxVisibleSessions)
130+ startIdx := 0
131+
132+ // If we have more sessions than can be displayed, adjust the start index
133+ if len(s.sessions) > maxVisibleSessions {
134+ // Center the selected item when possible
135+ halfVisible := maxVisibleSessions / 2
136+ if s.selectedIdx >= halfVisible && s.selectedIdx < len(s.sessions)-halfVisible {
137+ startIdx = s.selectedIdx - halfVisible
138+ } else if s.selectedIdx >= len(s.sessions)-halfVisible {
139+ startIdx = len(s.sessions) - maxVisibleSessions
140+ }
141+ }
142+
143+ endIdx := min(startIdx+maxVisibleSessions, len(s.sessions))
144+
145+ for i := startIdx; i < endIdx; i++ {
146+ sess := s.sessions[i]
147+ itemStyle := styles.BaseStyle.Width(maxWidth)
148+
149+ if i == s.selectedIdx {
150+ itemStyle = itemStyle.
151+ Background(styles.PrimaryColor).
152+ Foreground(styles.Background).
153+ Bold(true)
154+ }
155+
156+ sessionItems = append(sessionItems, itemStyle.Padding(0, 1).Render(sess.Title))
157+ }
158+
159+ title := styles.BaseStyle.
160+ Foreground(styles.PrimaryColor).
161+ Bold(true).
162+ Padding(0, 1).
163+ Render("Switch Session")
164+
165+ content := lipgloss.JoinVertical(
166+ lipgloss.Left,
167+ title,
168+ styles.BaseStyle.Render(""),
169+ lipgloss.JoinVertical(lipgloss.Left, sessionItems...),
170+ styles.BaseStyle.Render(""),
171+ styles.BaseStyle.Foreground(styles.ForgroundDim).Render("↑/k: up ↓/j: down enter: select esc: cancel"),
172+ )
173+
174+ return styles.BaseStyle.Padding(1, 2).
175+ Border(lipgloss.RoundedBorder()).
176+ BorderBackground(styles.Background).
177+ BorderForeground(styles.ForgroundDim).
178+ Width(lipgloss.Width(content) + 4).
179+ Render(content)
180+}
181+
182+func (s *sessionDialogCmp) BindingKeys() []key.Binding {
183+ return layout.KeyMapToSlice(sessionKeys)
184+}
185+
186+func (s *sessionDialogCmp) SetSessions(sessions []session.Session) {
187+ s.sessions = sessions
188+
189+ // If we have a selected session ID, find its index
190+ if s.selectedSessionID != "" {
191+ for i, sess := range sessions {
192+ if sess.ID == s.selectedSessionID {
193+ s.selectedIdx = i
194+ return
195+ }
196+ }
197+ }
198+
199+ // Default to first session if selected not found
200+ s.selectedIdx = 0
201+}
202+
203+func (s *sessionDialogCmp) SetSelectedSession(sessionID string) {
204+ s.selectedSessionID = sessionID
205+
206+ // Update the selected index if sessions are already loaded
207+ if len(s.sessions) > 0 {
208+ for i, sess := range s.sessions {
209+ if sess.ID == sessionID {
210+ s.selectedIdx = i
211+ return
212+ }
213+ }
214+ }
215+}
216+
217+// NewSessionDialogCmp creates a new session switching dialog
218+func NewSessionDialogCmp() SessionDialog {
219+ return &sessionDialogCmp{
220+ sessions: []session.Session{},
221+ selectedIdx: 0,
222+ selectedSessionID: "",
223+ }
224+}
internal/tui/components/logs/details.go+3−13
@@ -119,27 +119,17 @@ func (i *detailCmp) GetSize() (int, int) {
119 return i.width, i.height
119 return i.width, i.height
120}
120}
121
121
122−func (i *detailCmp) SetSize(width int, height int) {
122+func (i *detailCmp) SetSize(width int, height int) tea.Cmd {
123 i.width = width
123 i.width = width
124 i.height = height
124 i.height = height
125 i.viewport.Width = i.width
125 i.viewport.Width = i.width
126 i.viewport.Height = i.height
126 i.viewport.Height = i.height
127 i.updateContent()
127 i.updateContent()
128+ return nil
128}
129}
129
130
130func (i *detailCmp) BindingKeys() []key.Binding {
131func (i *detailCmp) BindingKeys() []key.Binding {
131− return []key.Binding{
132− i.viewport.KeyMap.PageDown,
133− i.viewport.KeyMap.PageUp,
134− i.viewport.KeyMap.HalfPageDown,
132+ return layout.KeyMapToSlice(i.viewport.KeyMap)
135− i.viewport.KeyMap.HalfPageUp,
136− }
137−}
138−
139−func (i *detailCmp) BorderText() map[layout.BorderPosition]string {
140− return map[layout.BorderPosition]string{
141− layout.TopLeftBorder: "Log Details",
142− }
143}
133}
144
134
145func NewLogsDetails() DetailComponent {
135func NewLogsDetails() DetailComponent {
internal/tui/components/logs/table.go+2−1
@@ -68,7 +68,7 @@ func (i *tableCmp) GetSize() (int, int) {
68 return i.table.Width(), i.table.Height()
68 return i.table.Width(), i.table.Height()
69}
69}
70
70
71−func (i *tableCmp) SetSize(width int, height int) {
71+func (i *tableCmp) SetSize(width int, height int) tea.Cmd {
72 i.table.SetWidth(width)
72 i.table.SetWidth(width)
73 i.table.SetHeight(height)
73 i.table.SetHeight(height)
74 cloumns := i.table.Columns()
74 cloumns := i.table.Columns()
@@ -77,6 +77,7 @@ func (i *tableCmp) SetSize(width int, height int) {
77 cloumns[i] = col
77 cloumns[i] = col
78 }
78 }
79 i.table.SetColumns(cloumns)
79 i.table.SetColumns(cloumns)
80+ return nil
80}
81}
81
82
82func (i *tableCmp) BindingKeys() []key.Binding {
83func (i *tableCmp) BindingKeys() []key.Binding {
internal/tui/layout/bento.godeleted−392
@@ -1,392 +0,0 @@
1−package layout
2−
3−import (
4− "github.com/charmbracelet/bubbles/key"
5− tea "github.com/charmbracelet/bubbletea"
6− "github.com/charmbracelet/lipgloss"
7−)
8−
9−type paneID string
10−
11−const (
12− BentoLeftPane paneID = "left"
13− BentoRightTopPane paneID = "right-top"
14− BentoRightBottomPane paneID = "right-bottom"
15−)
16−
17−type BentoPanes map[paneID]tea.Model
18−
19−const (
20− defaultLeftWidthRatio = 0.2
21− defaultRightTopHeightRatio = 0.85
22−
23− minLeftWidth = 10
24− minRightBottomHeight = 10
25−)
26−
27−type BentoLayout interface {
28− tea.Model
29− Sizeable
30− Bindings
31−}
32−
33−type BentoKeyBindings struct {
34− SwitchPane key.Binding
35− SwitchPaneBack key.Binding
36− HideCurrentPane key.Binding
37− ShowAllPanes key.Binding
38−}
39−
40−var defaultBentoKeyBindings = BentoKeyBindings{
41− SwitchPane: key.NewBinding(
42− key.WithKeys("tab"),
43− key.WithHelp("tab", "switch pane"),
44− ),
45− SwitchPaneBack: key.NewBinding(
46− key.WithKeys("shift+tab"),
47− key.WithHelp("shift+tab", "switch pane back"),
48− ),
49− HideCurrentPane: key.NewBinding(
50− key.WithKeys("X"),
51− key.WithHelp("X", "hide current pane"),
52− ),
53− ShowAllPanes: key.NewBinding(
54− key.WithKeys("R"),
55− key.WithHelp("R", "show all panes"),
56− ),
57−}
58−
59−type bentoLayout struct {
60− width int
61− height int
62−
63− leftWidthRatio float64
64− rightTopHeightRatio float64
65−
66− currentPane paneID
67− panes map[paneID]SinglePaneLayout
68− hiddenPanes map[paneID]bool
69−}
70−
71−func (b *bentoLayout) GetSize() (int, int) {
72− return b.width, b.height
73−}
74−
75−func (b *bentoLayout) Init() tea.Cmd {
76− var cmds []tea.Cmd
77− for _, pane := range b.panes {
78− cmd := pane.Init()
79− if cmd != nil {
80− cmds = append(cmds, cmd)
81− }
82− }
83− return tea.Batch(cmds...)
84−}
85−
86−func (b *bentoLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
87− switch msg := msg.(type) {
88− case tea.WindowSizeMsg:
89− b.SetSize(msg.Width, msg.Height)
90− return b, nil
91− case tea.KeyMsg:
92− switch {
93− case key.Matches(msg, defaultBentoKeyBindings.SwitchPane):
94− return b, b.SwitchPane(false)
95− case key.Matches(msg, defaultBentoKeyBindings.SwitchPaneBack):
96− return b, b.SwitchPane(true)
97− case key.Matches(msg, defaultBentoKeyBindings.HideCurrentPane):
98− return b, b.HidePane(b.currentPane)
99− case key.Matches(msg, defaultBentoKeyBindings.ShowAllPanes):
100− for id := range b.hiddenPanes {
101− delete(b.hiddenPanes, id)
102− }
103− b.SetSize(b.width, b.height)
104− return b, nil
105− }
106− }
107−
108− var cmds []tea.Cmd
109− for id, pane := range b.panes {
110− u, cmd := pane.Update(msg)
111− b.panes[id] = u.(SinglePaneLayout)
112− if cmd != nil {
113− cmds = append(cmds, cmd)
114− }
115− }
116− return b, tea.Batch(cmds...)
117−}
118−
119−func (b *bentoLayout) View() string {
120− if b.width <= 0 || b.height <= 0 {
121− return ""
122− }
123−
124− for id, pane := range b.panes {
125− if b.currentPane == id {
126− pane.Focus()
127− } else {
128− pane.Blur()
129− }
130− }
131−
132− leftVisible := false
133− rightTopVisible := false
134− rightBottomVisible := false
135−
136− var leftPane, rightTopPane, rightBottomPane string
137−
138− if pane, ok := b.panes[BentoLeftPane]; ok && !b.hiddenPanes[BentoLeftPane] {
139− leftPane = pane.View()
140− leftVisible = true
141− }
142−
143− if pane, ok := b.panes[BentoRightTopPane]; ok && !b.hiddenPanes[BentoRightTopPane] {
144− rightTopPane = pane.View()
145− rightTopVisible = true
146− }
147−
148− if pane, ok := b.panes[BentoRightBottomPane]; ok && !b.hiddenPanes[BentoRightBottomPane] {
149− rightBottomPane = pane.View()
150− rightBottomVisible = true
151− }
152−
153− if leftVisible {
154− if rightTopVisible || rightBottomVisible {
155− rightSection := ""
156− if rightTopVisible && rightBottomVisible {
157− rightSection = lipgloss.JoinVertical(lipgloss.Top, rightTopPane, rightBottomPane)
158− } else if rightTopVisible {
159− rightSection = rightTopPane
160− } else {
161− rightSection = rightBottomPane
162− }
163− return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(
164− lipgloss.JoinHorizontal(lipgloss.Left, leftPane, rightSection),
165− )
166− } else {
167− return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(leftPane)
168− }
169− } else if rightTopVisible || rightBottomVisible {
170− if rightTopVisible && rightBottomVisible {
171− return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(
172− lipgloss.JoinVertical(lipgloss.Top, rightTopPane, rightBottomPane),
173− )
174− } else if rightTopVisible {
175− return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(rightTopPane)
176− } else {
177− return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(rightBottomPane)
178− }
179− }
180− return ""
181−}
182−
183−func (b *bentoLayout) SetSize(width int, height int) {
184− if width < 0 || height < 0 {
185− return
186− }
187− b.width = width
188− b.height = height
189−
190− leftExists := false
191− rightTopExists := false
192− rightBottomExists := false
193−
194− if _, ok := b.panes[BentoLeftPane]; ok && !b.hiddenPanes[BentoLeftPane] {
195− leftExists = true
196− }
197− if _, ok := b.panes[BentoRightTopPane]; ok && !b.hiddenPanes[BentoRightTopPane] {
198− rightTopExists = true
199− }
200− if _, ok := b.panes[BentoRightBottomPane]; ok && !b.hiddenPanes[BentoRightBottomPane] {
201− rightBottomExists = true
202− }
203−
204− leftWidth := 0
205− rightWidth := 0
206− rightTopHeight := 0
207− rightBottomHeight := 0
208−
209− if leftExists && (rightTopExists || rightBottomExists) {
210− leftWidth = int(float64(width) * b.leftWidthRatio)
211− if leftWidth < minLeftWidth && width >= minLeftWidth {
212− leftWidth = minLeftWidth
213− }
214− rightWidth = width - leftWidth
215−
216− if rightTopExists && rightBottomExists {
217− rightTopHeight = int(float64(height) * b.rightTopHeightRatio)
218− rightBottomHeight = height - rightTopHeight
219−
220− if rightBottomHeight < minRightBottomHeight && height >= minRightBottomHeight {
221− rightBottomHeight = minRightBottomHeight
222− rightTopHeight = height - rightBottomHeight
223− }
224− } else if rightTopExists {
225− rightTopHeight = height
226− } else if rightBottomExists {
227− rightBottomHeight = height
228− }
229− } else if leftExists {
230− leftWidth = width
231− } else if rightTopExists || rightBottomExists {
232− rightWidth = width
233−
234− if rightTopExists && rightBottomExists {
235− rightTopHeight = int(float64(height) * b.rightTopHeightRatio)
236− rightBottomHeight = height - rightTopHeight
237−
238− if rightBottomHeight < minRightBottomHeight && height >= minRightBottomHeight {
239− rightBottomHeight = minRightBottomHeight
240− rightTopHeight = height - rightBottomHeight
241− }
242− } else if rightTopExists {
243− rightTopHeight = height
244− } else if rightBottomExists {
245− rightBottomHeight = height
246− }
247− }
248−
249− if pane, ok := b.panes[BentoLeftPane]; ok && !b.hiddenPanes[BentoLeftPane] {
250− pane.SetSize(leftWidth, height)
251− }
252− if pane, ok := b.panes[BentoRightTopPane]; ok && !b.hiddenPanes[BentoRightTopPane] {
253− pane.SetSize(rightWidth, rightTopHeight)
254− }
255− if pane, ok := b.panes[BentoRightBottomPane]; ok && !b.hiddenPanes[BentoRightBottomPane] {
256− pane.SetSize(rightWidth, rightBottomHeight)
257− }
258−}
259−
260−func (b *bentoLayout) HidePane(pane paneID) tea.Cmd {
261− if len(b.panes)-len(b.hiddenPanes) == 1 {
262− return nil
263− }
264− if _, ok := b.panes[pane]; ok {
265− b.hiddenPanes[pane] = true
266− }
267− b.SetSize(b.width, b.height)
268− return b.SwitchPane(false)
269−}
270−
271−func (b *bentoLayout) SwitchPane(back bool) tea.Cmd {
272− orderForward := []paneID{BentoLeftPane, BentoRightTopPane, BentoRightBottomPane}
273− orderBackward := []paneID{BentoLeftPane, BentoRightBottomPane, BentoRightTopPane}
274−
275− order := orderForward
276− if back {
277− order = orderBackward
278− }
279−
280− currentIdx := -1
281− for i, id := range order {
282− if id == b.currentPane {
283− currentIdx = i
284− break
285− }
286− }
287−
288− if currentIdx == -1 {
289− for _, id := range order {
290− if _, exists := b.panes[id]; exists {
291− if _, hidden := b.hiddenPanes[id]; !hidden {
292− b.currentPane = id
293− break
294− }
295− }
296− }
297− } else {
298− startIdx := currentIdx
299− for {
300− currentIdx = (currentIdx + 1) % len(order)
301−
302− nextID := order[currentIdx]
303− if _, exists := b.panes[nextID]; exists {
304− if _, hidden := b.hiddenPanes[nextID]; !hidden {
305− b.currentPane = nextID
306− break
307− }
308− }
309−
310− if currentIdx == startIdx {
311− break
312− }
313− }
314− }
315−
316− var cmds []tea.Cmd
317− for id, pane := range b.panes {
318− if _, ok := b.hiddenPanes[id]; ok {
319− continue
320− }
321− if id == b.currentPane {
322− cmds = append(cmds, pane.Focus())
323− } else {
324− cmds = append(cmds, pane.Blur())
325− }
326− }
327−
328− return tea.Batch(cmds...)
329−}
330−
331−func (s *bentoLayout) BindingKeys() []key.Binding {
332− bindings := KeyMapToSlice(defaultBentoKeyBindings)
333− if b, ok := s.panes[s.currentPane].(Bindings); ok {
334− bindings = append(bindings, b.BindingKeys()...)
335− }
336− return bindings
337−}
338−
339−type BentoLayoutOption func(*bentoLayout)
340−
341−func NewBentoLayout(panes BentoPanes, opts ...BentoLayoutOption) BentoLayout {
342− p := make(map[paneID]SinglePaneLayout, len(panes))
343− for id, pane := range panes {
344− if sp, ok := pane.(SinglePaneLayout); !ok {
345− p[id] = NewSinglePane(
346− pane,
347− WithSinglePaneFocusable(true),
348− WithSinglePaneBordered(true),
349− )
350− } else {
351− p[id] = sp
352− }
353− }
354− if len(p) == 0 {
355− panic("no panes provided for BentoLayout")
356− }
357− layout := &bentoLayout{
358− panes: p,
359− hiddenPanes: make(map[paneID]bool),
360− currentPane: BentoLeftPane,
361− leftWidthRatio: defaultLeftWidthRatio,
362− rightTopHeightRatio: defaultRightTopHeightRatio,
363− }
364−
365− for _, opt := range opts {
366− opt(layout)
367− }
368−
369− return layout
370−}
371−
372−func WithBentoLayoutLeftWidthRatio(ratio float64) BentoLayoutOption {
373− return func(b *bentoLayout) {
374− if ratio > 0 && ratio < 1 {
375− b.leftWidthRatio = ratio
376− }
377− }
378−}
379−
380−func WithBentoLayoutRightTopHeightRatio(ratio float64) BentoLayoutOption {
381− return func(b *bentoLayout) {
382− if ratio > 0 && ratio < 1 {
383− b.rightTopHeightRatio = ratio
384− }
385− }
386−}
387−
388−func WithBentoLayoutCurrentPane(pane paneID) BentoLayoutOption {
389− return func(b *bentoLayout) {
390− b.currentPane = pane
391− }
392−}
internal/tui/layout/border.godeleted−121
@@ -1,121 +0,0 @@
1−package layout
2−
3−import (
4− "fmt"
5− "strings"
6−
7− "github.com/charmbracelet/lipgloss"
8− "github.com/kujtimiihoxha/opencode/internal/tui/styles"
9−)
10−
11−type BorderPosition int
12−
13−const (
14− TopLeftBorder BorderPosition = iota
15− TopMiddleBorder
16− TopRightBorder
17− BottomLeftBorder
18− BottomMiddleBorder
19− BottomRightBorder
20−)
21−
22−var (
23− ActiveBorder = styles.Blue
24− InactivePreviewBorder = styles.Grey
25−)
26−
27−type BorderOptions struct {
28− Active bool
29− EmbeddedText map[BorderPosition]string
30− ActiveColor lipgloss.TerminalColor
31− InactiveColor lipgloss.TerminalColor
32− ActiveBorder lipgloss.Border
33− InactiveBorder lipgloss.Border
34−}
35−
36−func Borderize(content string, opts BorderOptions) string {
37− if opts.EmbeddedText == nil {
38− opts.EmbeddedText = make(map[BorderPosition]string)
39− }
40− if opts.ActiveColor == nil {
41− opts.ActiveColor = ActiveBorder
42− }
43− if opts.InactiveColor == nil {
44− opts.InactiveColor = InactivePreviewBorder
45− }
46− if opts.ActiveBorder == (lipgloss.Border{}) {
47− opts.ActiveBorder = lipgloss.ThickBorder()
48− }
49− if opts.InactiveBorder == (lipgloss.Border{}) {
50− opts.InactiveBorder = lipgloss.NormalBorder()
51− }
52−
53− var (
54− thickness = map[bool]lipgloss.Border{
55− true: opts.ActiveBorder,
56− false: opts.InactiveBorder,
57− }
58− color = map[bool]lipgloss.TerminalColor{
59− true: opts.ActiveColor,
60− false: opts.InactiveColor,
61− }
62− border = thickness[opts.Active]
63− style = lipgloss.NewStyle().Foreground(color[opts.Active])
64− width = lipgloss.Width(content)
65− )
66−
67− encloseInSquareBrackets := func(text string) string {
68− if text != "" {
69− return fmt.Sprintf("%s%s%s",
70− style.Render(border.TopRight),
71− text,
72− style.Render(border.TopLeft),
73− )
74− }
75− return text
76− }
77− buildHorizontalBorder := func(leftText, middleText, rightText, leftCorner, inbetween, rightCorner string) string {
78− leftText = encloseInSquareBrackets(leftText)
79− middleText = encloseInSquareBrackets(middleText)
80− rightText = encloseInSquareBrackets(rightText)
81− // Calculate length of border between embedded texts
82− remaining := max(0, width-lipgloss.Width(leftText)-lipgloss.Width(middleText)-lipgloss.Width(rightText))
83− leftBorderLen := max(0, (width/2)-lipgloss.Width(leftText)-(lipgloss.Width(middleText)/2))
84− rightBorderLen := max(0, remaining-leftBorderLen)
85− // Then construct border string
86− s := leftText +
87− style.Render(strings.Repeat(inbetween, leftBorderLen)) +
88− middleText +
89− style.Render(strings.Repeat(inbetween, rightBorderLen)) +
90− rightText
91− // Make it fit in the space available between the two corners.
92− s = lipgloss.NewStyle().
93− Inline(true).
94− MaxWidth(width).
95− Render(s)
96− // Add the corners
97− return style.Render(leftCorner) + s + style.Render(rightCorner)
98− }
99− // Stack top border, content and horizontal borders, and bottom border.
100− return strings.Join([]string{
101− buildHorizontalBorder(
102− opts.EmbeddedText[TopLeftBorder],
103− opts.EmbeddedText[TopMiddleBorder],
104− opts.EmbeddedText[TopRightBorder],
105− border.TopLeft,
106− border.Top,
107− border.TopRight,
108− ),
109− lipgloss.NewStyle().
110− BorderForeground(color[opts.Active]).
111− Border(border, false, true, false, true).Render(content),
112− buildHorizontalBorder(
113− opts.EmbeddedText[BottomLeftBorder],
114− opts.EmbeddedText[BottomMiddleBorder],
115− opts.EmbeddedText[BottomRightBorder],
116− border.BottomLeft,
117− border.Bottom,
118− border.BottomRight,
119− ),
120− }, "\n")
121−}
internal/tui/layout/container.go+3−2
@@ -86,7 +86,7 @@ func (c *container) View() string {
86 return style.Render(c.content.View())
86 return style.Render(c.content.View())
87}
87}
88
88
89−func (c *container) SetSize(width, height int) {
89+func (c *container) SetSize(width, height int) tea.Cmd {
90 c.width = width
90 c.width = width
91 c.height = height
91 c.height = height
92
92
@@ -113,8 +113,9 @@ func (c *container) SetSize(width, height int) {
113 // Set content size with adjusted dimensions
113 // Set content size with adjusted dimensions
114 contentWidth := max(0, width-horizontalSpace)
114 contentWidth := max(0, width-horizontalSpace)
115 contentHeight := max(0, height-verticalSpace)
115 contentHeight := max(0, height-verticalSpace)
116− sizeable.SetSize(contentWidth, contentHeight)
116+ return sizeable.SetSize(contentWidth, contentHeight)
117 }
117 }
118+ return nil
118}
119}
119
120
120func (c *container) GetSize() (int, int) {
121func (c *container) GetSize() (int, int) {
internal/tui/layout/grid.godeleted−254
@@ -1,254 +0,0 @@
1−package layout
2−
3−import (
4− "github.com/charmbracelet/bubbles/key"
5− tea "github.com/charmbracelet/bubbletea"
6− "github.com/charmbracelet/lipgloss"
7−)
8−
9−type GridLayout interface {
10− tea.Model
11− Sizeable
12− Bindings
13− Panes() [][]tea.Model
14−}
15−
16−type gridLayout struct {
17− width int
18− height int
19−
20− rows int
21− columns int
22−
23− panes [][]tea.Model
24−
25− gap int
26− bordered bool
27− focusable bool
28−
29− currentRow int
30− currentColumn int
31−
32− activeColor lipgloss.TerminalColor
33−}
34−
35−type GridOption func(*gridLayout)
36−
37−func (g *gridLayout) Init() tea.Cmd {
38− var cmds []tea.Cmd
39− for i := range g.panes {
40− for j := range g.panes[i] {
41− if g.panes[i][j] != nil {
42− cmds = append(cmds, g.panes[i][j].Init())
43− }
44− }
45− }
46− return tea.Batch(cmds...)
47−}
48−
49−func (g *gridLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
50− var cmds []tea.Cmd
51−
52− switch msg := msg.(type) {
53− case tea.WindowSizeMsg:
54− g.SetSize(msg.Width, msg.Height)
55− return g, nil
56− case tea.KeyMsg:
57− if key.Matches(msg, g.nextPaneBinding()) {
58− return g.focusNextPane()
59− }
60− }
61−
62− // Update all panes
63− for i := range g.panes {
64− for j := range g.panes[i] {
65− if g.panes[i][j] != nil {
66− var cmd tea.Cmd
67− g.panes[i][j], cmd = g.panes[i][j].Update(msg)
68− if cmd != nil {
69− cmds = append(cmds, cmd)
70− }
71− }
72− }
73− }
74−
75− return g, tea.Batch(cmds...)
76−}
77−
78−func (g *gridLayout) focusNextPane() (tea.Model, tea.Cmd) {
79− if !g.focusable {
80− return g, nil
81− }
82−
83− var cmds []tea.Cmd
84−
85− // Blur current pane
86− if g.currentRow < len(g.panes) && g.currentColumn < len(g.panes[g.currentRow]) {
87− if currentPane, ok := g.panes[g.currentRow][g.currentColumn].(Focusable); ok {
88− cmds = append(cmds, currentPane.Blur())
89− }
90− }
91−
92− // Find next valid pane
93− g.currentColumn++
94− if g.currentColumn >= len(g.panes[g.currentRow]) {
95− g.currentColumn = 0
96− g.currentRow++
97− if g.currentRow >= len(g.panes) {
98− g.currentRow = 0
99− }
100− }
101−
102− // Focus next pane
103− if g.currentRow < len(g.panes) && g.currentColumn < len(g.panes[g.currentRow]) {
104− if nextPane, ok := g.panes[g.currentRow][g.currentColumn].(Focusable); ok {
105− cmds = append(cmds, nextPane.Focus())
106− }
107− }
108−
109− return g, tea.Batch(cmds...)
110−}
111−
112−func (g *gridLayout) nextPaneBinding() key.Binding {
113− return key.NewBinding(
114− key.WithKeys("tab"),
115− key.WithHelp("tab", "next pane"),
116− )
117−}
118−
119−func (g *gridLayout) View() string {
120− if len(g.panes) == 0 {
121− return ""
122− }
123−
124− // Calculate dimensions for each cell
125− cellWidth := (g.width - (g.columns-1)*g.gap) / g.columns
126− cellHeight := (g.height - (g.rows-1)*g.gap) / g.rows
127−
128− // Render each row
129− rows := make([]string, g.rows)
130− for i := range g.rows {
131− // Render each column in this row
132− cols := make([]string, len(g.panes[i]))
133− for j := range g.panes[i] {
134− if g.panes[i][j] == nil {
135− cols[j] = ""
136− continue
137− }
138−
139− // Set size for each pane
140− if sizable, ok := g.panes[i][j].(Sizeable); ok {
141− effectiveWidth, effectiveHeight := cellWidth, cellHeight
142− if g.bordered {
143− effectiveWidth -= 2
144− effectiveHeight -= 2
145− }
146− sizable.SetSize(effectiveWidth, effectiveHeight)
147− }
148−
149− // Render the pane
150− content := g.panes[i][j].View()
151−
152− // Apply border if needed
153− if g.bordered {
154− isFocused := false
155− if focusable, ok := g.panes[i][j].(Focusable); ok {
156− isFocused = focusable.IsFocused()
157− }
158−
159− borderText := map[BorderPosition]string{}
160− if bordered, ok := g.panes[i][j].(Bordered); ok {
161− borderText = bordered.BorderText()
162− }
163−
164− content = Borderize(content, BorderOptions{
165− Active: isFocused,
166− EmbeddedText: borderText,
167− })
168− }
169−
170− cols[j] = content
171− }
172−
173− // Join columns with gap
174− rows[i] = lipgloss.JoinHorizontal(lipgloss.Top, cols...)
175− }
176−
177− // Join rows with gap
178− return lipgloss.JoinVertical(lipgloss.Left, rows...)
179−}
180−
181−func (g *gridLayout) SetSize(width, height int) {
182− g.width = width
183− g.height = height
184−}
185−
186−func (g *gridLayout) GetSize() (int, int) {
187− return g.width, g.height
188−}
189−
190−func (g *gridLayout) BindingKeys() []key.Binding {
191− var bindings []key.Binding
192− bindings = append(bindings, g.nextPaneBinding())
193−
194− // Collect bindings from all panes
195− for i := range g.panes {
196− for j := range g.panes[i] {
197− if g.panes[i][j] != nil {
198− if bindable, ok := g.panes[i][j].(Bindings); ok {
199− bindings = append(bindings, bindable.BindingKeys()...)
200− }
201− }
202− }
203− }
204−
205− return bindings
206−}
207−
208−func (g *gridLayout) Panes() [][]tea.Model {
209− return g.panes
210−}
211−
212−// NewGridLayout creates a new grid layout with the given number of rows and columns
213−func NewGridLayout(rows, cols int, panes [][]tea.Model, opts ...GridOption) GridLayout {
214− grid := &gridLayout{
215− rows: rows,
216− columns: cols,
217− panes: panes,
218− gap: 1,
219− }
220−
221− for _, opt := range opts {
222− opt(grid)
223− }
224−
225− return grid
226−}
227−
228−// WithGridGap sets the gap between cells
229−func WithGridGap(gap int) GridOption {
230− return func(g *gridLayout) {
231− g.gap = gap
232− }
233−}
234−
235−// WithGridBordered sets whether cells should have borders
236−func WithGridBordered(bordered bool) GridOption {
237− return func(g *gridLayout) {
238− g.bordered = bordered
239− }
240−}
241−
242−// WithGridFocusable sets whether the grid supports focus navigation
243−func WithGridFocusable(focusable bool) GridOption {
244− return func(g *gridLayout) {
245− g.focusable = focusable
246− }
247−}
248−
249−// WithGridActiveColor sets the active border color
250−func WithGridActiveColor(color lipgloss.TerminalColor) GridOption {
251− return func(g *gridLayout) {
252− g.activeColor = color
253− }
254−}
internal/tui/layout/layout.go+1−5
@@ -13,12 +13,8 @@ type Focusable interface {
13 IsFocused() bool
13 IsFocused() bool
14}
14}
15
15
16−type Bordered interface {
17− BorderText() map[BorderPosition]string
18−}
19−
20type Sizeable interface {
16type Sizeable interface {
21− SetSize(width, height int)
17+ SetSize(width, height int) tea.Cmd
22 GetSize() (int, int)
18 GetSize() (int, int)
23}
19}
24
20
internal/tui/layout/single.godeleted−189
@@ -1,189 +0,0 @@
1−package layout
2−
3−import (
4− "github.com/charmbracelet/bubbles/key"
5− tea "github.com/charmbracelet/bubbletea"
6− "github.com/charmbracelet/lipgloss"
7−)
8−
9−type SinglePaneLayout interface {
10− tea.Model
11− Focusable
12− Sizeable
13− Bindings
14− Pane() tea.Model
15−}
16−
17−type singlePaneLayout struct {
18− width int
19− height int
20−
21− focusable bool
22− focused bool
23−
24− bordered bool
25− borderText map[BorderPosition]string
26−
27− content tea.Model
28−
29− padding []int
30−
31− activeColor lipgloss.TerminalColor
32−}
33−
34−type SinglePaneOption func(*singlePaneLayout)
35−
36−func (s *singlePaneLayout) Init() tea.Cmd {
37− return s.content.Init()
38−}
39−
40−func (s *singlePaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
41− switch msg := msg.(type) {
42− case tea.WindowSizeMsg:
43− s.SetSize(msg.Width, msg.Height)
44− return s, nil
45− }
46− u, cmd := s.content.Update(msg)
47− s.content = u
48− return s, cmd
49−}
50−
51−func (s *singlePaneLayout) View() string {
52− style := lipgloss.NewStyle().Width(s.width).Height(s.height)
53− if s.bordered {
54− style = style.Width(s.width - 2).Height(s.height - 2)
55− }
56− if s.padding != nil {
57− style = style.Padding(s.padding...)
58− }
59− content := style.Render(s.content.View())
60− if s.bordered {
61− if s.borderText == nil {
62− s.borderText = map[BorderPosition]string{}
63− }
64− if bordered, ok := s.content.(Bordered); ok {
65− s.borderText = bordered.BorderText()
66− }
67− return Borderize(content, BorderOptions{
68− Active: s.focused,
69− EmbeddedText: s.borderText,
70− })
71− }
72− return content
73−}
74−
75−func (s *singlePaneLayout) Blur() tea.Cmd {
76− if s.focusable {
77− s.focused = false
78− }
79− if blurable, ok := s.content.(Focusable); ok {
80− return blurable.Blur()
81− }
82− return nil
83−}
84−
85−func (s *singlePaneLayout) Focus() tea.Cmd {
86− if s.focusable {
87− s.focused = true
88− }
89− if focusable, ok := s.content.(Focusable); ok {
90− return focusable.Focus()
91− }
92− return nil
93−}
94−
95−func (s *singlePaneLayout) SetSize(width, height int) {
96− s.width = width
97− s.height = height
98− childWidth, childHeight := s.width, s.height
99− if s.bordered {
100− childWidth -= 2
101− childHeight -= 2
102− }
103− if s.padding != nil {
104− if len(s.padding) == 1 {
105− childWidth -= s.padding[0] * 2
106− childHeight -= s.padding[0] * 2
107− } else if len(s.padding) == 2 {
108− childWidth -= s.padding[0] * 2
109− childHeight -= s.padding[1] * 2
110− } else if len(s.padding) == 3 {
111− childWidth -= s.padding[0] * 2
112− childHeight -= s.padding[1] + s.padding[2]
113− } else if len(s.padding) == 4 {
114− childWidth -= s.padding[0] + s.padding[2]
115− childHeight -= s.padding[1] + s.padding[3]
116− }
117− }
118− if s.content != nil {
119− if c, ok := s.content.(Sizeable); ok {
120− c.SetSize(childWidth, childHeight)
121− }
122− }
123−}
124−
125−func (s *singlePaneLayout) IsFocused() bool {
126− return s.focused
127−}
128−
129−func (s *singlePaneLayout) GetSize() (int, int) {
130− return s.width, s.height
131−}
132−
133−func (s *singlePaneLayout) BindingKeys() []key.Binding {
134− if b, ok := s.content.(Bindings); ok {
135− return b.BindingKeys()
136− }
137− return []key.Binding{}
138−}
139−
140−func (s *singlePaneLayout) Pane() tea.Model {
141− return s.content
142−}
143−
144−func NewSinglePane(content tea.Model, opts ...SinglePaneOption) SinglePaneLayout {
145− layout := &singlePaneLayout{
146− content: content,
147− }
148− for _, opt := range opts {
149− opt(layout)
150− }
151− return layout
152−}
153−
154−func WithSinglePaneSize(width, height int) SinglePaneOption {
155− return func(opts *singlePaneLayout) {
156− opts.width = width
157− opts.height = height
158− }
159−}
160−
161−func WithSinglePaneFocusable(focusable bool) SinglePaneOption {
162− return func(opts *singlePaneLayout) {
163− opts.focusable = focusable
164− }
165−}
166−
167−func WithSinglePaneBordered(bordered bool) SinglePaneOption {
168− return func(opts *singlePaneLayout) {
169− opts.bordered = bordered
170− }
171−}
172−
173−func WithSinglePaneBorderText(borderText map[BorderPosition]string) SinglePaneOption {
174− return func(opts *singlePaneLayout) {
175− opts.borderText = borderText
176− }
177−}
178−
179−func WithSinglePanePadding(padding ...int) SinglePaneOption {
180− return func(opts *singlePaneLayout) {
181− opts.padding = padding
182− }
183−}
184−
185−func WithSinglePaneActiveColor(color lipgloss.TerminalColor) SinglePaneOption {
186− return func(opts *singlePaneLayout) {
187− opts.activeColor = color
188− }
189−}
internal/tui/layout/split.go+22−15
@@ -11,9 +11,9 @@ type SplitPaneLayout interface {
11 tea.Model
11 tea.Model
12 Sizeable
12 Sizeable
13 Bindings
13 Bindings
14− SetLeftPanel(panel Container)
14+ SetLeftPanel(panel Container) tea.Cmd
15− SetRightPanel(panel Container)
15+ SetRightPanel(panel Container) tea.Cmd
16− SetBottomPanel(panel Container)
16+ SetBottomPanel(panel Container) tea.Cmd
17}
17}
18
18
19type splitPaneLayout struct {
19type splitPaneLayout struct {
@@ -53,8 +53,7 @@ func (s *splitPaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
53 var cmds []tea.Cmd
53 var cmds []tea.Cmd
54 switch msg := msg.(type) {
54 switch msg := msg.(type) {
55 case tea.WindowSizeMsg:
55 case tea.WindowSizeMsg:
56− s.SetSize(msg.Width, msg.Height)
56+ return s, s.SetSize(msg.Width, msg.Height)
57− return s, nil
58 }
57 }
59
58
60 if s.rightPanel != nil {
59 if s.rightPanel != nil {
@@ -122,7 +121,7 @@ func (s *splitPaneLayout) View() string {
122 return finalView
121 return finalView
123}
122}
124
123
125−func (s *splitPaneLayout) SetSize(width, height int) {
124+func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
126 s.width = width
125 s.width = width
127 s.height = height
126 s.height = height
128
127
@@ -147,42 +146,50 @@ func (s *splitPaneLayout) SetSize(width, height int) {
147 rightWidth = width
146 rightWidth = width
148 }
147 }
149
148
149+ var cmds []tea.Cmd
150 if s.leftPanel != nil {
150 if s.leftPanel != nil {
151− s.leftPanel.SetSize(leftWidth, topHeight)
151+ cmd := s.leftPanel.SetSize(leftWidth, topHeight)
152+ cmds = append(cmds, cmd)
152 }
153 }
153
154
154 if s.rightPanel != nil {
155 if s.rightPanel != nil {
155− s.rightPanel.SetSize(rightWidth, topHeight)
156+ cmd := s.rightPanel.SetSize(rightWidth, topHeight)
157+ cmds = append(cmds, cmd)
156 }
158 }
157
159
158 if s.bottomPanel != nil {
160 if s.bottomPanel != nil {
159− s.bottomPanel.SetSize(width, bottomHeight)
161+ cmd := s.bottomPanel.SetSize(width, bottomHeight)
162+ cmds = append(cmds, cmd)
160 }
163 }
164+ return tea.Batch(cmds...)
161}
165}
162
166
163func (s *splitPaneLayout) GetSize() (int, int) {
167func (s *splitPaneLayout) GetSize() (int, int) {
164 return s.width, s.height
168 return s.width, s.height
165}
169}
166
170
167−func (s *splitPaneLayout) SetLeftPanel(panel Container) {
171+func (s *splitPaneLayout) SetLeftPanel(panel Container) tea.Cmd {
168 s.leftPanel = panel
172 s.leftPanel = panel
169 if s.width > 0 && s.height > 0 {
173 if s.width > 0 && s.height > 0 {
170− s.SetSize(s.width, s.height)
174+ return s.SetSize(s.width, s.height)
171 }
175 }
176+ return nil
172}
177}
173
178
174−func (s *splitPaneLayout) SetRightPanel(panel Container) {
179+func (s *splitPaneLayout) SetRightPanel(panel Container) tea.Cmd {
175 s.rightPanel = panel
180 s.rightPanel = panel
176 if s.width > 0 && s.height > 0 {
181 if s.width > 0 && s.height > 0 {
177− s.SetSize(s.width, s.height)
182+ return s.SetSize(s.width, s.height)
178 }
183 }
184+ return nil
179}
185}
180
186
181−func (s *splitPaneLayout) SetBottomPanel(panel Container) {
187+func (s *splitPaneLayout) SetBottomPanel(panel Container) tea.Cmd {
182 s.bottomPanel = panel
188 s.bottomPanel = panel
183 if s.width > 0 && s.height > 0 {
189 if s.width > 0 && s.height > 0 {
184− s.SetSize(s.width, s.height)
190+ return s.SetSize(s.width, s.height)
185 }
191 }
192+ return nil
186}
193}
187
194
188func (s *splitPaneLayout) BindingKeys() []key.Binding {
195func (s *splitPaneLayout) BindingKeys() []key.Binding {
internal/tui/page/chat.go+14−17
@@ -54,9 +54,11 @@ func (p *chatPage) Init() tea.Cmd {
54}
54}
55
55
56func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
56func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
57+ var cmds []tea.Cmd
57 switch msg := msg.(type) {
58 switch msg := msg.(type) {
58 case tea.WindowSizeMsg:
59 case tea.WindowSizeMsg:
59− p.layout.SetSize(msg.Width, msg.Height)
60+ cmd := p.layout.SetSize(msg.Width, msg.Height)
61+ cmds = append(cmds, cmd)
60 case chat.SendMsg:
62 case chat.SendMsg:
61 cmd := p.sendMessage(msg.Text)
63 cmd := p.sendMessage(msg.Text)
62 if cmd != nil {
64 if cmd != nil {
@@ -68,8 +70,10 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
68 switch {
70 switch {
69 case key.Matches(msg, keyMap.NewSession):
71 case key.Matches(msg, keyMap.NewSession):
70 p.session = session.Session{}
72 p.session = session.Session{}
73+ return p, tea.Batch(
71− p.clearSidebar()
74+ p.clearSidebar(),
72− return p, util.CmdHandler(chat.SessionClearedMsg{})
75+ util.CmdHandler(chat.SessionClearedMsg{}),
76+ )
73 case key.Matches(msg, keyMap.Cancel):
77 case key.Matches(msg, keyMap.Cancel):
74 if p.session.ID != "" {
78 if p.session.ID != "" {
75 // Cancel the current session's generation process
79 // Cancel the current session's generation process
@@ -80,11 +84,9 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
80 }
84 }
81 }
85 }
82 u, cmd := p.layout.Update(msg)
86 u, cmd := p.layout.Update(msg)
87+ cmds = append(cmds, cmd)
83 p.layout = u.(layout.SplitPaneLayout)
88 p.layout = u.(layout.SplitPaneLayout)
84− if cmd != nil {
85− return p, cmd
89+ return p, tea.Batch(cmds...)
86− }
87− return p, nil
88}
90}
89
91
90func (p *chatPage) setSidebar() tea.Cmd {
92func (p *chatPage) setSidebar() tea.Cmd {
@@ -92,16 +94,11 @@ func (p *chatPage) setSidebar() tea.Cmd {
92 chat.NewSidebarCmp(p.session, p.app.History),
94 chat.NewSidebarCmp(p.session, p.app.History),
93 layout.WithPadding(1, 1, 1, 1),
95 layout.WithPadding(1, 1, 1, 1),
94 )
96 )
95− p.layout.SetRightPanel(sidebarContainer)
96− width, height := p.layout.GetSize()
97− p.layout.SetSize(width, height)
98− return sidebarContainer.Init()
97+ return tea.Batch(p.layout.SetRightPanel(sidebarContainer), sidebarContainer.Init())
99}
98}
100
99
101−func (p *chatPage) clearSidebar() {
100+func (p *chatPage) clearSidebar() tea.Cmd {
102− p.layout.SetRightPanel(nil)
101+ return p.layout.SetRightPanel(nil)
103− width, height := p.layout.GetSize()
104− p.layout.SetSize(width, height)
105}
102}
106
103
107func (p *chatPage) sendMessage(text string) tea.Cmd {
104func (p *chatPage) sendMessage(text string) tea.Cmd {
@@ -124,8 +121,8 @@ func (p *chatPage) sendMessage(text string) tea.Cmd {
124 return tea.Batch(cmds...)
121 return tea.Batch(cmds...)
125}
122}
126
123
127−func (p *chatPage) SetSize(width, height int) {
124+func (p *chatPage) SetSize(width, height int) tea.Cmd {
128− p.layout.SetSize(width, height)
125+ return p.layout.SetSize(width, height)
129}
126}
130
127
131func (p *chatPage) GetSize() (int, int) {
128func (p *chatPage) GetSize() (int, int) {
internal/tui/page/logs.go+7−6
@@ -23,15 +23,14 @@ type logsPage struct {
23}
23}
24
24
25func (p *logsPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
25func (p *logsPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
26+ var cmds []tea.Cmd
26 switch msg := msg.(type) {
27 switch msg := msg.(type) {
27 case tea.WindowSizeMsg:
28 case tea.WindowSizeMsg:
28 p.width = msg.Width
29 p.width = msg.Width
29 p.height = msg.Height
30 p.height = msg.Height
30− p.table.SetSize(msg.Width, msg.Height/2)
31− p.details.SetSize(msg.Width, msg.Height/2)
31+ return p, p.SetSize(msg.Width, msg.Height)
32 }
32 }
33
33
34− var cmds []tea.Cmd
35 table, cmd := p.table.Update(msg)
34 table, cmd := p.table.Update(msg)
36 cmds = append(cmds, cmd)
35 cmds = append(cmds, cmd)
37 p.table = table.(layout.Container)
36 p.table = table.(layout.Container)
@@ -60,11 +59,13 @@ func (p *logsPage) GetSize() (int, int) {
60}
59}
61
60
62// SetSize implements LogPage.
61// SetSize implements LogPage.
63−func (p *logsPage) SetSize(width int, height int) {
62+func (p *logsPage) SetSize(width int, height int) tea.Cmd {
64 p.width = width
63 p.width = width
65 p.height = height
64 p.height = height
65+ return tea.Batch(
66− p.table.SetSize(width, height/2)
66+ p.table.SetSize(width, height/2),
67− p.details.SetSize(width, height/2)
67+ p.details.SetSize(width, height/2),
68+ )
68}
69}
69
70
70func (p *logsPage) Init() tea.Cmd {
71func (p *logsPage) Init() tea.Cmd {
internal/tui/styles/background.go+78−36
@@ -3,7 +3,6 @@ package styles
3import (
3import (
4 "fmt"
4 "fmt"
5 "regexp"
5 "regexp"
6− "strconv"
7 "strings"
6 "strings"
8
7
9 "github.com/charmbracelet/lipgloss"
8 "github.com/charmbracelet/lipgloss"
@@ -25,57 +24,100 @@ func getColorRGB(c lipgloss.TerminalColor) (uint8, uint8, uint8) {
25 return uint8(r >> 8), uint8(g >> 8), uint8(b >> 8)
24 return uint8(r >> 8), uint8(g >> 8), uint8(b >> 8)
26}
25}
27
26
27+// ForceReplaceBackgroundWithLipgloss replaces any ANSI background color codes
28+// in `input` with a single 24‑bit background (48;2;R;G;B).
28func ForceReplaceBackgroundWithLipgloss(input string, newBgColor lipgloss.TerminalColor) string {
29func ForceReplaceBackgroundWithLipgloss(input string, newBgColor lipgloss.TerminalColor) string {
30+ // Precompute our new-bg sequence once
29 r, g, b := getColorRGB(newBgColor)
31 r, g, b := getColorRGB(newBgColor)
30−
31 newBg := fmt.Sprintf("48;2;%d;%d;%d", r, g, b)
32 newBg := fmt.Sprintf("48;2;%d;%d;%d", r, g, b)
32
33
33 return ansiEscape.ReplaceAllStringFunc(input, func(seq string) string {
34 return ansiEscape.ReplaceAllStringFunc(input, func(seq string) string {
34− // Extract content between "\x1b[" and "m"
35+ const (
35− content := seq[2 : len(seq)-1]
36+ escPrefixLen = 2 // "\x1b["
36− tokens := strings.Split(content, ";")
37+ escSuffixLen = 1 // "m"
37− var newTokens []string
38+ )
38−
39+
39− // Skip background color tokens
40+ raw := seq
40− for i := 0; i < len(tokens); i++ {
41+ start := escPrefixLen
41− if tokens[i] == "" {
42+ end := len(raw) - escSuffixLen
42− continue
43− }
44
43
45− val, err := strconv.Atoi(tokens[i])
44+ var sb strings.Builder
46− if err != nil {
45+ // reserve enough space: original content minus bg codes + our newBg
47− newTokens = append(newTokens, tokens[i])
46+ sb.Grow((end - start) + len(newBg) + 2)
48− continue
47+
48+ // scan from start..end, token by token
49+ for i := start; i < end; {
50+ // find the next ';' or end
51+ j := i
52+ for j < end && raw[j] != ';' {
53+ j++
49 }
54 }
55+ token := raw[i:j]
50
56
51− // Skip background color tokens
52− if val == 48 {
53− // Skip "48;5;N" or "48;2;R;G;B" sequences
57+ // fast‑path: skip "48;5;N" or "48;2;R;G;B"
54− if i+1 < len(tokens) {
58+ if len(token) == 2 && token[0] == '4' && token[1] == '8' {
55− if nextVal, err := strconv.Atoi(tokens[i+1]); err == nil {
59+ k := j + 1
56− switch nextVal {
60+ if k < end {
57− case 5:
61+ // find next token
58− i += 2 // Skip "5" and color index
62+ l := k
59− case 2:
63+ for l < end && raw[l] != ';' {
60− i += 4 // Skip "2" and RGB components
64+ l++
65+ }
66+ next := raw[k:l]
67+ if next == "5" {
68+ // skip "48;5;N"
69+ m := l + 1
70+ for m < end && raw[m] != ';' {
71+ m++
72+ }
73+ i = m + 1
74+ continue
75+ } else if next == "2" {
76+ // skip "48;2;R;G;B"
77+ m := l + 1
78+ for count := 0; count < 3 && m < end; count++ {
79+ for m < end && raw[m] != ';' {
80+ m++
81+ }
82+ m++
61 }
83 }
84+ i = m
85+ continue
62 }
86 }
63 }
87 }
64− } else if (val < 40 || val > 47) && (val < 100 || val > 107) && val != 49 {
65− // Keep non-background tokens
66− newTokens = append(newTokens, tokens[i])
67 }
88 }
68− }
69
89
70− // Add new background if provided
90+ // decide whether to keep this token
71− if newBg != "" {
91+ // manually parse ASCII digits to int
72− newTokens = append(newTokens, strings.Split(newBg, ";")...)
92+ isNum := true
93+ val := 0
94+ for p := i; p < j; p++ {
95+ c := raw[p]
96+ if c < '0' || c > '9' {
97+ isNum = false
98+ break
99+ }
100+ val = val*10 + int(c-'0')
101+ }
102+ keep := !isNum ||
103+ ((val < 40 || val > 47) && (val < 100 || val > 107) && val != 49)
104+
105+ if keep {
106+ if sb.Len() > 0 {
107+ sb.WriteByte(';')
108+ }
109+ sb.WriteString(token)
110+ }
111+ // advance past this token (and the semicolon)
112+ i = j + 1
73 }
113 }
74
114
75− if len(newTokens) == 0 {
115+ // append our new background
76− return ""
116+ if sb.Len() > 0 {
117+ sb.WriteByte(';')
77 }
118 }
119+ sb.WriteString(newBg)
78
120
79− return "\x1b[" + strings.Join(newTokens, ";") + "m"
121+ return "\x1b[" + sb.String() + "m"
80 })
122 })
81}
123}
internal/tui/styles/icons.go+3−11
@@ -2,19 +2,11 @@ package styles
2
2
3const (
3const (
4 OpenCodeIcon string = "⌬"
4 OpenCodeIcon string = "⌬"
5− SessionsIcon string = "󰧑"
6− ChatIcon string = "󰭹"
7−
8− BotIcon string = "󰚩"
9− ToolIcon string = ""
10− UserIcon string = ""
11
5
12 CheckIcon string = "✓"
6 CheckIcon string = "✓"
13− ErrorIcon string = ""
7+ ErrorIcon string = "✖"
14− WarningIcon string = ""
8+ WarningIcon string = "⚠"
15 InfoIcon string = ""
9 InfoIcon string = ""
16− HintIcon string = ""
10+ HintIcon string = "i"
17 SpinnerIcon string = "..."
11 SpinnerIcon string = "..."
18− BugIcon string = ""
19− SleepIcon string = "󰒲"
20)
12)
internal/tui/tui.go+95−16
@@ -1,6 +1,8 @@
1package tui
1package tui
2
2
3import (
3import (
4+ "context"
5+
4 "github.com/charmbracelet/bubbles/key"
6 "github.com/charmbracelet/bubbles/key"
5 tea "github.com/charmbracelet/bubbletea"
7 tea "github.com/charmbracelet/bubbletea"
6 "github.com/charmbracelet/lipgloss"
8 "github.com/charmbracelet/lipgloss"
@@ -8,6 +10,7 @@ import (
8 "github.com/kujtimiihoxha/opencode/internal/logging"
10 "github.com/kujtimiihoxha/opencode/internal/logging"
9 "github.com/kujtimiihoxha/opencode/internal/permission"
11 "github.com/kujtimiihoxha/opencode/internal/permission"
10 "github.com/kujtimiihoxha/opencode/internal/pubsub"
12 "github.com/kujtimiihoxha/opencode/internal/pubsub"
13+ "github.com/kujtimiihoxha/opencode/internal/tui/components/chat"
11 "github.com/kujtimiihoxha/opencode/internal/tui/components/core"
14 "github.com/kujtimiihoxha/opencode/internal/tui/components/core"
12 "github.com/kujtimiihoxha/opencode/internal/tui/components/dialog"
15 "github.com/kujtimiihoxha/opencode/internal/tui/components/dialog"
13 "github.com/kujtimiihoxha/opencode/internal/tui/layout"
16 "github.com/kujtimiihoxha/opencode/internal/tui/layout"
@@ -16,9 +19,10 @@ import (
16)
19)
17
20
18type keyMap struct {
21type keyMap struct {
19− Logs key.Binding
22+ Logs key.Binding
20− Quit key.Binding
23+ Quit key.Binding
24+ Help key.Binding
21− Help key.Binding
25+ SwitchSession key.Binding
22}
26}
23
27
24var keys = keyMap{
28var keys = keyMap{
@@ -35,6 +39,10 @@ var keys = keyMap{
35 key.WithKeys("ctrl+_"),
39 key.WithKeys("ctrl+_"),
36 key.WithHelp("ctrl+?", "toggle help"),
40 key.WithHelp("ctrl+?", "toggle help"),
37 ),
41 ),
42+ SwitchSession: key.NewBinding(
43+ key.WithKeys("ctrl+a"),
44+ key.WithHelp("ctrl+a", "switch session"),
45+ ),
38}
46}
39
47
40var returnKey = key.NewBinding(
48var returnKey = key.NewBinding(
@@ -64,6 +72,9 @@ type appModel struct {
64
72
65 showQuit bool
73 showQuit bool
66 quit dialog.QuitDialog
74 quit dialog.QuitDialog
75+
76+ showSessionDialog bool
77+ sessionDialog dialog.SessionDialog
67}
78}
68
79
69func (a appModel) Init() tea.Cmd {
80func (a appModel) Init() tea.Cmd {
@@ -77,6 +88,8 @@ func (a appModel) Init() tea.Cmd {
77 cmds = append(cmds, cmd)
88 cmds = append(cmds, cmd)
78 cmd = a.help.Init()
89 cmd = a.help.Init()
79 cmds = append(cmds, cmd)
90 cmds = append(cmds, cmd)
91+ cmd = a.sessionDialog.Init()
92+ cmds = append(cmds, cmd)
80 return tea.Batch(cmds...)
93 return tea.Batch(cmds...)
81}
94}
82
95
@@ -100,6 +113,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
100 a.help = help.(dialog.HelpCmp)
113 a.help = help.(dialog.HelpCmp)
101 cmds = append(cmds, helpCmd)
114 cmds = append(cmds, helpCmd)
102
115
116+ session, sessionCmd := a.sessionDialog.Update(msg)
117+ a.sessionDialog = session.(dialog.SessionDialog)
118+ cmds = append(cmds, sessionCmd)
119+
103 return a, tea.Batch(cmds...)
120 return a, tea.Batch(cmds...)
104
121
105 // Status
122 // Status
@@ -144,8 +161,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
144 // Permission
161 // Permission
145 case pubsub.Event[permission.PermissionRequest]:
162 case pubsub.Event[permission.PermissionRequest]:
146 a.showPermissions = true
163 a.showPermissions = true
147− a.permissions.SetPermissions(msg.Payload)
164+ return a, a.permissions.SetPermissions(msg.Payload)
148− return a, nil
149 case dialog.PermissionResponseMsg:
165 case dialog.PermissionResponseMsg:
150 switch msg.Action {
166 switch msg.Action {
151 case dialog.PermissionAllow:
167 case dialog.PermissionAllow:
@@ -165,6 +181,19 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
165 a.showQuit = false
181 a.showQuit = false
166 return a, nil
182 return a, nil
167
183
184+ case dialog.CloseSessionDialogMsg:
185+ a.showSessionDialog = false
186+ return a, nil
187+
188+ case chat.SessionSelectedMsg:
189+ a.sessionDialog.SetSelectedSession(msg.ID)
190+ case dialog.SessionSelectedMsg:
191+ a.showSessionDialog = false
192+ if a.currentPage == page.ChatPage {
193+ return a, util.CmdHandler(chat.SessionSelectedMsg(msg.Session))
194+ }
195+ return a, nil
196+
168 case tea.KeyMsg:
197 case tea.KeyMsg:
169 switch {
198 switch {
170 case key.Matches(msg, keys.Quit):
199 case key.Matches(msg, keys.Quit):
@@ -172,6 +201,24 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
172 if a.showHelp {
201 if a.showHelp {
173 a.showHelp = false
202 a.showHelp = false
174 }
203 }
204+ if a.showSessionDialog {
205+ a.showSessionDialog = false
206+ }
207+ return a, nil
208+ case key.Matches(msg, keys.SwitchSession):
209+ if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions {
210+ // Load sessions and show the dialog
211+ sessions, err := a.app.Sessions.List(context.Background())
212+ if err != nil {
213+ return a, util.ReportError(err)
214+ }
215+ if len(sessions) == 0 {
216+ return a, util.ReportWarn("No sessions available")
217+ }
218+ a.sessionDialog.SetSessions(sessions)
219+ a.showSessionDialog = true
220+ return a, nil
221+ }
175 return a, nil
222 return a, nil
176 case key.Matches(msg, logsKeyReturnKey):
223 case key.Matches(msg, logsKeyReturnKey):
177 if a.currentPage == page.LogsPage {
224 if a.currentPage == page.LogsPage {
@@ -216,6 +263,16 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
216 }
263 }
217 }
264 }
218
265
266+ if a.showSessionDialog {
267+ d, sessionCmd := a.sessionDialog.Update(msg)
268+ a.sessionDialog = d.(dialog.SessionDialog)
269+ cmds = append(cmds, sessionCmd)
270+ // Only block key messages send all other messages down
271+ if _, ok := msg.(tea.KeyMsg); ok {
272+ return a, tea.Batch(cmds...)
273+ }
274+ }
275+
219 a.status, _ = a.status.Update(msg)
276 a.status, _ = a.status.Update(msg)
220 a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
277 a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
221 cmds = append(cmds, cmd)
278 cmds = append(cmds, cmd)
@@ -223,18 +280,24 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
223}
280}
224
281
225func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
282func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
283+ if a.app.CoderAgent.IsBusy() {
284+ // For now we don't move to any page if the agent is busy
285+ return util.ReportWarn("Agent is busy, please wait...")
286+ }
226− var cmd tea.Cmd
287+ var cmds []tea.Cmd
227 if _, ok := a.loadedPages[pageID]; !ok {
288 if _, ok := a.loadedPages[pageID]; !ok {
228− cmd = a.pages[pageID].Init()
289+ cmd := a.pages[pageID].Init()
290+ cmds = append(cmds, cmd)
229 a.loadedPages[pageID] = true
291 a.loadedPages[pageID] = true
230 }
292 }
231 a.previousPage = a.currentPage
293 a.previousPage = a.currentPage
232 a.currentPage = pageID
294 a.currentPage = pageID
233 if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
295 if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
234− sizable.SetSize(a.width, a.height)
296+ cmd := sizable.SetSize(a.width, a.height)
297+ cmds = append(cmds, cmd)
235 }
298 }
236
299
237− return cmd
300+ return tea.Batch(cmds...)
238}
301}
239
302
240func (a appModel) View() string {
303func (a appModel) View() string {
@@ -304,19 +367,35 @@ func (a appModel) View() string {
304 )
367 )
305 }
368 }
306
369
370+ if a.showSessionDialog {
371+ overlay := a.sessionDialog.View()
372+ row := lipgloss.Height(appView) / 2
373+ row -= lipgloss.Height(overlay) / 2
374+ col := lipgloss.Width(appView) / 2
375+ col -= lipgloss.Width(overlay) / 2
376+ appView = layout.PlaceOverlay(
377+ col,
378+ row,
379+ overlay,
380+ appView,
381+ true,
382+ )
383+ }
384+
307 return appView
385 return appView
308}
386}
309
387
310func New(app *app.App) tea.Model {
388func New(app *app.App) tea.Model {
311 startPage := page.ChatPage
389 startPage := page.ChatPage
312 return &appModel{
390 return &appModel{
313− currentPage: startPage,
391+ currentPage: startPage,
314− loadedPages: make(map[page.PageID]bool),
392+ loadedPages: make(map[page.PageID]bool),
315− status: core.NewStatusCmp(app.LSPClients),
393+ status: core.NewStatusCmp(app.LSPClients),
316− help: dialog.NewHelpCmp(),
394+ help: dialog.NewHelpCmp(),
317− quit: dialog.NewQuitCmp(),
395+ quit: dialog.NewQuitCmp(),
396+ sessionDialog: dialog.NewSessionDialogCmp(),
318− permissions: dialog.NewPermissionDialogCmp(),
397+ permissions: dialog.NewPermissionDialogCmp(),
319− app: app,
398+ app: app,
320 pages: map[page.PageID]tea.Model{
399 pages: map[page.PageID]tea.Model{
321 page.ChatPage: page.NewChatPage(app),
400 page.ChatPage: page.NewChatPage(app),
322 page.LogsPage: page.NewLogsPage(),
401 page.LogsPage: page.NewLogsPage(),