on main help sign in
implement patch, update ui, improve rendering
commit333ea6ec4b2abfc2c1a9c3f6b0918ca5d296347f authorKujtim Hoxha <kujtimii.h@gmail.com> dateFri 18 Apr 2025, 20:17 (2 years ago) parent05d0e86
38 files changed+3304−2254
README.md+271−30 internal/config/config.go+3−13 internal/diff/diff.go+18−10 internal/diff/patch.go+739 internal/history/file.go+11−10 internal/llm/agent/agent.go+34−19 internal/llm/agent/tools.go+1−2 internal/llm/models/anthropic.go+6 internal/llm/models/models.go+4−60 internal/llm/models/openai.go+169 internal/llm/prompt/coder.go+44−39 internal/llm/provider/openai.go+41−8 internal/llm/tools/glob.go+37−1 internal/llm/tools/grep.go+31−6 internal/llm/tools/patch.go+259−191 internal/llm/tools/view.go+12−1 internal/tui/components/chat/editor.go+53−5 internal/tui/components/chat/list.go+463 internal/tui/components/chat/message.go+561 internal/tui/components/chat/messages.go−742 internal/tui/components/chat/sidebar.go+67−27 internal/tui/components/core/status.go+19−5 internal/tui/components/dialog/permission.go+9−7 internal/tui/components/dialog/session.go+224 internal/tui/components/logs/details.go+3−13 internal/tui/components/logs/table.go+2−1 internal/tui/layout/bento.go−392 internal/tui/layout/border.go−121 internal/tui/layout/container.go+3−2 internal/tui/layout/grid.go−254 internal/tui/layout/layout.go+1−5 internal/tui/layout/single.go−189 internal/tui/layout/split.go+22−15 internal/tui/page/chat.go+14−17 internal/tui/page/logs.go+7−6 internal/tui/styles/background.go+78−36 internal/tui/styles/icons.go+3−11 internal/tui/tui.go+95−16
README.md+271−30
@@ -4,8 +4,6 @@
44
55A powerful terminal-based AI assistant for developers, providing intelligent coding assistance directly in your terminal.
66
7−[![OpenCode Demo](https://asciinema.org/a/dtc4nJyGSZX79HRUmFLY3gmoy.svg)](https://asciinema.org/a/dtc4nJyGSZX79HRUmFLY3gmoy)
8−
97## Overview
108
119OpenCode 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
1311## Features
1412
1513- **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
1715- **Session Management**: Save and manage multiple conversation sessions
1816- **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
2018- **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
2121
2222## Installation
2323
@@ -34,11 +34,107 @@ OpenCode looks for configuration in the following locations:
3434- `$XDG_CONFIG_HOME/opencode/.opencode.json`
3535- `./.opencode.json` (local directory)
3636
37−You can also use environment variables:
37+### Environment Variables
38+
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+```
38100
39−- `ANTHROPIC_API_KEY`: For Claude models
40−- `OPENAI_API_KEY`: For OpenAI models
41−- `GEMINI_API_KEY`: For Google Gemini models
101+## Supported AI 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+| --------------------------- | ----------- | ----------------- | -------------- |
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 | - |
42138
43139## Usage
44140
@@ -48,36 +144,78 @@ opencode
48144
49145# Start with debug logging
50146opencode -d
147+
148+# Start with a specific working directory
149+opencode -c /path/to/project
51150```
52151
53−### Keyboard Shortcuts
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+
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
54181
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 |
56187
57−- `?`: Toggle help panel
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
188+### Logs Page Shortcuts
62189
63−#### Session Management
190+| Shortcut | Action |
191+| ----------- | ------------------- |
192+| `Backspace` | Return to chat page |
64193
65−- `N`: Create new session
66−- `Enter` or `Space`: Select session (in sessions list)
194+## AI Assistant Tools
67195
68−#### Editor Shortcuts (Vim-like)
196+OpenCode's AI assistant has access to various tools to help with coding tasks:
69197
70−- `i`: Enter insert mode
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)
198+### File and Code Tools
76199
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) |
78210
79−- Arrow keys: Navigate through lists and content
80−- Page Up/Down: Scroll through content
211+### Other Tools
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) |
81219
82220## Architecture
83221
@@ -92,6 +230,101 @@ OpenCode is built with a modular architecture:
92230- **internal/logging**: Logging infrastructure
93231- **internal/message**: Message handling
94232- **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
95328
96329## Development
97330
@@ -124,8 +357,16 @@ OpenCode builds upon the work of several open source projects and developers:
124357
125358## License
126359
127−[License information coming soon]
360+OpenCode is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
128361
129362## Contributing
130363
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 (
4141
4242// Agent defines configuration for different LLM models and their token limits.
4343type Agent struct {
44− Model models.ModelID `json:"model"`
45− MaxTokens int64 `json:"maxTokens"`
44+ Model models.ModelID `json:"model"`
45+ MaxTokens int64 `json:"maxTokens"`
46+ ReasoningEffort string `json:"reasoningEffort"` // For openai models low,medium,heigh
4647}
4748
4849// Provider defines configuration for an LLM provider.
@@ -80,7 +81,6 @@ type Config struct {
8081const (
8182 defaultDataDirectory = ".opencode"
8283 defaultLogLevel = "info"
83− defaultMaxTokens = int64(5000)
8484 appName = "opencode"
8585)
8686
@@ -202,9 +202,7 @@ func setProviderDefaults() {
202202 if apiKey := os.Getenv("GROQ_API_KEY"); apiKey != "" {
203203 viper.SetDefault("providers.groq.apiKey", apiKey)
204204 viper.SetDefault("agents.coder.model", models.QWENQwq)
205− viper.SetDefault("agents.coder.maxTokens", defaultMaxTokens)
206205 viper.SetDefault("agents.task.model", models.QWENQwq)
207− viper.SetDefault("agents.task.maxTokens", defaultMaxTokens)
208206 viper.SetDefault("agents.title.model", models.QWENQwq)
209207 }
210208
@@ -212,9 +210,7 @@ func setProviderDefaults() {
212210 if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
213211 viper.SetDefault("providers.gemini.apiKey", apiKey)
214212 viper.SetDefault("agents.coder.model", models.GRMINI20Flash)
215− viper.SetDefault("agents.coder.maxTokens", defaultMaxTokens)
216213 viper.SetDefault("agents.task.model", models.GRMINI20Flash)
217− viper.SetDefault("agents.task.maxTokens", defaultMaxTokens)
218214 viper.SetDefault("agents.title.model", models.GRMINI20Flash)
219215 }
220216
@@ -222,9 +218,7 @@ func setProviderDefaults() {
222218 if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
223219 viper.SetDefault("providers.openai.apiKey", apiKey)
224220 viper.SetDefault("agents.coder.model", models.GPT4o)
225− viper.SetDefault("agents.coder.maxTokens", defaultMaxTokens)
226221 viper.SetDefault("agents.task.model", models.GPT4o)
227− viper.SetDefault("agents.task.maxTokens", defaultMaxTokens)
228222 viper.SetDefault("agents.title.model", models.GPT4o)
229223
230224 }
@@ -233,17 +227,13 @@ func setProviderDefaults() {
233227 if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
234228 viper.SetDefault("providers.anthropic.apiKey", apiKey)
235229 viper.SetDefault("agents.coder.model", models.Claude37Sonnet)
236− viper.SetDefault("agents.coder.maxTokens", defaultMaxTokens)
237230 viper.SetDefault("agents.task.model", models.Claude37Sonnet)
238− viper.SetDefault("agents.task.maxTokens", defaultMaxTokens)
239231 viper.SetDefault("agents.title.model", models.Claude37Sonnet)
240232 }
241233
242234 if hasAWSCredentials() {
243235 viper.SetDefault("agents.coder.model", models.BedrockClaude37Sonnet)
244− viper.SetDefault("agents.coder.maxTokens", defaultMaxTokens)
245236 viper.SetDefault("agents.task.model", models.BedrockClaude37Sonnet)
246− viper.SetDefault("agents.task.maxTokens", defaultMaxTokens)
247237 viper.SetDefault("agents.title.model", models.BedrockClaude37Sonnet)
248238 }
249239}
internal/diff/diff.go+18−10
@@ -79,8 +79,9 @@ type linePair struct {
7979
8080// StyleConfig defines styling for diff rendering
8181type StyleConfig struct {
82− ShowHeader bool
83− FileNameFg lipgloss.Color
82+ ShowHeader bool
83+ ShowHunkHeader bool
84+ FileNameFg lipgloss.Color
8485 // Background colors
8586 RemovedLineBg lipgloss.Color
8687 AddedLineBg lipgloss.Color
@@ -111,7 +112,8 @@ func NewStyleConfig(opts ...StyleOption) StyleConfig {
111112 // Default color scheme
112113 config := StyleConfig{
113114 ShowHeader: true,
114− FileNameFg: lipgloss.Color("#fab283"),
115+ ShowHunkHeader: true,
116+ FileNameFg: lipgloss.Color("#a0a0a0"),
115117 RemovedLineBg: lipgloss.Color("#3A3030"),
116118 AddedLineBg: lipgloss.Color("#303A30"),
117119 ContextLineBg: lipgloss.Color("#212121"),
@@ -204,6 +206,10 @@ func WithShowHeader(show bool) StyleOption {
204206 return func(s *StyleConfig) { s.ShowHeader = show }
205207}
206208
209+func WithShowHunkHeader(show bool) StyleOption {
210+ return func(s *StyleConfig) { s.ShowHunkHeader = show }
211+}
212+
207213// -------------------------------------------------------------------------
208214// Parse Configuration
209215// -------------------------------------------------------------------------
@@ -914,13 +920,15 @@ func FormatDiff(diffText string, opts ...SideBySideOption) (string, error) {
914920
915921 for _, h := range diffResult.Hunks {
916922 // Render hunk header
917− sb.WriteString(
918− lipgloss.NewStyle().
919− Background(config.Style.HunkLineBg).
920− Foreground(config.Style.HunkLineFg).
921− Width(config.TotalWidth).
922− Render(h.Header) + "\n",
923− )
923+ if config.Style.ShowHunkHeader {
924+ sb.WriteString(
925+ lipgloss.NewStyle().
926+ Background(config.Style.HunkLineBg).
927+ Foreground(config.Style.HunkLineFg).
928+ Width(config.TotalWidth).
929+ Render(h.Header) + "\n",
930+ )
931+ }
924932 sb.WriteString(RenderSideBySideHunk(diffResult.OldFile, h, opts...))
925933 }
926934
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 {
5050 return &service{
5151 Broker: pubsub.NewBroker[File](),
5252 q: q,
53+ db: db,
5354 }
5455}
5556
@@ -100,30 +101,30 @@ func (s *service) createWithVersion(ctx context.Context, sessionID, path, conten
100101 var err error
101102
102103 // Retry loop for transaction conflicts
103− for attempt := 0; attempt < maxRetries; attempt++ {
104+ for attempt := range maxRetries {
104105 // Start a transaction
105− tx, err := s.db.BeginTx(ctx, nil)
106− if err != nil {
107− return File{}, fmt.Errorf("failed to begin transaction: %w", err)
106+ tx, txErr := s.db.Begin()
107+ if txErr != nil {
108+ return File{}, fmt.Errorf("failed to begin transaction: %w", txErr)
108109 }
109110
110111 // Create a new queries instance with the transaction
111112 qtx := s.q.WithTx(tx)
112113
113114 // Try to create the file within the transaction
114− dbFile, err := qtx.CreateFile(ctx, db.CreateFileParams{
115+ dbFile, txErr := qtx.CreateFile(ctx, db.CreateFileParams{
115116 ID: uuid.New().String(),
116117 SessionID: sessionID,
117118 Path: path,
118119 Content: content,
119120 Version: version,
120121 })
121− if err != nil {
122+ if txErr != nil {
122123 // Rollback the transaction
123124 tx.Rollback()
124125
125126 // 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") {
127128 if attempt < maxRetries-1 {
128129 // If we have retries left, generate a new version and try again
129130 if strings.HasPrefix(version, "v") {
@@ -138,12 +139,12 @@ func (s *service) createWithVersion(ctx context.Context, sessionID, path, conten
138139 continue
139140 }
140141 }
141− return File{}, err
142+ return File{}, txErr
142143 }
143144
144145 // Commit the transaction
145− if err = tx.Commit(); err != nil {
146− return File{}, fmt.Errorf("failed to commit transaction: %w", err)
146+ if txErr = tx.Commit(); txErr != nil {
147+ return File{}, fmt.Errorf("failed to commit transaction: %w", txErr)
147148 }
148149
149150 file = s.fromDBItem(dbFile)
internal/llm/agent/agent.go+34−19
@@ -41,6 +41,7 @@ type Service interface {
4141 Run(ctx context.Context, sessionID string, content string) (<-chan AgentEvent, error)
4242 Cancel(sessionID string)
4343 IsSessionBusy(sessionID string) bool
44+ IsBusy() bool
4445}
4546
4647type agent struct {
@@ -95,6 +96,20 @@ func (a *agent) Cancel(sessionID string) {
9596 }
9697}
9798
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+
98113func (a *agent) IsSessionBusy(sessionID string) bool {
99114 _, busy := a.activeRequests.Load(sessionID)
100115 return busy
@@ -313,23 +328,8 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg
313328 }
314329 }
315330 a.finishMessage(ctx, &assistantMsg, message.FinishReasonPermissionDenied)
316− } else {
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)
331+ break
330332 }
331− // If permission is denied or an error happens we cancel all the following tools
332− break
333333 }
334334 toolResults[i] = message.ToolResult{
335335 ToolCallID: toolCall.ID,
@@ -437,12 +437,27 @@ func createAgentProvider(agentName config.AgentName) (provider.Provider, error)
437437 if providerCfg.Disabled {
438438 return nil, fmt.Errorf("provider %s is not enabled", model.Provider)
439439 }
440− agentProvider, err := provider.NewProvider(
441− model.Provider,
440+ maxTokens := model.DefaultMaxTokens
441+ if agentConfig.MaxTokens > 0 {
442+ maxTokens = agentConfig.MaxTokens
443+ }
444+ opts := []provider.ProviderClientOption{
442445 provider.WithAPIKey(providerCfg.APIKey),
443446 provider.WithModel(model),
444447 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...,
446461 )
447462 if err != nil {
448463 return nil, fmt.Errorf("could not create provider: %v", err)
internal/llm/agent/tools.go+1−2
@@ -31,10 +31,9 @@ func CoderAgentTools(
3131 tools.NewGlobTool(),
3232 tools.NewGrepTool(),
3333 tools.NewLsTool(),
34− // TODO: see if we want to use this tool
35− // tools.NewPatchTool(lspClients, permissions, history),
3634 tools.NewSourcegraphTool(),
3735 tools.NewViewTool(lspClients),
36+ tools.NewPatchTool(lspClients, permissions, history),
3837 tools.NewWriteTool(lspClients, permissions, history),
3938 NewAgentTool(sessions, messages, lspClients),
4039 }, otherTools...,
internal/llm/models/anthropic.go+6
@@ -23,6 +23,7 @@ var AnthropicModels = map[ModelID]Model{
2323 CostPer1MOutCached: 0.30,
2424 CostPer1MOut: 15.0,
2525 ContextWindow: 200000,
26+ DefaultMaxTokens: 5000,
2627 },
2728 Claude3Haiku: {
2829 ID: Claude3Haiku,
@@ -34,6 +35,7 @@ var AnthropicModels = map[ModelID]Model{
3435 CostPer1MOutCached: 0.03,
3536 CostPer1MOut: 1.25,
3637 ContextWindow: 200000,
38+ DefaultMaxTokens: 5000,
3739 },
3840 Claude37Sonnet: {
3941 ID: Claude37Sonnet,
@@ -45,6 +47,8 @@ var AnthropicModels = map[ModelID]Model{
4547 CostPer1MOutCached: 0.30,
4648 CostPer1MOut: 15.0,
4749 ContextWindow: 200000,
50+ DefaultMaxTokens: 50000,
51+ CanReason: true,
4852 },
4953 Claude35Haiku: {
5054 ID: Claude35Haiku,
@@ -56,6 +60,7 @@ var AnthropicModels = map[ModelID]Model{
5660 CostPer1MOutCached: 0.08,
5761 CostPer1MOut: 4.0,
5862 ContextWindow: 200000,
63+ DefaultMaxTokens: 4096,
5964 },
6065 Claude3Opus: {
6166 ID: Claude3Opus,
@@ -67,5 +72,6 @@ var AnthropicModels = map[ModelID]Model{
6772 CostPer1MOutCached: 1.50,
6873 CostPer1MOut: 75.0,
6974 ContextWindow: 200000,
75+ DefaultMaxTokens: 4096,
7076 },
7177}
internal/llm/models/models.go+4−60
@@ -17,15 +17,12 @@ type Model struct {
1717 CostPer1MInCached float64 `json:"cost_per_1m_in_cached"`
1818 CostPer1MOutCached float64 `json:"cost_per_1m_out_cached"`
1919 ContextWindow int64 `json:"context_window"`
20+ DefaultMaxTokens int64 `json:"default_max_tokens"`
21+ CanReason bool `json:"can_reason"`
2022}
2123
2224// Model IDs
23−const (
24− // OpenAI
25− GPT4o ModelID = "gpt-4o"
26− GPT41 ModelID = "gpt-4.1"
27−
28− // GEMINI
25+const ( // GEMINI
2926 GEMINI25 ModelID = "gemini-2.5"
3027 GRMINI20Flash ModelID = "gemini-2.0-flash"
3128
@@ -37,7 +34,6 @@ const (
3734)
3835
3936const (
40− ProviderOpenAI ModelProvider = "openai"
4137 ProviderBedrock ModelProvider = "bedrock"
4238 ProviderGemini ModelProvider = "gemini"
4339 ProviderGROQ ModelProvider = "groq"
@@ -47,59 +43,6 @@ const (
4743)
4844
4945var 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− },
10346 //
10447 // // GEMINI
10548 // GEMINI25: {
@@ -151,4 +94,5 @@ var SupportedModels = map[ModelID]Model{
15194
15295func init() {
15396 maps.Copy(SupportedModels, AnthropicModels)
97+ maps.Copy(SupportedModels, OpenAIModels)
15498}
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 {
2525}
2626
2727const baseOpenAICoderPrompt = `
28−You are **OpenCode**, an autonomous CLI assistant for software‑engineering tasks.
29−
30−### ── INTERNAL REFLECTION ──
31−• Silently think step‑by‑step about the user request, directory layout, and tool calls (never reveal this).
32−• Formulate a plan, then execute without further approval unless a blocker triggers the Ask‑Only‑If rules.
33−
34−### ── PUBLIC RESPONSE RULES ──
35−• Visible reply ≤ 4 lines; no fluff, preamble, or postamble.
36−• Use GitHub‑flavored Markdown.
37−• When running a non‑trivial shell command, add ≤ 1 brief purpose sentence.
38−
39−### ── CONTEXT & MEMORY ──
40−• Infer file intent from directory structure before editing.
41−• Auto‑load 'OpenCode.md'; ask once before writing new reusable commands or style notes.
42−
43−### ── AUTONOMY PRIORITY ──
44−**Ask‑Only‑If Decision Tree:**
45−1. **Safety risk?** (e.g., destructive command, secret exposure) → ask.
46−2. **Critical unknown?** (no docs/tests; cannot infer) → ask.
47−3. **Tool failure after two self‑attempts?** → ask.
48−Otherwise, proceed autonomously.
49−
50−### ── SAFETY & STYLE ──
51−• Mimic existing code style; verify libraries exist before import.
52−• Never commit unless explicitly told.
53−• After edits, run lint & type‑check (ask for commands once, then offer to store in 'OpenCode.md').
54−• Protect secrets; follow standard security practices :contentReference[oaicite:2]{index=2}.
55−
56−### ── TOOL USAGE ──
57−• Batch independent Agent search/file calls in one block for efficiency :contentReference[oaicite:3]{index=3}.
58−• Communicate with the user only via visible text; do not expose tool output or internal reasoning.
59−
60−### ── EXAMPLES ──
61−user: list files
62−assistant: ls
63−
64−user: write tests for new feature
65−assistant: [searches & edits autonomously, no extra chit‑chat]
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+
30+You can:
31+- Receive user prompts, project context, and files.
32+- Stream responses and emit function calls (e.g., shell commands, code edits).
33+- Apply patches, run commands, and manage user approvals based on policy.
34+- Work inside a sandboxed, git-backed workspace with rollback support.
35+- Log telemetry so sessions can be replayed or inspected later.
36+- More details on your functionality are available at "opencode --help"
37+
38+
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+
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+
43+You MUST adhere to the following criteria when executing the task:
44+- Working on the repo(s) in the current environment is allowed, even if they are proprietary.
45+- Analyzing code for vulnerabilities is allowed.
46+- Showing user code and tool call details is allowed.
47+- User instructions may overwrite the *CODING GUIDELINES* section in this developer message.
48+- If completing the user's task requires writing or modifying files:
49+ - Your code and final answer should follow these *CODING GUIDELINES*:
50+ - Fix the problem at the root cause rather than applying surface-level patches, when possible.
51+ - Avoid unneeded complexity in your solution.
52+ - Ignore unrelated bugs or broken tests; it is not your responsibility to fix them.
53+ - Update documentation as necessary.
54+ - Keep changes consistent with the style of the existing codebase. Changes should be minimal and focused on the task.
55+ - Use "git log" and "git blame" to search the history of the codebase if additional context is required; internet access is disabled.
56+ - NEVER add copyright or license headers unless specifically requested.
57+ - You do not need to "git commit" your changes; this will be done automatically for you.
58+ - Once you finish coding, you must
59+ - Check "git status" to sanity check your changes; revert any scratch files or changes.
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+ - Check if you accidentally add copyright or license headers. If so, remove them.
62+ - For smaller tasks, describe in brief bullet points
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+- 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+ - 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.
6671`
6772
6873const 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
125130
126131<example>
127132user: 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]
129134</example>
130135
131136# Proactiveness
internal/llm/provider/openai.go+41−8
@@ -14,11 +14,13 @@ import (
1414 "github.com/kujtimiihoxha/opencode/internal/message"
1515 "github.com/openai/openai-go"
1616 "github.com/openai/openai-go/option"
17+ "github.com/openai/openai-go/shared"
1718)
1819
1920type openaiOptions struct {
20− baseURL string
21− disableCache bool
21+ baseURL string
22+ disableCache bool
23+ reasoningEffort string
2224}
2325
2426type OpenAIOption func(*openaiOptions)
@@ -32,7 +34,9 @@ type openaiClient struct {
3234type OpenAIClient ProviderClient
3335
3436func newOpenAIClient(opts providerClientOptions) OpenAIClient {
35− openaiOpts := openaiOptions{}
37+ openaiOpts := openaiOptions{
38+ reasoningEffort: "medium",
39+ }
3640 for _, o := range opts.openaiOptions {
3741 o(&openaiOpts)
3842 }
@@ -138,12 +142,29 @@ func (o *openaiClient) finishReason(reason string) message.FinishReason {
138142}
139143
140144func (o *openaiClient) preparedParams(messages []openai.ChatCompletionMessageParamUnion, tools []openai.ChatCompletionToolParam) openai.ChatCompletionNewParams {
141− return openai.ChatCompletionNewParams{
142− Model: openai.ChatModel(o.providerOptions.model.APIModel),
143− Messages: messages,
144− MaxTokens: openai.Int(o.providerOptions.maxTokens),
145− Tools: tools,
145+ params := openai.ChatCompletionNewParams{
146+ Model: openai.ChatModel(o.providerOptions.model.APIModel),
147+ Messages: messages,
148+ Tools: tools,
146149 }
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
147168}
148169
149170func (o *openaiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) {
@@ -359,3 +380,15 @@ func WithOpenAIDisableCache() OpenAIOption {
359380 }
360381}
361382
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) {
192192}
193193
194194func skipHidden(path string) bool {
195+ // Check for hidden files (starting with a dot)
195196 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
197233}
internal/llm/tools/grep.go+31−6
@@ -17,9 +17,10 @@ import (
1717)
1818
1919type GrepParams struct {
20− Pattern string `json:"pattern"`
21− Path string `json:"path"`
22− Include string `json:"include"`
20+ Pattern string `json:"pattern"`
21+ Path string `json:"path"`
22+ Include string `json:"include"`
23+ LiteralText bool `json:"literal_text"`
2324}
2425
2526type grepMatch struct {
@@ -45,11 +46,12 @@ WHEN TO USE THIS TOOL:
4546
4647HOW TO USE:
4748- 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)
4850- Optionally specify a starting directory (defaults to current working directory)
4951- Optionally provide an include pattern to filter which files to search
5052- Results are sorted with most recently modified files first
5153
52−REGEX PATTERN SYNTAX:
54+REGEX PATTERN SYNTAX (when literal_text=false):
5355- Supports standard regular expression syntax
5456- 'function' searches for the literal text "function"
5557- 'log\..*Error' finds text starting with "log." and ending with "Error"
@@ -69,7 +71,8 @@ LIMITATIONS:
6971TIPS:
7072- For faster, more targeted searches, first use Glob to find relevant files, then use Grep
7173- 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.`
7376)
7477
7578func NewGrepTool() BaseTool {
@@ -93,11 +96,27 @@ func (g *grepTool) Info() ToolInfo {
9396 "type": "string",
9497 "description": "File pattern to include in the search (e.g. \"*.js\", \"*.{ts,tsx}\")",
9598 },
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+ },
96103 },
97104 Required: []string{"pattern"},
98105 }
99106}
100107
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+
101120func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
102121 var params GrepParams
103122 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)
108127 return NewTextErrorResponse("pattern is required"), nil
109128 }
110129
130+ // If literal_text is true, escape the pattern
131+ searchPattern := params.Pattern
132+ if params.LiteralText {
133+ searchPattern = escapeRegexPattern(params.Pattern)
134+ }
135+
111136 searchPath := params.Path
112137 if searchPath == "" {
113138 searchPath = config.WorkingDirectory()
114139 }
115140
116− matches, truncated, err := searchFiles(params.Pattern, searchPath, params.Include, 100)
141+ matches, truncated, err := searchFiles(searchPattern, searchPath, params.Include, 100)
117142 if err != nil {
118143 return ToolResponse{}, fmt.Errorf("error searching files: %w", err)
119144 }
internal/llm/tools/patch.go+259−191
@@ -6,7 +6,6 @@ import (
66 "fmt"
77 "os"
88 "path/filepath"
9− "strings"
109 "time"
1110
1211 "github.com/kujtimiihoxha/opencode/internal/config"
@@ -17,19 +16,13 @@ import (
1716)
1817
1918type PatchParams struct {
20− FilePath string `json:"file_path"`
21− Patch string `json:"patch"`
22−}
23−
24−type PatchPermissionsParams struct {
25− FilePath string `json:"file_path"`
26− Diff string `json:"diff"`
19+ PatchText string `json:"patch_text"`
2720}
2821
2922type PatchResponseMetadata struct {
30− Diff string `json:"diff"`
31− Additions int `json:"additions"`
32− Removals int `json:"removals"`
23+ FilesChanged []string `json:"files_changed"`
24+ Additions int `json:"additions"`
25+ Removals int `json:"removals"`
3326}
3427
3528type patchTool struct {
@@ -39,47 +32,35 @@ type patchTool struct {
3932}
4033
4134const (
42− // TODO: test if this works as expected
4335 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
4551
4652Before using this tool:
47−
48−1. Use the FileRead tool to understand the file's contents and context
49−
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.
53+1. Use the FileRead tool to understand the files' contents and context
54+2. Verify all file paths are correct (use the LS tool)
5855
5956CRITICAL REQUIREMENTS FOR USING THIS TOOL:
6057
61−1. PATCH FORMAT: The patch must be in unified diff format, which includes:
62− - File headers (--- a/file_path, +++ b/file_path)
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
79− - Do not leave the code in a broken state
80− - Always use absolute file paths (starting with /)
58+1. UNIQUENESS: Context lines MUST uniquely identify the specific sections you want to change
59+2. PRECISION: All whitespace, indentation, and surrounding code must match exactly
60+3. VALIDATION: Ensure edits result in idiomatic, correct code
61+4. PATHS: Always use absolute file paths (starting with /)
8162
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.`
8364)
8465
8566func NewPatchTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service) BaseTool {
@@ -95,16 +76,12 @@ func (p *patchTool) Info() ToolInfo {
9576 Name: PatchToolName,
9677 Description: patchDescription,
9778 Parameters: map[string]any{
98− "file_path": map[string]any{
79+ "patch_text": map[string]any{
9980 "type": "string",
100− "description": "The absolute path to the file to modify",
101− },
102− "patch": map[string]any{
103− "type": "string",
104− "description": "The unified diff patch to apply",
81+ "description": "The full patch text that describes all changes to be made",
10582 },
10683 },
107− Required: []string{"file_path", "patch"},
84+ Required: []string{"patch_text"},
10885 }
10986}
11087
@@ -114,187 +91,278 @@ func (p *patchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
11491 return NewTextErrorResponse("invalid parameters"), nil
11592 }
11693
117− if params.FilePath == "" {
118− return NewTextErrorResponse("file_path is required"), nil
94+ if params.PatchText == "" {
95+ return NewTextErrorResponse("patch_text is required"), nil
11996 }
12097
121− if params.Patch == "" {
122− return NewTextErrorResponse("patch is required"), nil
123− }
98+ // Identify all files needed for the patch and verify they've been read
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)
105+ }
124106
125− if !filepath.IsAbs(params.FilePath) {
126− wd := config.WorkingDirectory()
127− params.FilePath = filepath.Join(wd, params.FilePath)
128− }
107+ if getLastReadTime(absPath).IsZero() {
108+ return NewTextErrorResponse(fmt.Sprintf("you must read the file %s before patching it. Use the FileRead tool first", filePath)), nil
109+ }
129110
130− // Check if file exists
131− fileInfo, err := os.Stat(params.FilePath)
132− if err != nil {
133− if os.IsNotExist(err) {
134− return NewTextErrorResponse(fmt.Sprintf("file not found: %s", params.FilePath)), nil
111+ fileInfo, err := os.Stat(absPath)
112+ if err != nil {
113+ if os.IsNotExist(err) {
114+ return NewTextErrorResponse(fmt.Sprintf("file not found: %s", absPath)), nil
115+ }
116+ return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
135117 }
136− return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
137− }
138118
139− if fileInfo.IsDir() {
140− return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil
141− }
119+ if fileInfo.IsDir() {
120+ return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", absPath)), nil
121+ }
142122
143− if getLastReadTime(params.FilePath).IsZero() {
144− return NewTextErrorResponse("you must read the file before patching it. Use the View tool first"), nil
123+ modTime := fileInfo.ModTime()
124+ lastRead := getLastReadTime(absPath)
125+ if modTime.After(lastRead) {
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+ }
145131 }
146132
147− modTime := fileInfo.ModTime()
148− lastRead := getLastReadTime(params.FilePath)
149− if modTime.After(lastRead) {
150− return NewTextErrorResponse(
151− fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
152− params.FilePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
153− )), nil
154− }
133+ // Check for new files to ensure they don't already exist
134+ filesToAdd := diff.IdentifyFilesAdded(params.PatchText)
135+ for _, filePath := range filesToAdd {
136+ absPath := filePath
137+ if !filepath.IsAbs(absPath) {
138+ wd := config.WorkingDirectory()
139+ absPath = filepath.Join(wd, absPath)
140+ }
155141
156− // Read the current file content
157− content, err := os.ReadFile(params.FilePath)
158− if err != nil {
159− return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
142+ _, err := os.Stat(absPath)
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) {
146+ return ToolResponse{}, fmt.Errorf("failed to check file: %w", err)
147+ }
160148 }
161149
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+ }
163158
164− // Parse and apply the patch
165− diffResult, err := diff.ParseUnifiedDiff(params.Patch)
166− if err != nil {
167− return NewTextErrorResponse(fmt.Sprintf("failed to parse patch: %v", err)), nil
159+ content, err := os.ReadFile(absPath)
160+ if err != nil {
161+ return ToolResponse{}, fmt.Errorf("failed to read file %s: %w", absPath, err)
162+ }
163+ currentFiles[filePath] = string(content)
168164 }
169165
170− // Apply the patch to get the new content
171− newContent, err := applyPatch(oldContent, diffResult)
166+ // Process the patch
167+ patch, fuzz, err := diff.TextToPatch(params.PatchText, currentFiles)
172168 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
174170 }
175171
176− if oldContent == newContent {
177− return NewTextErrorResponse("patch did not result in any changes to the file"), nil
172+ if fuzz > 0 {
173+ return NewTextErrorResponse(fmt.Sprintf("patch contains fuzzy matches (fuzz level: %d). Please make your context lines more precise", fuzz)), nil
178174 }
179175
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
180183 sessionID, messageID := GetContextValues(ctx)
181184 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")
183186 }
184187
185− // Generate a diff for permission request and metadata
186− diffText, additions, removals := diff.GenerateDiff(
187− oldContent,
188− newContent,
189− params.FilePath,
190− )
191−
192− // Request permission to apply the patch
193− p.permissions.Request(
194− permission.CreatePermissionRequest{
195− Path: filepath.Dir(params.FilePath),
196− ToolName: PatchToolName,
197− Action: "patch",
198− Description: fmt.Sprintf("Apply patch to file %s", params.FilePath),
199− Params: PatchPermissionsParams{
200− FilePath: params.FilePath,
201− Diff: diffText,
202− },
203− },
204− )
205−
206− // Write the new content to the file
207− err = os.WriteFile(params.FilePath, []byte(newContent), 0o644)
208− if err != nil {
209− return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
188+ // Request permission for all changes
189+ for path, change := range commit.Changes {
190+ switch change.Type {
191+ case diff.ActionAdd:
192+ dir := filepath.Dir(path)
193+ patchDiff, _, _ := diff.GenerateDiff("", *change.NewContent, path)
194+ p := p.permissions.Request(
195+ permission.CreatePermissionRequest{
196+ Path: dir,
197+ ToolName: PatchToolName,
198+ Action: "create",
199+ Description: fmt.Sprintf("Create file %s", path),
200+ Params: EditPermissionsParams{
201+ FilePath: path,
202+ Diff: patchDiff,
203+ },
204+ },
205+ )
206+ if !p {
207+ return ToolResponse{}, permission.ErrorPermissionDenied
208+ }
209+ case diff.ActionUpdate:
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+ }
210254 }
211255
212− // Update file history
213− file, err := p.files.GetByPathAndSession(ctx, params.FilePath, sessionID)
214− if err != nil {
215− _, err = p.files.Create(ctx, sessionID, params.FilePath, oldContent)
216− if err != nil {
217− return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
256+ // Apply the changes to the filesystem
257+ err = diff.ApplyCommit(commit, func(path string, content string) error {
258+ absPath := path
259+ if !filepath.IsAbs(absPath) {
260+ wd := config.WorkingDirectory()
261+ absPath = filepath.Join(wd, absPath)
218262 }
219− }
220− if file.Content != oldContent {
221− // User manually changed the content, store an intermediate version
222− _, err = p.files.CreateVersion(ctx, sessionID, params.FilePath, oldContent)
223− if err != nil {
224− fmt.Printf("Error creating file history version: %v\n", err)
263+
264+ // Create parent directories if needed
265+ dir := filepath.Dir(absPath)
266+ if err := os.MkdirAll(dir, 0o755); err != nil {
267+ return fmt.Errorf("failed to create parent directories for %s: %w", absPath, err)
225268 }
226− }
227− // Store the new version
228− _, err = p.files.CreateVersion(ctx, sessionID, params.FilePath, newContent)
269+
270+ return os.WriteFile(absPath, []byte(content), 0o644)
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+ })
229279 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
231281 }
232282
233− recordFileWrite(params.FilePath)
234− recordFileRead(params.FilePath)
283+ // Update file history for all modified files
284+ changedFiles := []string{}
285+ totalAdditions := 0
286+ totalRemovals := 0
235287
236− // Wait for LSP diagnostics and include them in the response
237− waitForLspDiagnostics(ctx, params.FilePath, p.lspClients)
238− text := fmt.Sprintf("<r>\nPatch applied to file: %s\n</r>\n", params.FilePath)
239− text += getDiagnostics(params.FilePath, p.lspClients)
288+ for path, change := range commit.Changes {
289+ absPath := path
290+ if !filepath.IsAbs(absPath) {
291+ wd := config.WorkingDirectory()
292+ absPath = filepath.Join(wd, absPath)
293+ }
294+ changedFiles = append(changedFiles, absPath)
240295
241− return WithResponseMetadata(
242− NewTextResponse(text),
243− PatchResponseMetadata{
244− Diff: diffText,
245− Additions: additions,
246− Removals: removals,
247− }), nil
248−}
296+ oldContent := ""
297+ if change.OldContent != nil {
298+ oldContent = *change.OldContent
299+ }
249300
250−// applyPatch applies a parsed diff to a string and returns the resulting content
251−func applyPatch(content string, diffResult diff.DiffResult) (string, error) {
252− lines := strings.Split(content, "\n")
301+ newContent := ""
302+ if change.NewContent != nil {
303+ newContent = *change.NewContent
304+ }
253305
254− // Process each hunk in the diff
255− for _, hunk := range diffResult.Hunks {
256− // Parse the hunk header to get line numbers
257− var oldStart, oldCount, newStart, newCount int
258− _, err := fmt.Sscanf(hunk.Header, "@@ -%d,%d +%d,%d @@", &oldStart, &oldCount, &newStart, &newCount)
259− if err != nil {
260− // Try alternative format with single line counts
261− _, err = fmt.Sscanf(hunk.Header, "@@ -%d +%d @@", &oldStart, &newStart)
306+ // Calculate diff statistics
307+ _, additions, removals := diff.GenerateDiff(oldContent, newContent, path)
308+ totalAdditions += additions
309+ totalRemovals += removals
310+
311+ // Update history
312+ file, err := p.files.GetByPathAndSession(ctx, absPath, sessionID)
313+ if err != nil && change.Type != diff.ActionAdd {
314+ // If not adding a file, create history entry for existing file
315+ _, err = p.files.Create(ctx, sessionID, absPath, oldContent)
262316 if err != nil {
263− return "", fmt.Errorf("invalid hunk header format: %s", hunk.Header)
317+ fmt.Printf("Error creating file history: %v\n", err)
264318 }
265− oldCount = 1
266− newCount = 1
267319 }
268320
269− // Adjust for 0-based array indexing
270− oldStart--
271− newStart--
272−
273− // Apply the changes
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)
321+ if err == nil && change.Type != diff.ActionAdd && file.Content != oldContent {
322+ // User manually changed content, store intermediate version
323+ _, err = p.files.CreateVersion(ctx, sessionID, absPath, oldContent)
324+ if err != nil {
325+ fmt.Printf("Error creating file history version: %v\n", err)
290326 }
291327 }
292328
293− // Append the rest of the file
294− newLines = append(newLines, lines[currentOldLine:]...)
295− lines = newLines
329+ // Store new version
330+ if change.Type == diff.ActionDelete {
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)
296342 }
297343
298− return strings.Join(lines, "\n"), nil
299−}
344+ // Run LSP diagnostics on all changed files
345+ for _, filePath := range changedFiles {
346+ waitForLspDiagnostics(ctx, filePath, p.lspClients)
347+ }
300348
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 {
2424 lspClients map[string]*lsp.Client
2525}
2626
27+type ViewResponseMetadata struct {
28+ FilePath string `json:"file_path"`
29+ Content string `json:"content"`
30+}
31+
2732const (
2833 ViewToolName = "view"
2934 MaxReadSize = 250 * 1024
@@ -180,7 +185,13 @@ func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
180185 output += "\n</file>\n"
181186 output += getDiagnostics(filePath, v.lspClients)
182187 recordFileRead(filePath)
183− return NewTextResponse(output), nil
188+ return WithResponseMetadata(
189+ NewTextResponse(output),
190+ ViewResponseMetadata{
191+ FilePath: filePath,
192+ Content: content,
193+ },
194+ ), nil
184195}
185196
186197func addLineNumbers(content string, startLine int) string {
internal/tui/components/chat/editor.go+53−5
@@ -1,6 +1,9 @@
11package chat
22
33import (
4+ "os"
5+ "os/exec"
6+
47 "github.com/charmbracelet/bubbles/key"
58 "github.com/charmbracelet/bubbles/textarea"
69 tea "github.com/charmbracelet/bubbletea"
@@ -19,13 +22,15 @@ type editorCmp struct {
1922}
2023
2124type focusedEditorKeyMaps struct {
22− Send key.Binding
23− Blur key.Binding
25+ Send key.Binding
26+ OpenEditor key.Binding
27+ Blur key.Binding
2428}
2529
2630type bluredEditorKeyMaps struct {
27− Send key.Binding
28− Focus key.Binding
31+ Send key.Binding
32+ Focus key.Binding
33+ OpenEditor key.Binding
2934}
3035
3136var focusedKeyMaps = focusedEditorKeyMaps{
@@ -37,6 +42,10 @@ var focusedKeyMaps = focusedEditorKeyMaps{
3742 key.WithKeys("esc"),
3843 key.WithHelp("esc", "focus messages"),
3944 ),
45+ OpenEditor: key.NewBinding(
46+ key.WithKeys("ctrl+e"),
47+ key.WithHelp("ctrl+e", "open editor"),
48+ ),
4049}
4150
4251var bluredKeyMaps = bluredEditorKeyMaps{
@@ -48,6 +57,40 @@ var bluredKeyMaps = bluredEditorKeyMaps{
4857 key.WithKeys("i"),
4958 key.WithHelp("i", "focus editor"),
5059 ),
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+ })
5194}
5295
5396func (m *editorCmp) Init() tea.Cmd {
@@ -82,6 +125,10 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
82125 }
83126 return m, nil
84127 case tea.KeyMsg:
128+ if key.Matches(msg, focusedKeyMaps.OpenEditor) {
129+ m.textarea.Blur()
130+ return m, openEditor()
131+ }
85132 // if the key does not match any binding, return
86133 if m.textarea.Focused() && key.Matches(msg, focusedKeyMaps.Send) {
87134 return m, m.send()
@@ -108,9 +155,10 @@ func (m *editorCmp) View() string {
108155 return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View())
109156}
110157
111−func (m *editorCmp) SetSize(width, height int) {
158+func (m *editorCmp) SetSize(width, height int) tea.Cmd {
112159 m.textarea.SetWidth(width - 3) // account for the prompt and padding right
113160 m.textarea.SetHeight(height)
161+ return nil
114162}
115163
116164func (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 {
5151
5252func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
5353 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+ }
5460 case pubsub.Event[session.Session]:
5561 if msg.Type == pubsub.UpdatedEvent {
5662 if m.session.ID == msg.Payload.ID {
@@ -59,10 +65,16 @@ func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
5965 }
6066 case pubsub.Event[history.File]:
6167 if msg.Payload.SessionID == m.session.ID {
62− // When a file changes, reload all modified files
63− // This ensures we have the complete and accurate list
68+ // Process the individual file change instead of reloading all files
6469 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+ }
6678 }
6779 }
6880 return m, nil
@@ -71,6 +83,8 @@ func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
7183func (m *sidebarCmp) View() string {
7284 return styles.BaseStyle.
7385 Width(m.width).
86+ PaddingLeft(4).
87+ PaddingRight(2).
7488 Height(m.height - 1).
7589 Render(
7690 lipgloss.JoinVertical(
@@ -79,9 +93,9 @@ func (m *sidebarCmp) View() string {
7993 " ",
8094 m.sessionSection(),
8195 " ",
82− m.modifiedFiles(),
83− " ",
8496 lspsConfigured(m.width),
97+ " ",
98+ m.modifiedFiles(),
8599 ),
86100 )
87101}
@@ -170,9 +184,10 @@ func (m *sidebarCmp) modifiedFiles() string {
170184 )
171185}
172186
173−func (m *sidebarCmp) SetSize(width, height int) {
187+func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
174188 m.width = width
175189 m.height = height
190+ return nil
176191}
177192
178193func (m *sidebarCmp) GetSize() (int, int) {
@@ -203,6 +218,12 @@ func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) {
203218 return
204219 }
205220
221+ // Clear the existing map to rebuild it
222+ m.modFiles = make(map[string]struct {
223+ additions int
224+ removals int
225+ })
226+
206227 // Process each latest file
207228 for _, file := range latestFiles {
208229 // Skip if this is the initial version (no changes to show)
@@ -250,28 +271,23 @@ func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) {
250271}
251272
252273func (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)
254275 if file.Version == history.InitialVersion {
255276 return
256277 }
257278
258− // Get all versions of this file
259− fileVersions, err := m.history.ListBySession(ctx, m.session.ID)
260− if err != nil {
279+ // Find the initial version for this file
280+ initialVersion, err := m.findInitialVersion(ctx, file.Path)
281+ if err != nil || initialVersion.ID == "" {
261282 return
262283 }
263284
264− // Find the initial version
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 == "" {
285+ // Skip if content hasn't changed
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)
275291 return
276292 }
277293
@@ -280,12 +296,7 @@ func (m *sidebarCmp) processFileChanges(ctx context.Context, file history.File)
280296
281297 // Only add to modified files if there are changes
282298 if additions > 0 || removals > 0 {
283− // Remove working directory prefix from file path
284− displayPath := file.Path
285− workingDir := config.WorkingDirectory()
286− displayPath = strings.TrimPrefix(displayPath, workingDir)
287− displayPath = strings.TrimPrefix(displayPath, "/")
288−
299+ displayPath := getDisplayPath(file.Path)
289300 m.modFiles[displayPath] = struct {
290301 additions int
291302 removals int
@@ -293,5 +304,34 @@ func (m *sidebarCmp) processFileChanges(ctx context.Context, file history.File)
293304 additions: additions,
294305 removals: removals,
295306 }
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
296320 }
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, "/")
297337}
internal/tui/components/core/status.go+19−5
@@ -166,19 +166,31 @@ func (m *statusCmp) projectDiagnostics() string {
166166 diagnostics := []string{}
167167
168168 if len(errorDiagnostics) > 0 {
169− errStr := lipgloss.NewStyle().Foreground(styles.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, len(errorDiagnostics)))
169+ errStr := lipgloss.NewStyle().
170+ Background(styles.BackgroundDarker).
171+ Foreground(styles.Error).
172+ Render(fmt.Sprintf("%s %d", styles.ErrorIcon, len(errorDiagnostics)))
170173 diagnostics = append(diagnostics, errStr)
171174 }
172175 if len(warnDiagnostics) > 0 {
173− warnStr := lipgloss.NewStyle().Foreground(styles.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, len(warnDiagnostics)))
176+ warnStr := lipgloss.NewStyle().
177+ Background(styles.BackgroundDarker).
178+ Foreground(styles.Warning).
179+ Render(fmt.Sprintf("%s %d", styles.WarningIcon, len(warnDiagnostics)))
174180 diagnostics = append(diagnostics, warnStr)
175181 }
176182 if len(hintDiagnostics) > 0 {
177− hintStr := lipgloss.NewStyle().Foreground(styles.Text).Render(fmt.Sprintf("%s %d", styles.HintIcon, len(hintDiagnostics)))
183+ hintStr := lipgloss.NewStyle().
184+ Background(styles.BackgroundDarker).
185+ Foreground(styles.Text).
186+ Render(fmt.Sprintf("%s %d", styles.HintIcon, len(hintDiagnostics)))
178187 diagnostics = append(diagnostics, hintStr)
179188 }
180189 if len(infoDiagnostics) > 0 {
181− infoStr := lipgloss.NewStyle().Foreground(styles.Peach).Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics)))
190+ infoStr := lipgloss.NewStyle().
191+ Background(styles.BackgroundDarker).
192+ Foreground(styles.Peach).
193+ Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics)))
182194 diagnostics = append(diagnostics, infoStr)
183195 }
184196
@@ -187,10 +199,12 @@ func (m *statusCmp) projectDiagnostics() string {
187199
188200func (m statusCmp) availableFooterMsgWidth(diagnostics string) int {
189201 tokens := ""
202+ tokensWidth := 0
190203 if m.session.ID != "" {
191204 tokens = formatTokensAndCost(m.session.PromptTokens+m.session.CompletionTokens, m.session.Cost)
205+ tokensWidth = lipgloss.Width(tokens) + 2
192206 }
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)
194208}
195209
196210func (m statusCmp) model() string {
internal/tui/components/dialog/permission.go+9−7
@@ -36,7 +36,7 @@ type PermissionResponseMsg struct {
3636type PermissionDialogCmp interface {
3737 tea.Model
3838 layout.Bindings
39− SetPermissions(permission permission.PermissionRequest)
39+ SetPermissions(permission permission.PermissionRequest) tea.Cmd
4040}
4141
4242type permissionsMapping struct {
@@ -98,7 +98,8 @@ func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
9898 switch msg := msg.(type) {
9999 case tea.WindowSizeMsg:
100100 p.windowSize = msg
101− p.SetSize()
101+ cmd := p.SetSize()
102+ cmds = append(cmds, cmd)
102103 p.markdownCache = make(map[string]string)
103104 p.diffCache = make(map[string]string)
104105 case tea.KeyMsg:
@@ -267,7 +268,7 @@ func (p *permissionDialogCmp) renderEditContent() string {
267268}
268269
269270func (p *permissionDialogCmp) renderPatchContent() string {
270− if pr, ok := p.permission.Params.(tools.PatchPermissionsParams); ok {
271+ if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
271272 diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
272273 return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
273274 })
@@ -401,9 +402,9 @@ func (p *permissionDialogCmp) BindingKeys() []key.Binding {
401402 return layout.KeyMapToSlice(helpKeys)
402403}
403404
404−func (p *permissionDialogCmp) SetSize() {
405+func (p *permissionDialogCmp) SetSize() tea.Cmd {
405406 if p.permission.ID == "" {
406− return
407+ return nil
407408 }
408409 switch p.permission.ToolName {
409410 case tools.BashToolName:
@@ -422,11 +423,12 @@ func (p *permissionDialogCmp) SetSize() {
422423 p.width = int(float64(p.windowSize.Width) * 0.7)
423424 p.height = int(float64(p.windowSize.Height) * 0.5)
424425 }
426+ return nil
425427}
426428
427−func (p *permissionDialogCmp) SetPermissions(permission permission.PermissionRequest) {
429+func (p *permissionDialogCmp) SetPermissions(permission permission.PermissionRequest) tea.Cmd {
428430 p.permission = permission
429− p.SetSize()
431+ return p.SetSize()
430432}
431433
432434// 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) {
119119 return i.width, i.height
120120}
121121
122−func (i *detailCmp) SetSize(width int, height int) {
122+func (i *detailCmp) SetSize(width int, height int) tea.Cmd {
123123 i.width = width
124124 i.height = height
125125 i.viewport.Width = i.width
126126 i.viewport.Height = i.height
127127 i.updateContent()
128+ return nil
128129}
129130
130131func (i *detailCmp) BindingKeys() []key.Binding {
131− return []key.Binding{
132− i.viewport.KeyMap.PageDown,
133− i.viewport.KeyMap.PageUp,
134− i.viewport.KeyMap.HalfPageDown,
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− }
132+ return layout.KeyMapToSlice(i.viewport.KeyMap)
143133}
144134
145135func NewLogsDetails() DetailComponent {
internal/tui/components/logs/table.go+2−1
@@ -68,7 +68,7 @@ func (i *tableCmp) GetSize() (int, int) {
6868 return i.table.Width(), i.table.Height()
6969}
7070
71−func (i *tableCmp) SetSize(width int, height int) {
71+func (i *tableCmp) SetSize(width int, height int) tea.Cmd {
7272 i.table.SetWidth(width)
7373 i.table.SetHeight(height)
7474 cloumns := i.table.Columns()
@@ -77,6 +77,7 @@ func (i *tableCmp) SetSize(width int, height int) {
7777 cloumns[i] = col
7878 }
7979 i.table.SetColumns(cloumns)
80+ return nil
8081}
8182
8283func (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 {
8686 return style.Render(c.content.View())
8787}
8888
89−func (c *container) SetSize(width, height int) {
89+func (c *container) SetSize(width, height int) tea.Cmd {
9090 c.width = width
9191 c.height = height
9292
@@ -113,8 +113,9 @@ func (c *container) SetSize(width, height int) {
113113 // Set content size with adjusted dimensions
114114 contentWidth := max(0, width-horizontalSpace)
115115 contentHeight := max(0, height-verticalSpace)
116− sizeable.SetSize(contentWidth, contentHeight)
116+ return sizeable.SetSize(contentWidth, contentHeight)
117117 }
118+ return nil
118119}
119120
120121func (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 {
1313 IsFocused() bool
1414}
1515
16−type Bordered interface {
17− BorderText() map[BorderPosition]string
18−}
19−
2016type Sizeable interface {
21− SetSize(width, height int)
17+ SetSize(width, height int) tea.Cmd
2218 GetSize() (int, int)
2319}
2420
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 {
1111 tea.Model
1212 Sizeable
1313 Bindings
14− SetLeftPanel(panel Container)
15− SetRightPanel(panel Container)
16− SetBottomPanel(panel Container)
14+ SetLeftPanel(panel Container) tea.Cmd
15+ SetRightPanel(panel Container) tea.Cmd
16+ SetBottomPanel(panel Container) tea.Cmd
1717}
1818
1919type splitPaneLayout struct {
@@ -53,8 +53,7 @@ func (s *splitPaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
5353 var cmds []tea.Cmd
5454 switch msg := msg.(type) {
5555 case tea.WindowSizeMsg:
56− s.SetSize(msg.Width, msg.Height)
57− return s, nil
56+ return s, s.SetSize(msg.Width, msg.Height)
5857 }
5958
6059 if s.rightPanel != nil {
@@ -122,7 +121,7 @@ func (s *splitPaneLayout) View() string {
122121 return finalView
123122}
124123
125−func (s *splitPaneLayout) SetSize(width, height int) {
124+func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
126125 s.width = width
127126 s.height = height
128127
@@ -147,42 +146,50 @@ func (s *splitPaneLayout) SetSize(width, height int) {
147146 rightWidth = width
148147 }
149148
149+ var cmds []tea.Cmd
150150 if s.leftPanel != nil {
151− s.leftPanel.SetSize(leftWidth, topHeight)
151+ cmd := s.leftPanel.SetSize(leftWidth, topHeight)
152+ cmds = append(cmds, cmd)
152153 }
153154
154155 if s.rightPanel != nil {
155− s.rightPanel.SetSize(rightWidth, topHeight)
156+ cmd := s.rightPanel.SetSize(rightWidth, topHeight)
157+ cmds = append(cmds, cmd)
156158 }
157159
158160 if s.bottomPanel != nil {
159− s.bottomPanel.SetSize(width, bottomHeight)
161+ cmd := s.bottomPanel.SetSize(width, bottomHeight)
162+ cmds = append(cmds, cmd)
160163 }
164+ return tea.Batch(cmds...)
161165}
162166
163167func (s *splitPaneLayout) GetSize() (int, int) {
164168 return s.width, s.height
165169}
166170
167−func (s *splitPaneLayout) SetLeftPanel(panel Container) {
171+func (s *splitPaneLayout) SetLeftPanel(panel Container) tea.Cmd {
168172 s.leftPanel = panel
169173 if s.width > 0 && s.height > 0 {
170− s.SetSize(s.width, s.height)
174+ return s.SetSize(s.width, s.height)
171175 }
176+ return nil
172177}
173178
174−func (s *splitPaneLayout) SetRightPanel(panel Container) {
179+func (s *splitPaneLayout) SetRightPanel(panel Container) tea.Cmd {
175180 s.rightPanel = panel
176181 if s.width > 0 && s.height > 0 {
177− s.SetSize(s.width, s.height)
182+ return s.SetSize(s.width, s.height)
178183 }
184+ return nil
179185}
180186
181−func (s *splitPaneLayout) SetBottomPanel(panel Container) {
187+func (s *splitPaneLayout) SetBottomPanel(panel Container) tea.Cmd {
182188 s.bottomPanel = panel
183189 if s.width > 0 && s.height > 0 {
184− s.SetSize(s.width, s.height)
190+ return s.SetSize(s.width, s.height)
185191 }
192+ return nil
186193}
187194
188195func (s *splitPaneLayout) BindingKeys() []key.Binding {
internal/tui/page/chat.go+14−17
@@ -54,9 +54,11 @@ func (p *chatPage) Init() tea.Cmd {
5454}
5555
5656func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
57+ var cmds []tea.Cmd
5758 switch msg := msg.(type) {
5859 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)
6062 case chat.SendMsg:
6163 cmd := p.sendMessage(msg.Text)
6264 if cmd != nil {
@@ -68,8 +70,10 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
6870 switch {
6971 case key.Matches(msg, keyMap.NewSession):
7072 p.session = session.Session{}
71− p.clearSidebar()
72− return p, util.CmdHandler(chat.SessionClearedMsg{})
73+ return p, tea.Batch(
74+ p.clearSidebar(),
75+ util.CmdHandler(chat.SessionClearedMsg{}),
76+ )
7377 case key.Matches(msg, keyMap.Cancel):
7478 if p.session.ID != "" {
7579 // Cancel the current session's generation process
@@ -80,11 +84,9 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
8084 }
8185 }
8286 u, cmd := p.layout.Update(msg)
87+ cmds = append(cmds, cmd)
8388 p.layout = u.(layout.SplitPaneLayout)
84− if cmd != nil {
85− return p, cmd
86− }
87− return p, nil
89+ return p, tea.Batch(cmds...)
8890}
8991
9092func (p *chatPage) setSidebar() tea.Cmd {
@@ -92,16 +94,11 @@ func (p *chatPage) setSidebar() tea.Cmd {
9294 chat.NewSidebarCmp(p.session, p.app.History),
9395 layout.WithPadding(1, 1, 1, 1),
9496 )
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())
9998}
10099
101−func (p *chatPage) clearSidebar() {
102− p.layout.SetRightPanel(nil)
103− width, height := p.layout.GetSize()
104− p.layout.SetSize(width, height)
100+func (p *chatPage) clearSidebar() tea.Cmd {
101+ return p.layout.SetRightPanel(nil)
105102}
106103
107104func (p *chatPage) sendMessage(text string) tea.Cmd {
@@ -124,8 +121,8 @@ func (p *chatPage) sendMessage(text string) tea.Cmd {
124121 return tea.Batch(cmds...)
125122}
126123
127−func (p *chatPage) SetSize(width, height int) {
128− p.layout.SetSize(width, height)
124+func (p *chatPage) SetSize(width, height int) tea.Cmd {
125+ return p.layout.SetSize(width, height)
129126}
130127
131128func (p *chatPage) GetSize() (int, int) {
internal/tui/page/logs.go+7−6
@@ -23,15 +23,14 @@ type logsPage struct {
2323}
2424
2525func (p *logsPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
26+ var cmds []tea.Cmd
2627 switch msg := msg.(type) {
2728 case tea.WindowSizeMsg:
2829 p.width = msg.Width
2930 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)
3232 }
3333
34− var cmds []tea.Cmd
3534 table, cmd := p.table.Update(msg)
3635 cmds = append(cmds, cmd)
3736 p.table = table.(layout.Container)
@@ -60,11 +59,13 @@ func (p *logsPage) GetSize() (int, int) {
6059}
6160
6261// SetSize implements LogPage.
63−func (p *logsPage) SetSize(width int, height int) {
62+func (p *logsPage) SetSize(width int, height int) tea.Cmd {
6463 p.width = width
6564 p.height = height
66− p.table.SetSize(width, height/2)
67− p.details.SetSize(width, height/2)
65+ return tea.Batch(
66+ p.table.SetSize(width, height/2),
67+ p.details.SetSize(width, height/2),
68+ )
6869}
6970
7071func (p *logsPage) Init() tea.Cmd {
internal/tui/styles/background.go+78−36
@@ -3,7 +3,6 @@ package styles
33import (
44 "fmt"
55 "regexp"
6− "strconv"
76 "strings"
87
98 "github.com/charmbracelet/lipgloss"
@@ -25,57 +24,100 @@ func getColorRGB(c lipgloss.TerminalColor) (uint8, uint8, uint8) {
2524 return uint8(r >> 8), uint8(g >> 8), uint8(b >> 8)
2625}
2726
27+// ForceReplaceBackgroundWithLipgloss replaces any ANSI background color codes
28+// in `input` with a single 24‑bit background (48;2;R;G;B).
2829func ForceReplaceBackgroundWithLipgloss(input string, newBgColor lipgloss.TerminalColor) string {
30+ // Precompute our new-bg sequence once
2931 r, g, b := getColorRGB(newBgColor)
30−
3132 newBg := fmt.Sprintf("48;2;%d;%d;%d", r, g, b)
3233
3334 return ansiEscape.ReplaceAllStringFunc(input, func(seq string) string {
34− // Extract content between "\x1b[" and "m"
35− content := seq[2 : len(seq)-1]
36− tokens := strings.Split(content, ";")
37− var newTokens []string
38−
39− // Skip background color tokens
40− for i := 0; i < len(tokens); i++ {
41− if tokens[i] == "" {
42− continue
43− }
35+ const (
36+ escPrefixLen = 2 // "\x1b["
37+ escSuffixLen = 1 // "m"
38+ )
39+
40+ raw := seq
41+ start := escPrefixLen
42+ end := len(raw) - escSuffixLen
4443
45− val, err := strconv.Atoi(tokens[i])
46− if err != nil {
47− newTokens = append(newTokens, tokens[i])
48− continue
44+ var sb strings.Builder
45+ // reserve enough space: original content minus bg codes + our newBg
46+ sb.Grow((end - start) + len(newBg) + 2)
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++
4954 }
55+ token := raw[i:j]
5056
51− // Skip background color tokens
52− if val == 48 {
53− // Skip "48;5;N" or "48;2;R;G;B" sequences
54− if i+1 < len(tokens) {
55− if nextVal, err := strconv.Atoi(tokens[i+1]); err == nil {
56− switch nextVal {
57− case 5:
58− i += 2 // Skip "5" and color index
59− case 2:
60− i += 4 // Skip "2" and RGB components
57+ // fast‑path: skip "48;5;N" or "48;2;R;G;B"
58+ if len(token) == 2 && token[0] == '4' && token[1] == '8' {
59+ k := j + 1
60+ if k < end {
61+ // find next token
62+ l := k
63+ for l < end && raw[l] != ';' {
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++
6183 }
84+ i = m
85+ continue
6286 }
6387 }
64− } else if (val < 40 || val > 47) && (val < 100 || val > 107) && val != 49 {
65− // Keep non-background tokens
66− newTokens = append(newTokens, tokens[i])
6788 }
68− }
6989
70− // Add new background if provided
71− if newBg != "" {
72− newTokens = append(newTokens, strings.Split(newBg, ";")...)
90+ // decide whether to keep this token
91+ // manually parse ASCII digits to int
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
73113 }
74114
75− if len(newTokens) == 0 {
76− return ""
115+ // append our new background
116+ if sb.Len() > 0 {
117+ sb.WriteByte(';')
77118 }
119+ sb.WriteString(newBg)
78120
79− return "\x1b[" + strings.Join(newTokens, ";") + "m"
121+ return "\x1b[" + sb.String() + "m"
80122 })
81123}
internal/tui/styles/icons.go+3−11
@@ -2,19 +2,11 @@ package styles
22
33const (
44 OpenCodeIcon string = "⌬"
5− SessionsIcon string = "󰧑"
6− ChatIcon string = "󰭹"
7−
8− BotIcon string = "󰚩"
9− ToolIcon string = ""
10− UserIcon string = ""
115
126 CheckIcon string = "✓"
13− ErrorIcon string = ""
14− WarningIcon string = ""
7+ ErrorIcon string = "✖"
8+ WarningIcon string = "⚠"
159 InfoIcon string = ""
16− HintIcon string = ""
10+ HintIcon string = "i"
1711 SpinnerIcon string = "..."
18− BugIcon string = ""
19− SleepIcon string = "󰒲"
2012)
internal/tui/tui.go+95−16
@@ -1,6 +1,8 @@
11package tui
22
33import (
4+ "context"
5+
46 "github.com/charmbracelet/bubbles/key"
57 tea "github.com/charmbracelet/bubbletea"
68 "github.com/charmbracelet/lipgloss"
@@ -8,6 +10,7 @@ import (
810 "github.com/kujtimiihoxha/opencode/internal/logging"
911 "github.com/kujtimiihoxha/opencode/internal/permission"
1012 "github.com/kujtimiihoxha/opencode/internal/pubsub"
13+ "github.com/kujtimiihoxha/opencode/internal/tui/components/chat"
1114 "github.com/kujtimiihoxha/opencode/internal/tui/components/core"
1215 "github.com/kujtimiihoxha/opencode/internal/tui/components/dialog"
1316 "github.com/kujtimiihoxha/opencode/internal/tui/layout"
@@ -16,9 +19,10 @@ import (
1619)
1720
1821type keyMap struct {
19− Logs key.Binding
20− Quit key.Binding
21− Help key.Binding
22+ Logs key.Binding
23+ Quit key.Binding
24+ Help key.Binding
25+ SwitchSession key.Binding
2226}
2327
2428var keys = keyMap{
@@ -35,6 +39,10 @@ var keys = keyMap{
3539 key.WithKeys("ctrl+_"),
3640 key.WithHelp("ctrl+?", "toggle help"),
3741 ),
42+ SwitchSession: key.NewBinding(
43+ key.WithKeys("ctrl+a"),
44+ key.WithHelp("ctrl+a", "switch session"),
45+ ),
3846}
3947
4048var returnKey = key.NewBinding(
@@ -64,6 +72,9 @@ type appModel struct {
6472
6573 showQuit bool
6674 quit dialog.QuitDialog
75+
76+ showSessionDialog bool
77+ sessionDialog dialog.SessionDialog
6778}
6879
6980func (a appModel) Init() tea.Cmd {
@@ -77,6 +88,8 @@ func (a appModel) Init() tea.Cmd {
7788 cmds = append(cmds, cmd)
7889 cmd = a.help.Init()
7990 cmds = append(cmds, cmd)
91+ cmd = a.sessionDialog.Init()
92+ cmds = append(cmds, cmd)
8093 return tea.Batch(cmds...)
8194}
8295
@@ -100,6 +113,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
100113 a.help = help.(dialog.HelpCmp)
101114 cmds = append(cmds, helpCmd)
102115
116+ session, sessionCmd := a.sessionDialog.Update(msg)
117+ a.sessionDialog = session.(dialog.SessionDialog)
118+ cmds = append(cmds, sessionCmd)
119+
103120 return a, tea.Batch(cmds...)
104121
105122 // Status
@@ -144,8 +161,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
144161 // Permission
145162 case pubsub.Event[permission.PermissionRequest]:
146163 a.showPermissions = true
147− a.permissions.SetPermissions(msg.Payload)
148− return a, nil
164+ return a, a.permissions.SetPermissions(msg.Payload)
149165 case dialog.PermissionResponseMsg:
150166 switch msg.Action {
151167 case dialog.PermissionAllow:
@@ -165,6 +181,19 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
165181 a.showQuit = false
166182 return a, nil
167183
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+
168197 case tea.KeyMsg:
169198 switch {
170199 case key.Matches(msg, keys.Quit):
@@ -172,6 +201,24 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
172201 if a.showHelp {
173202 a.showHelp = false
174203 }
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+ }
175222 return a, nil
176223 case key.Matches(msg, logsKeyReturnKey):
177224 if a.currentPage == page.LogsPage {
@@ -216,6 +263,16 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
216263 }
217264 }
218265
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+
219276 a.status, _ = a.status.Update(msg)
220277 a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
221278 cmds = append(cmds, cmd)
@@ -223,18 +280,24 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
223280}
224281
225282func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
226− var cmd 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+ }
287+ var cmds []tea.Cmd
227288 if _, ok := a.loadedPages[pageID]; !ok {
228− cmd = a.pages[pageID].Init()
289+ cmd := a.pages[pageID].Init()
290+ cmds = append(cmds, cmd)
229291 a.loadedPages[pageID] = true
230292 }
231293 a.previousPage = a.currentPage
232294 a.currentPage = pageID
233295 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)
235298 }
236299
237− return cmd
300+ return tea.Batch(cmds...)
238301}
239302
240303func (a appModel) View() string {
@@ -304,19 +367,35 @@ func (a appModel) View() string {
304367 )
305368 }
306369
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+
307385 return appView
308386}
309387
310388func New(app *app.App) tea.Model {
311389 startPage := page.ChatPage
312390 return &appModel{
313− currentPage: startPage,
314− loadedPages: make(map[page.PageID]bool),
315− status: core.NewStatusCmp(app.LSPClients),
316− help: dialog.NewHelpCmp(),
317− quit: dialog.NewQuitCmp(),
318− permissions: dialog.NewPermissionDialogCmp(),
319− app: app,
391+ currentPage: startPage,
392+ loadedPages: make(map[page.PageID]bool),
393+ status: core.NewStatusCmp(app.LSPClients),
394+ help: dialog.NewHelpCmp(),
395+ quit: dialog.NewQuitCmp(),
396+ sessionDialog: dialog.NewSessionDialogCmp(),
397+ permissions: dialog.NewPermissionDialogCmp(),
398+ app: app,
320399 pages: map[page.PageID]tea.Model{
321400 page.ChatPage: page.NewChatPage(app),
322401 page.LogsPage: page.NewLogsPage(),