on main help sign in
Merge pull request #27 from kujtimiihoxha/opencode

OpenCode - Initial Implementation

commitf33dff87725764af0b675b5e5b2e011b21c14c90 authorKujtim Hoxha <kujtimii.h@gmail.com> dateMon 21 Apr 2025, 19:59 (2 years ago) parent6b1c64b3a6a269
119 files changed+13824−7504
.gitignore+2−2 .opencode.json+8 .termai.json−11 LICENSE+21 README.md+305−52 cmd/root.go+194−79 cmd/schema/README.md+64 cmd/schema/main.go+262 go.mod+26−17 go.sum+78−34 internal/app/app.go+99 internal/app/lsp.go+126 internal/app/services.go−60 internal/config/config.go+490−103 internal/config/config_test.go−465 internal/config/init.go+61 internal/db/connect.go+5−5 internal/db/db.go+126−26 internal/db/files.sql.go+311 internal/db/messages.sql.go+23−11 internal/db/migrations/000001_initial.down.sql+2 internal/db/migrations/000001_initial.up.sql+25 internal/db/models.go+15−3 internal/db/querier.go+10 internal/db/sql/files.sql+71 internal/db/sql/messages.sql+4−1 internal/diff/diff.go+1047 internal/diff/patch.go+740 internal/history/file.go+252 internal/llm/agent/agent-tool.go+36−27 internal/llm/agent/agent.go+395−421 internal/llm/agent/coder.go−73 internal/llm/agent/mcp-tools.go+12−7 internal/llm/agent/task.go−46 internal/llm/agent/tools.go+51 internal/llm/models/anthropic.go+77 internal/llm/models/gemini.go+63 internal/llm/models/models.go+54−97 internal/llm/models/openai.go+169 internal/llm/prompt/coder.go+62−66 internal/llm/prompt/prompt.go+63 internal/llm/prompt/task.go+4−3 internal/llm/prompt/title.go+4−1 internal/llm/provider/anthropic.go+316−266 internal/llm/provider/bedrock.go+59−46 internal/llm/provider/gemini.go+351−185 internal/llm/provider/openai.go+275−160 internal/llm/provider/provider.go+136−38 internal/llm/tools/bash.go+29−13 internal/llm/tools/bash_test.go−371 internal/llm/tools/diagnostics.go+4−4 internal/llm/tools/edit.go+214−127 internal/llm/tools/edit_test.go−509 internal/llm/tools/fetch.go+13−6 internal/llm/tools/glob.go+51−4 internal/llm/tools/grep.go+103−42 internal/llm/tools/ls.go+14−3 internal/llm/tools/patch.go+372 internal/llm/tools/shell/shell.go+15−5 internal/llm/tools/sourcegraph.go+11−6 internal/llm/tools/sourcegraph_test.go−86 internal/llm/tools/tools.go+39−4 internal/llm/tools/view.go+18−6 internal/llm/tools/write.go+71−14 internal/llm/tools/write_test.go−307 internal/logging/logger.go+40−1 internal/logging/writer.go+1−1 internal/lsp/client.go+363−20 internal/lsp/handlers.go+5−5 internal/lsp/language.go+1−1 internal/lsp/methods.go+1−1 internal/lsp/transport.go+16−16 internal/lsp/util/edit.go+1−1 internal/lsp/watcher/watcher.go+386−60 internal/message/content.go+84−4 internal/message/message.go+39−28 internal/permission/permission.go+27−5 internal/pubsub/broker.go+47−32 internal/session/session.go+38−25 internal/tui/components/chat/chat.go+119 internal/tui/components/chat/editor.go+207 internal/tui/components/chat/list.go+415 internal/tui/components/chat/message.go+624 internal/tui/components/chat/sidebar.go+337 internal/tui/components/core/button.go−287 internal/tui/components/core/dialog.go−117 internal/tui/components/core/help.go−119 internal/tui/components/core/status.go+171−21 internal/tui/components/dialog/commands.go+247 internal/tui/components/dialog/help.go+182 internal/tui/components/dialog/init.go+191 internal/tui/components/dialog/permission.go+360−350 internal/tui/components/dialog/quit.go+94−68 internal/tui/components/dialog/session.go+226 internal/tui/components/logs/details.go+8−45 internal/tui/components/logs/table.go+29−51 internal/tui/components/repl/editor.go−201 internal/tui/components/repl/messages.go−512 internal/tui/components/repl/sessions.go−247 internal/tui/layout/bento.go−392 internal/tui/layout/border.go−121 internal/tui/layout/container.go+226 internal/tui/layout/grid.go−254 internal/tui/layout/layout.go+1−5 internal/tui/layout/overlay.go+7−6 internal/tui/layout/single.go−189 internal/tui/layout/split.go+289 internal/tui/page/chat.go+167 internal/tui/page/init.go−308 internal/tui/page/logs.go+73−10 internal/tui/page/repl.go−21 internal/tui/styles/background.go+123 internal/tui/styles/icons.go+7−13 internal/tui/styles/markdown.go+445−2 internal/tui/styles/styles.go+49 internal/tui/tui.go+454−151 internal/version/version.go+1−1 main.go+6−1 opencode-schema.json+269
.gitignore+2−2
@@ -31,7 +31,7 @@ go.work
3131.Trashes
3232ehthumbs.db
3333Thumbs.db
34−debug.log
34+*.log
3535
3636# Binary output directory
3737/bin/
@@ -41,5 +41,5 @@ debug.log
4141.env
4242.env.local
4343
44−.termai
44+.opencode/
4545
.opencode.jsonnew file+8
@@ -0,0 +1,8 @@
1+{
2+ "$schema": "./opencode-schema.json",
3+ "lsp": {
4+ "gopls": {
5+ "command": "gopls"
6+ }
7+ }
8+}
.termai.jsondeleted−11
@@ -1,11 +0,0 @@
1−{
2− "model": {
3− "coder": "claude-3.7-sonnet",
4− "coderMaxTokens": 20000
5− },
6− "lsp": {
7− "gopls": {
8− "command": "gopls"
9− }
10− }
11−}
LICENSEnew file+21
@@ -0,0 +1,21 @@
1+MIT License
2+
3+Copyright (c) 2025 Kujtim Hoxha
4+
5+Permission is hereby granted, free of charge, to any person obtaining a copy
6+of this software and associated documentation files (the "Software"), to deal
7+in the Software without restriction, including without limitation the rights
8+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+copies of the Software, and to permit persons to whom the Software is
10+furnished to do so, subject to the following conditions:
11+
12+The above copyright notice and this permission notice shall be included in all
13+copies or substantial portions of the Software.
14+
15+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+SOFTWARE.
README.md+305−52
@@ -1,83 +1,244 @@
1−# TermAI
1+# OpenCode
22
33> **⚠️ Early Development Notice:** This project is in early development and is not yet ready for production use. Features may change, break, or be incomplete. Use at your own risk.
44
55A powerful terminal-based AI assistant for developers, providing intelligent coding assistance directly in your terminal.
66
7−[![TermAI Demo](https://asciinema.org/a/dtc4nJyGSZX79HRUmFLY3gmoy.svg)](https://asciinema.org/a/dtc4nJyGSZX79HRUmFLY3gmoy)
8−
97## Overview
108
11−TermAI 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.
9+OpenCode is a Go-based CLI application that brings AI assistance to your terminal. It provides a TUI (Terminal User Interface) for interacting with various AI models to help with coding tasks, debugging, and more.
1210
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
21+- **External Editor Support**: Open your preferred editor for composing messages
2122
2223## Installation
2324
2425```bash
2526# Coming soon
26−go install github.com/kujtimiihoxha/termai@latest
27+go install github.com/kujtimiihoxha/opencode@latest
2728```
2829
2930## Configuration
3031
31−TermAI looks for configuration in the following locations:
32+OpenCode looks for configuration in the following locations:
33+
34+- `$HOME/.opencode.json`
35+- `$XDG_CONFIG_HOME/opencode/.opencode.json`
36+- `./.opencode.json` (local directory)
37+
38+### Environment Variables
39+
40+You can configure OpenCode using environment variables:
41+
42+| Environment Variable | Purpose |
43+| ----------------------- | ------------------------ |
44+| `ANTHROPIC_API_KEY` | For Claude models |
45+| `OPENAI_API_KEY` | For OpenAI models |
46+| `GEMINI_API_KEY` | For Google Gemini models |
47+| `GROQ_API_KEY` | For Groq models |
48+| `AWS_ACCESS_KEY_ID` | For AWS Bedrock (Claude) |
49+| `AWS_SECRET_ACCESS_KEY` | For AWS Bedrock (Claude) |
50+| `AWS_REGION` | For AWS Bedrock (Claude) |
51+
52+### Configuration File Structure
53+
54+```json
55+{
56+ "data": {
57+ "directory": ".opencode"
58+ },
59+ "providers": {
60+ "openai": {
61+ "apiKey": "your-api-key",
62+ "disabled": false
63+ },
64+ "anthropic": {
65+ "apiKey": "your-api-key",
66+ "disabled": false
67+ }
68+ },
69+ "agents": {
70+ "coder": {
71+ "model": "claude-3.7-sonnet",
72+ "maxTokens": 5000
73+ },
74+ "task": {
75+ "model": "claude-3.7-sonnet",
76+ "maxTokens": 5000
77+ },
78+ "title": {
79+ "model": "claude-3.7-sonnet",
80+ "maxTokens": 80
81+ }
82+ },
83+ "mcpServers": {
84+ "example": {
85+ "type": "stdio",
86+ "command": "path/to/mcp-server",
87+ "env": [],
88+ "args": []
89+ }
90+ },
91+ "lsp": {
92+ "go": {
93+ "disabled": false,
94+ "command": "gopls"
95+ }
96+ },
97+ "debug": false,
98+ "debugLSP": false
99+}
100+```
32101
33−- `$HOME/.termai.json`
34−- `$XDG_CONFIG_HOME/termai/.termai.json`
35−- `./.termai.json` (local directory)
102+## Supported AI Models
36103
37−You can also use environment variables:
104+OpenCode supports a variety of AI models from different providers:
38105
39−- `ANTHROPIC_API_KEY`: For Claude models
40−- `OPENAI_API_KEY`: For OpenAI models
41−- `GEMINI_API_KEY`: For Google Gemini models
106+### OpenAI
42107
43−## Usage
108+- GPT-4.1 family (gpt-4.1, gpt-4.1-mini, gpt-4.1-nano)
109+- GPT-4.5 Preview
110+- GPT-4o family (gpt-4o, gpt-4o-mini)
111+- O1 family (o1, o1-pro, o1-mini)
112+- O3 family (o3, o3-mini)
113+- O4 Mini
44114
45−```bash
46−# Start TermAI
47−termai
115+### Anthropic
48116
49−# Start with debug logging
50−termai -d
51−```
117+- Claude 3.5 Sonnet
118+- Claude 3.5 Haiku
119+- Claude 3.7 Sonnet
120+- Claude 3 Haiku
121+- Claude 3 Opus
122+
123+### Google
124+
125+- Gemini 2.5
126+- Gemini 2.5 Flash
127+- Gemini 2.0 Flash
128+- Gemini 2.0 Flash Lite
52129
53−### Keyboard Shortcuts
130+### AWS Bedrock
54131
55−#### Global Shortcuts
56−- `?`: Toggle help panel
57−- `Ctrl+C` or `q`: Quit application
58−- `L`: View logs
59−- `Backspace`: Go back to previous page
60−- `Esc`: Close current view/dialog or return to normal mode
132+- Claude 3.7 Sonnet
61133
62−#### Session Management
63−- `N`: Create new session
64−- `Enter` or `Space`: Select session (in sessions list)
134+## Usage
135+
136+```bash
137+# Start OpenCode
138+opencode
139+
140+# Start with debug logging
141+opencode -d
65142
66−#### Editor Shortcuts (Vim-like)
67−- `i`: Enter insert mode
68−- `Esc`: Enter normal mode
69−- `v`: Enter visual mode
70−- `V`: Enter visual line mode
71−- `Enter`: Send message (in normal mode)
72−- `Ctrl+S`: Send message (in insert mode)
143+# Start with a specific working directory
144+opencode -c /path/to/project
145+```
73146
74−#### Navigation
75−- Arrow keys: Navigate through lists and content
76−- Page Up/Down: Scroll through content
147+## Command-line Flags
148+
149+| Flag | Short | Description |
150+| --------- | ----- | ----------------------------- |
151+| `--help` | `-h` | Display help information |
152+| `--debug` | `-d` | Enable debug mode |
153+| `--cwd` | `-c` | Set current working directory |
154+
155+## Keyboard Shortcuts
156+
157+### Global Shortcuts
158+
159+| Shortcut | Action |
160+| -------- | ------------------------------------------------------- |
161+| `Ctrl+C` | Quit application |
162+| `Ctrl+?` | Toggle help dialog |
163+| `?` | Toggle help dialog (when not in editing mode) |
164+| `Ctrl+L` | View logs |
165+| `Ctrl+A` | Switch session |
166+| `Ctrl+K` | Command dialog |
167+| `Esc` | Close current overlay/dialog or return to previous mode |
168+
169+### Chat Page Shortcuts
170+
171+| Shortcut | Action |
172+| -------- | --------------------------------------- |
173+| `Ctrl+N` | Create new session |
174+| `Ctrl+X` | Cancel current operation/generation |
175+| `i` | Focus editor (when not in writing mode) |
176+| `Esc` | Exit writing mode and focus messages |
177+
178+### Editor Shortcuts
179+
180+| Shortcut | Action |
181+| ------------------- | ----------------------------------------- |
182+| `Ctrl+S` | Send message (when editor is focused) |
183+| `Enter` or `Ctrl+S` | Send message (when editor is not focused) |
184+| `Ctrl+E` | Open external editor |
185+| `Esc` | Blur editor and focus messages |
186+
187+### Session Dialog Shortcuts
188+
189+| Shortcut | Action |
190+| ---------- | ---------------- |
191+| `↑` or `k` | Previous session |
192+| `↓` or `j` | Next session |
193+| `Enter` | Select session |
194+| `Esc` | Close dialog |
195+
196+### Permission Dialog Shortcuts
197+
198+| Shortcut | Action |
199+| ----------------------- | ---------------------------- |
200+| `←` or `left` | Switch options left |
201+| `→` or `right` or `tab` | Switch options right |
202+| `Enter` or `space` | Confirm selection |
203+| `a` | Allow permission |
204+| `A` | Allow permission for session |
205+| `d` | Deny permission |
206+
207+### Logs Page Shortcuts
208+
209+| Shortcut | Action |
210+| ------------------ | ------------------- |
211+| `Backspace` or `q` | Return to chat page |
212+
213+## AI Assistant Tools
214+
215+OpenCode's AI assistant has access to various tools to help with coding tasks:
216+
217+### File and Code Tools
218+
219+| Tool | Description | Parameters |
220+| ------------- | --------------------------- | ---------------------------------------------------------------------------------------- |
221+| `glob` | Find files by pattern | `pattern` (required), `path` (optional) |
222+| `grep` | Search file contents | `pattern` (required), `path` (optional), `include` (optional), `literal_text` (optional) |
223+| `ls` | List directory contents | `path` (optional), `ignore` (optional array of patterns) |
224+| `view` | View file contents | `file_path` (required), `offset` (optional), `limit` (optional) |
225+| `write` | Write to files | `file_path` (required), `content` (required) |
226+| `edit` | Edit files | Various parameters for file editing |
227+| `patch` | Apply patches to files | `file_path` (required), `diff` (required) |
228+| `diagnostics` | Get diagnostics information | `file_path` (optional) |
229+
230+### Other Tools
231+
232+| Tool | Description | Parameters |
233+| ------------- | -------------------------------------- | ----------------------------------------------------------------------------------------- |
234+| `bash` | Execute shell commands | `command` (required), `timeout` (optional) |
235+| `fetch` | Fetch data from URLs | `url` (required), `format` (required), `timeout` (optional) |
236+| `sourcegraph` | Search code across public repositories | `query` (required), `count` (optional), `context_window` (optional), `timeout` (optional) |
237+| `agent` | Run sub-tasks with the AI agent | `prompt` (required) |
77238
78239## Architecture
79240
80−TermAI is built with a modular architecture:
241+OpenCode is built with a modular architecture:
81242
82243- **cmd**: Command-line interface using Cobra
83244- **internal/app**: Core application services
@@ -88,37 +249,129 @@ TermAI is built with a modular architecture:
88249- **internal/logging**: Logging infrastructure
89250- **internal/message**: Message handling
90251- **internal/session**: Session management
252+- **internal/lsp**: Language Server Protocol integration
253+
254+## MCP (Model Context Protocol)
255+
256+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.
257+
258+### MCP Features
259+
260+- **External Tool Integration**: Connect to external tools and services via a standardized protocol
261+- **Tool Discovery**: Automatically discover available tools from MCP servers
262+- **Multiple Connection Types**:
263+ - **Stdio**: Communicate with tools via standard input/output
264+ - **SSE**: Communicate with tools via Server-Sent Events
265+- **Security**: Permission system for controlling access to MCP tools
266+
267+### Configuring MCP Servers
268+
269+MCP servers are defined in the configuration file under the `mcpServers` section:
270+
271+```json
272+{
273+ "mcpServers": {
274+ "example": {
275+ "type": "stdio",
276+ "command": "path/to/mcp-server",
277+ "env": [],
278+ "args": []
279+ },
280+ "web-example": {
281+ "type": "sse",
282+ "url": "https://example.com/mcp",
283+ "headers": {
284+ "Authorization": "Bearer token"
285+ }
286+ }
287+ }
288+}
289+```
290+
291+### MCP Tool Usage
292+
293+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.
294+
295+## LSP (Language Server Protocol)
296+
297+OpenCode integrates with Language Server Protocol to provide code intelligence features across multiple programming languages.
298+
299+### LSP Features
300+
301+- **Multi-language Support**: Connect to language servers for different programming languages
302+- **Diagnostics**: Receive error checking and linting information
303+- **File Watching**: Automatically notify language servers of file changes
304+
305+### Configuring LSP
306+
307+Language servers are configured in the configuration file under the `lsp` section:
308+
309+```json
310+{
311+ "lsp": {
312+ "go": {
313+ "disabled": false,
314+ "command": "gopls"
315+ },
316+ "typescript": {
317+ "disabled": false,
318+ "command": "typescript-language-server",
319+ "args": ["--stdio"]
320+ }
321+ }
322+}
323+```
324+
325+### LSP Integration with AI
326+
327+The AI assistant can access LSP features through the `diagnostics` tool, allowing it to:
328+
329+- Check for errors in your code
330+- Suggest fixes based on diagnostics
331+
332+While the LSP client implementation supports the full LSP protocol (including completions, hover, definition, etc.), currently only diagnostics are exposed to the AI assistant.
91333
92334## Development
93335
94336### Prerequisites
95337
96−- Go 1.23.5 or higher
338+- Go 1.24.0 or higher
97339
98340### Building from Source
99341
100342```bash
101343# Clone the repository
102−git clone https://github.com/kujtimiihoxha/termai.git
103−cd termai
344+git clone https://github.com/kujtimiihoxha/opencode.git
345+cd opencode
104346
105347# Build
106−go build -o termai
348+go build -o opencode
107349
108350# Run
109−./termai
351+./opencode
110352```
111353
112354## Acknowledgments
113355
114−TermAI builds upon the work of several open source projects and developers:
356+OpenCode gratefully acknowledges the contributions and support from these key individuals:
357+
358+- [@isaacphi](https://github.com/isaacphi) - For the [mcp-language-server](https://github.com/isaacphi/mcp-language-server) project which provided the foundation for our LSP client implementation
359+- [@adamdottv](https://github.com/adamdottv) - For the design direction and UI/UX architecture
115360
116−- [@isaacphi](https://github.com/isaacphi) - LSP client implementation
361+Special thanks to the broader open source community whose tools and libraries have made this project possible.
117362
118363## License
119364
120−[License information coming soon]
365+OpenCode is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
121366
122367## Contributing
123368
124−[Contribution guidelines coming soon]
369+Contributions are welcome! Here's how you can contribute:
370+
371+1. Fork the repository
372+2. Create a feature branch (`git checkout -b feature/amazing-feature`)
373+3. Commit your changes (`git commit -m 'Add some amazing feature'`)
374+4. Push to the branch (`git push origin feature/amazing-feature`)
375+5. Open a Pull Request
376+
377+Please make sure to update tests as appropriate and follow the existing code style.
cmd/root.go+194−79
@@ -2,126 +2,240 @@ package cmd
22
33import (
44 "context"
5− "log/slog"
5+ "fmt"
66 "os"
77 "sync"
8+ "time"
89
910 tea "github.com/charmbracelet/bubbletea"
10− "github.com/kujtimiihoxha/termai/internal/app"
11− "github.com/kujtimiihoxha/termai/internal/config"
12− "github.com/kujtimiihoxha/termai/internal/db"
13− "github.com/kujtimiihoxha/termai/internal/llm/agent"
14− "github.com/kujtimiihoxha/termai/internal/logging"
15− "github.com/kujtimiihoxha/termai/internal/tui"
11+ "github.com/kujtimiihoxha/opencode/internal/app"
12+ "github.com/kujtimiihoxha/opencode/internal/config"
13+ "github.com/kujtimiihoxha/opencode/internal/db"
14+ "github.com/kujtimiihoxha/opencode/internal/llm/agent"
15+ "github.com/kujtimiihoxha/opencode/internal/logging"
16+ "github.com/kujtimiihoxha/opencode/internal/pubsub"
17+ "github.com/kujtimiihoxha/opencode/internal/tui"
1618 zone "github.com/lrstanley/bubblezone"
1719 "github.com/spf13/cobra"
1820)
1921
2022var rootCmd = &cobra.Command{
21− Use: "termai",
22− Short: "A terminal ai assistant",
23− Long: `A terminal ai assistant`,
23+ Use: "OpenCode",
24+ Short: "A terminal AI assistant for software development",
25+ Long: `OpenCode is a powerful terminal-based AI assistant that helps with software development tasks.
26+It provides an interactive chat interface with AI capabilities, code analysis, and LSP integration
27+to assist developers in writing, debugging, and understanding code directly from the terminal.`,
2428 RunE: func(cmd *cobra.Command, args []string) error {
29+ // If the help flag is set, show the help message
2530 if cmd.Flag("help").Changed {
2631 cmd.Help()
2732 return nil
2833 }
34+
35+ // Load the config
2936 debug, _ := cmd.Flags().GetBool("debug")
30− err := config.Load(debug)
31− cfg := config.Get()
32− defaultLevel := slog.LevelInfo
33− if cfg.Debug {
34− defaultLevel = slog.LevelDebug
37+ cwd, _ := cmd.Flags().GetString("cwd")
38+ if cwd != "" {
39+ err := os.Chdir(cwd)
40+ if err != nil {
41+ return fmt.Errorf("failed to change directory: %v", err)
42+ }
3543 }
36− logger := slog.New(slog.NewTextHandler(logging.NewWriter(), &slog.HandlerOptions{
37− Level: defaultLevel,
38− }))
39− slog.SetDefault(logger)
40−
44+ if cwd == "" {
45+ c, err := os.Getwd()
46+ if err != nil {
47+ return fmt.Errorf("failed to get current working directory: %v", err)
48+ }
49+ cwd = c
50+ }
51+ _, err := config.Load(cwd, debug)
4152 if err != nil {
4253 return err
4354 }
55+
56+ // Connect DB, this will also run migrations
4457 conn, err := db.Connect()
4558 if err != nil {
4659 return err
4760 }
48− ctx := context.Background()
4961
50− app := app.New(ctx, conn)
51− logging.Info("Starting termai...")
62+ // Create main context for the application
63+ ctx, cancel := context.WithCancel(context.Background())
64+ defer cancel()
65+
66+ app, err := app.New(ctx, conn)
67+ if err != nil {
68+ logging.Error("Failed to create app: %v", err)
69+ return err
70+ }
71+
72+ // Set up the TUI
5273 zone.NewGlobal()
53− tui := tea.NewProgram(
74+ program := tea.NewProgram(
5475 tui.New(app),
5576 tea.WithAltScreen(),
5677 tea.WithMouseCellMotion(),
5778 )
58− logging.Info("Setting up subscriptions...")
59− ch, unsub := setupSubscriptions(app)
60− defer unsub()
6179
80+ // Initialize MCP tools in the background
81+ initMCPTools(ctx, app)
82+
83+ // Setup the subscriptions, this will send services events to the TUI
84+ ch, cancelSubs := setupSubscriptions(app, ctx)
85+
86+ // Create a context for the TUI message handler
87+ tuiCtx, tuiCancel := context.WithCancel(ctx)
88+ var tuiWg sync.WaitGroup
89+ tuiWg.Add(1)
90+
91+ // Set up message handling for the TUI
6292 go func() {
63− // Set this up once
64− agent.GetMcpTools(ctx, app.Permissions)
65− for msg := range ch {
66− tui.Send(msg)
93+ defer tuiWg.Done()
94+ defer logging.RecoverPanic("TUI-message-handler", func() {
95+ attemptTUIRecovery(program)
96+ })
97+
98+ for {
99+ select {
100+ case <-tuiCtx.Done():
101+ logging.Info("TUI message handler shutting down")
102+ return
103+ case msg, ok := <-ch:
104+ if !ok {
105+ logging.Info("TUI message channel closed")
106+ return
107+ }
108+ program.Send(msg)
109+ }
67110 }
68111 }()
69− if _, err := tui.Run(); err != nil {
70− return err
112+
113+ // Cleanup function for when the program exits
114+ cleanup := func() {
115+ // Shutdown the app
116+ app.Shutdown()
117+
118+ // Cancel subscriptions first
119+ cancelSubs()
120+
121+ // Then cancel TUI message handler
122+ tuiCancel()
123+
124+ // Wait for TUI message handler to finish
125+ tuiWg.Wait()
126+
127+ logging.Info("All goroutines cleaned up")
71128 }
129+
130+ // Run the TUI
131+ result, err := program.Run()
132+ cleanup()
133+
134+ if err != nil {
135+ logging.Error("TUI error: %v", err)
136+ return fmt.Errorf("TUI error: %v", err)
137+ }
138+
139+ logging.Info("TUI exited with result: %v", result)
72140 return nil
73141 },
74142}
75143
76−func setupSubscriptions(app *app.App) (chan tea.Msg, func()) {
77− ch := make(chan tea.Msg)
78− wg := sync.WaitGroup{}
79− ctx, cancel := context.WithCancel(app.Context)
80− {
81− sub := logging.Subscribe(ctx)
82− wg.Add(1)
83− go func() {
84− for ev := range sub {
85− ch <- ev
86− }
87− wg.Done()
88− }()
89− }
90− {
91− sub := app.Sessions.Subscribe(ctx)
92− wg.Add(1)
93− go func() {
94− for ev := range sub {
95− ch <- ev
96− }
97− wg.Done()
98− }()
99− }
100− {
101− sub := app.Messages.Subscribe(ctx)
102− wg.Add(1)
103− go func() {
104− for ev := range sub {
105− ch <- ev
144+// attemptTUIRecovery tries to recover the TUI after a panic
145+func attemptTUIRecovery(program *tea.Program) {
146+ logging.Info("Attempting to recover TUI after panic")
147+
148+ // We could try to restart the TUI or gracefully exit
149+ // For now, we'll just quit the program to avoid further issues
150+ program.Quit()
151+}
152+
153+func initMCPTools(ctx context.Context, app *app.App) {
154+ go func() {
155+ defer logging.RecoverPanic("MCP-goroutine", nil)
156+
157+ // Create a context with timeout for the initial MCP tools fetch
158+ ctxWithTimeout, cancel := context.WithTimeout(ctx, 30*time.Second)
159+ defer cancel()
160+
161+ // Set this up once with proper error handling
162+ agent.GetMcpTools(ctxWithTimeout, app.Permissions)
163+ logging.Info("MCP message handling goroutine exiting")
164+ }()
165+}
166+
167+func setupSubscriber[T any](
168+ ctx context.Context,
169+ wg *sync.WaitGroup,
170+ name string,
171+ subscriber func(context.Context) <-chan pubsub.Event[T],
172+ outputCh chan<- tea.Msg,
173+) {
174+ wg.Add(1)
175+ go func() {
176+ defer wg.Done()
177+ defer logging.RecoverPanic(fmt.Sprintf("subscription-%s", name), nil)
178+
179+ subCh := subscriber(ctx)
180+
181+ for {
182+ select {
183+ case event, ok := <-subCh:
184+ if !ok {
185+ logging.Info("subscription channel closed", "name", name)
186+ return
187+ }
188+
189+ var msg tea.Msg = event
190+
191+ select {
192+ case outputCh <- msg:
193+ case <-time.After(2 * time.Second):
194+ logging.Warn("message dropped due to slow consumer", "name", name)
195+ case <-ctx.Done():
196+ logging.Info("subscription cancelled", "name", name)
197+ return
198+ }
199+ case <-ctx.Done():
200+ logging.Info("subscription cancelled", "name", name)
201+ return
106202 }
107− wg.Done()
108− }()
109− }
110− {
111− sub := app.Permissions.Subscribe(ctx)
112− wg.Add(1)
203+ }
204+ }()
205+}
206+
207+func setupSubscriptions(app *app.App, parentCtx context.Context) (chan tea.Msg, func()) {
208+ ch := make(chan tea.Msg, 100)
209+
210+ wg := sync.WaitGroup{}
211+ ctx, cancel := context.WithCancel(parentCtx) // Inherit from parent context
212+
213+ setupSubscriber(ctx, &wg, "logging", logging.Subscribe, ch)
214+ setupSubscriber(ctx, &wg, "sessions", app.Sessions.Subscribe, ch)
215+ setupSubscriber(ctx, &wg, "messages", app.Messages.Subscribe, ch)
216+ setupSubscriber(ctx, &wg, "permissions", app.Permissions.Subscribe, ch)
217+
218+ cleanupFunc := func() {
219+ logging.Info("Cancelling all subscriptions")
220+ cancel() // Signal all goroutines to stop
221+
222+ waitCh := make(chan struct{})
113223 go func() {
114− for ev := range sub {
115− ch <- ev
116− }
117− wg.Done()
224+ defer logging.RecoverPanic("subscription-cleanup", nil)
225+ wg.Wait()
226+ close(waitCh)
118227 }()
228+
229+ select {
230+ case <-waitCh:
231+ logging.Info("All subscription goroutines completed successfully")
232+ close(ch) // Only close after all writers are confirmed done
233+ case <-time.After(5 * time.Second):
234+ logging.Warn("Timed out waiting for some subscription goroutines to complete")
235+ close(ch)
236+ }
119237 }
120− return ch, func() {
121− cancel()
122− wg.Wait()
123− close(ch)
124− }
238+ return ch, cleanupFunc
125239}
126240
127241func Execute() {
@@ -133,5 +247,6 @@ func Execute() {
133247
134248func init() {
135249 rootCmd.Flags().BoolP("help", "h", false, "Help")
136− rootCmd.Flags().BoolP("debug", "d", false, "Help")
250+ rootCmd.Flags().BoolP("debug", "d", false, "Debug")
251+ rootCmd.Flags().StringP("cwd", "c", "", "Current working directory")
137252}
cmd/schema/README.mdnew file+64
@@ -0,0 +1,64 @@
1+# OpenCode Configuration Schema Generator
2+
3+This tool generates a JSON Schema for the OpenCode configuration file. The schema can be used to validate configuration files and provide autocompletion in editors that support JSON Schema.
4+
5+## Usage
6+
7+```bash
8+go run cmd/schema/main.go > opencode-schema.json
9+```
10+
11+This will generate a JSON Schema file that can be used to validate configuration files.
12+
13+## Schema Features
14+
15+The generated schema includes:
16+
17+- All configuration options with descriptions
18+- Default values where applicable
19+- Validation for enum values (e.g., model IDs, provider types)
20+- Required fields
21+- Type checking
22+
23+## Using the Schema
24+
25+You can use the generated schema in several ways:
26+
27+1. **Editor Integration**: Many editors (VS Code, JetBrains IDEs, etc.) support JSON Schema for validation and autocompletion. You can configure your editor to use the generated schema for `.opencode.json` files.
28+
29+2. **Validation Tools**: You can use tools like [jsonschema](https://github.com/Julian/jsonschema) to validate your configuration files against the schema.
30+
31+3. **Documentation**: The schema serves as documentation for the configuration options.
32+
33+## Example Configuration
34+
35+Here's an example configuration that conforms to the schema:
36+
37+```json
38+{
39+ "data": {
40+ "directory": ".opencode"
41+ },
42+ "debug": false,
43+ "providers": {
44+ "anthropic": {
45+ "apiKey": "your-api-key"
46+ }
47+ },
48+ "agents": {
49+ "coder": {
50+ "model": "claude-3.7-sonnet",
51+ "maxTokens": 5000,
52+ "reasoningEffort": "medium"
53+ },
54+ "task": {
55+ "model": "claude-3.7-sonnet",
56+ "maxTokens": 5000
57+ },
58+ "title": {
59+ "model": "claude-3.7-sonnet",
60+ "maxTokens": 80
61+ }
62+ }
63+}
64+```
cmd/schema/main.gonew file+262
@@ -0,0 +1,262 @@
1+package main
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "os"
7+
8+ "github.com/kujtimiihoxha/opencode/internal/config"
9+ "github.com/kujtimiihoxha/opencode/internal/llm/models"
10+)
11+
12+// JSONSchemaType represents a JSON Schema type
13+type JSONSchemaType struct {
14+ Type string `json:"type,omitempty"`
15+ Description string `json:"description,omitempty"`
16+ Properties map[string]any `json:"properties,omitempty"`
17+ Required []string `json:"required,omitempty"`
18+ AdditionalProperties any `json:"additionalProperties,omitempty"`
19+ Enum []any `json:"enum,omitempty"`
20+ Items map[string]any `json:"items,omitempty"`
21+ OneOf []map[string]any `json:"oneOf,omitempty"`
22+ AnyOf []map[string]any `json:"anyOf,omitempty"`
23+ Default any `json:"default,omitempty"`
24+}
25+
26+func main() {
27+ schema := generateSchema()
28+
29+ // Pretty print the schema
30+ encoder := json.NewEncoder(os.Stdout)
31+ encoder.SetIndent("", " ")
32+ if err := encoder.Encode(schema); err != nil {
33+ fmt.Fprintf(os.Stderr, "Error encoding schema: %v\n", err)
34+ os.Exit(1)
35+ }
36+}
37+
38+func generateSchema() map[string]any {
39+ schema := map[string]any{
40+ "$schema": "http://json-schema.org/draft-07/schema#",
41+ "title": "OpenCode Configuration",
42+ "description": "Configuration schema for the OpenCode application",
43+ "type": "object",
44+ "properties": map[string]any{},
45+ }
46+
47+ // Add Data configuration
48+ schema["properties"].(map[string]any)["data"] = map[string]any{
49+ "type": "object",
50+ "description": "Storage configuration",
51+ "properties": map[string]any{
52+ "directory": map[string]any{
53+ "type": "string",
54+ "description": "Directory where application data is stored",
55+ "default": ".opencode",
56+ },
57+ },
58+ "required": []string{"directory"},
59+ }
60+
61+ // Add working directory
62+ schema["properties"].(map[string]any)["wd"] = map[string]any{
63+ "type": "string",
64+ "description": "Working directory for the application",
65+ }
66+
67+ // Add debug flags
68+ schema["properties"].(map[string]any)["debug"] = map[string]any{
69+ "type": "boolean",
70+ "description": "Enable debug mode",
71+ "default": false,
72+ }
73+
74+ schema["properties"].(map[string]any)["debugLSP"] = map[string]any{
75+ "type": "boolean",
76+ "description": "Enable LSP debug mode",
77+ "default": false,
78+ }
79+
80+ // Add MCP servers
81+ schema["properties"].(map[string]any)["mcpServers"] = map[string]any{
82+ "type": "object",
83+ "description": "Model Control Protocol server configurations",
84+ "additionalProperties": map[string]any{
85+ "type": "object",
86+ "description": "MCP server configuration",
87+ "properties": map[string]any{
88+ "command": map[string]any{
89+ "type": "string",
90+ "description": "Command to execute for the MCP server",
91+ },
92+ "env": map[string]any{
93+ "type": "array",
94+ "description": "Environment variables for the MCP server",
95+ "items": map[string]any{
96+ "type": "string",
97+ },
98+ },
99+ "args": map[string]any{
100+ "type": "array",
101+ "description": "Command arguments for the MCP server",
102+ "items": map[string]any{
103+ "type": "string",
104+ },
105+ },
106+ "type": map[string]any{
107+ "type": "string",
108+ "description": "Type of MCP server",
109+ "enum": []string{"stdio", "sse"},
110+ "default": "stdio",
111+ },
112+ "url": map[string]any{
113+ "type": "string",
114+ "description": "URL for SSE type MCP servers",
115+ },
116+ "headers": map[string]any{
117+ "type": "object",
118+ "description": "HTTP headers for SSE type MCP servers",
119+ "additionalProperties": map[string]any{
120+ "type": "string",
121+ },
122+ },
123+ },
124+ "required": []string{"command"},
125+ },
126+ }
127+
128+ // Add providers
129+ providerSchema := map[string]any{
130+ "type": "object",
131+ "description": "LLM provider configurations",
132+ "additionalProperties": map[string]any{
133+ "type": "object",
134+ "description": "Provider configuration",
135+ "properties": map[string]any{
136+ "apiKey": map[string]any{
137+ "type": "string",
138+ "description": "API key for the provider",
139+ },
140+ "disabled": map[string]any{
141+ "type": "boolean",
142+ "description": "Whether the provider is disabled",
143+ "default": false,
144+ },
145+ },
146+ },
147+ }
148+
149+ // Add known providers
150+ knownProviders := []string{
151+ string(models.ProviderAnthropic),
152+ string(models.ProviderOpenAI),
153+ string(models.ProviderGemini),
154+ string(models.ProviderGROQ),
155+ string(models.ProviderBedrock),
156+ }
157+
158+ providerSchema["additionalProperties"].(map[string]any)["properties"].(map[string]any)["provider"] = map[string]any{
159+ "type": "string",
160+ "description": "Provider type",
161+ "enum": knownProviders,
162+ }
163+
164+ schema["properties"].(map[string]any)["providers"] = providerSchema
165+
166+ // Add agents
167+ agentSchema := map[string]any{
168+ "type": "object",
169+ "description": "Agent configurations",
170+ "additionalProperties": map[string]any{
171+ "type": "object",
172+ "description": "Agent configuration",
173+ "properties": map[string]any{
174+ "model": map[string]any{
175+ "type": "string",
176+ "description": "Model ID for the agent",
177+ },
178+ "maxTokens": map[string]any{
179+ "type": "integer",
180+ "description": "Maximum tokens for the agent",
181+ "minimum": 1,
182+ },
183+ "reasoningEffort": map[string]any{
184+ "type": "string",
185+ "description": "Reasoning effort for models that support it (OpenAI, Anthropic)",
186+ "enum": []string{"low", "medium", "high"},
187+ },
188+ },
189+ "required": []string{"model"},
190+ },
191+ }
192+
193+ // Add model enum
194+ modelEnum := []string{}
195+ for modelID := range models.SupportedModels {
196+ modelEnum = append(modelEnum, string(modelID))
197+ }
198+ agentSchema["additionalProperties"].(map[string]any)["properties"].(map[string]any)["model"].(map[string]any)["enum"] = modelEnum
199+
200+ // Add specific agent properties
201+ agentProperties := map[string]any{}
202+ knownAgents := []string{
203+ string(config.AgentCoder),
204+ string(config.AgentTask),
205+ string(config.AgentTitle),
206+ }
207+
208+ for _, agentName := range knownAgents {
209+ agentProperties[agentName] = map[string]any{
210+ "$ref": "#/definitions/agent",
211+ }
212+ }
213+
214+ // Create a combined schema that allows both specific agents and additional ones
215+ combinedAgentSchema := map[string]any{
216+ "type": "object",
217+ "description": "Agent configurations",
218+ "properties": agentProperties,
219+ "additionalProperties": agentSchema["additionalProperties"],
220+ }
221+
222+ schema["properties"].(map[string]any)["agents"] = combinedAgentSchema
223+ schema["definitions"] = map[string]any{
224+ "agent": agentSchema["additionalProperties"],
225+ }
226+
227+ // Add LSP configuration
228+ schema["properties"].(map[string]any)["lsp"] = map[string]any{
229+ "type": "object",
230+ "description": "Language Server Protocol configurations",
231+ "additionalProperties": map[string]any{
232+ "type": "object",
233+ "description": "LSP configuration for a language",
234+ "properties": map[string]any{
235+ "disabled": map[string]any{
236+ "type": "boolean",
237+ "description": "Whether the LSP is disabled",
238+ "default": false,
239+ },
240+ "command": map[string]any{
241+ "type": "string",
242+ "description": "Command to execute for the LSP server",
243+ },
244+ "args": map[string]any{
245+ "type": "array",
246+ "description": "Command arguments for the LSP server",
247+ "items": map[string]any{
248+ "type": "string",
249+ },
250+ },
251+ "options": map[string]any{
252+ "type": "object",
253+ "description": "Additional options for the LSP server",
254+ },
255+ },
256+ "required": []string{"command"},
257+ },
258+ }
259+
260+ return schema
261+}
262+
go.mod+26−17
@@ -1,4 +1,4 @@
1−module github.com/kujtimiihoxha/termai
1+module github.com/kujtimiihoxha/opencode
22
33go 1.24.0
44
@@ -7,6 +7,7 @@ toolchain go1.24.2
77require (
88 github.com/JohannesKaufmann/html-to-markdown v1.6.0
99 github.com/PuerkitoBio/goquery v1.9.2
10+ github.com/alecthomas/chroma/v2 v2.15.0
1011 github.com/anthropics/anthropic-sdk-go v0.2.0-beta.2
1112 github.com/bmatcuk/doublestar/v4 v4.8.1
1213 github.com/catppuccin/go v0.3.0
@@ -15,12 +16,13 @@ require (
1516 github.com/charmbracelet/glamour v0.9.1
1617 github.com/charmbracelet/huh v0.6.0
1718 github.com/charmbracelet/lipgloss v1.1.0
19+ github.com/charmbracelet/x/ansi v0.8.0
1820 github.com/fsnotify/fsnotify v1.8.0
21+ github.com/go-git/go-git/v5 v5.15.0
1922 github.com/go-logfmt/logfmt v0.6.0
2023 github.com/golang-migrate/migrate/v4 v4.18.2
2124 github.com/google/generative-ai-go v0.19.0
2225 github.com/google/uuid v1.6.0
23− github.com/kujtimiihoxha/vimtea v0.0.3-0.20250329221256-a250e98498f9
2426 github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231
2527 github.com/mark3labs/mcp-go v0.17.0
2628 github.com/mattn/go-runewidth v0.0.16
@@ -29,11 +31,10 @@ require (
2931 github.com/muesli/reflow v0.3.0
3032 github.com/muesli/termenv v0.16.0
3133 github.com/openai/openai-go v0.1.0-beta.2
32− github.com/sergi/go-diff v1.3.1
34+ github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
3335 github.com/spf13/cobra v1.9.1
3436 github.com/spf13/viper v1.20.0
3537 github.com/stretchr/testify v1.10.0
36− golang.org/x/net v0.34.0
3738 google.golang.org/api v0.215.0
3839)
3940
@@ -44,7 +45,9 @@ require (
4445 cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
4546 cloud.google.com/go/compute/metadata v0.6.0 // indirect
4647 cloud.google.com/go/longrunning v0.5.7 // indirect
47− github.com/alecthomas/chroma/v2 v2.15.0 // indirect
48+ dario.cat/mergo v1.0.0 // indirect
49+ github.com/Microsoft/go-winio v0.6.2 // indirect
50+ github.com/ProtonMail/go-crypto v1.1.6 // indirect
4851 github.com/andybalholm/cascadia v1.3.2 // indirect
4952 github.com/atotto/clipboard v0.1.4 // indirect
5053 github.com/aws/aws-sdk-go-v2 v1.30.3 // indirect
@@ -64,18 +67,23 @@ require (
6467 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
6568 github.com/aymerick/douceur v0.2.0 // indirect
6669 github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
67− github.com/charmbracelet/x/ansi v0.8.0 // indirect
6870 github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
6971 github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
7072 github.com/charmbracelet/x/term v0.2.1 // indirect
73+ github.com/cloudflare/circl v1.6.1 // indirect
74+ github.com/cyphar/filepath-securejoin v0.4.1 // indirect
7175 github.com/davecgh/go-spew v1.1.1 // indirect
7276 github.com/dlclark/regexp2 v1.11.4 // indirect
7377 github.com/dustin/go-humanize v1.0.1 // indirect
78+ github.com/emirpasic/gods v1.18.1 // indirect
7479 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
7580 github.com/felixge/httpsnoop v1.0.4 // indirect
81+ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
82+ github.com/go-git/go-billy/v5 v5.6.2 // indirect
7683 github.com/go-logr/logr v1.4.2 // indirect
7784 github.com/go-logr/stdr v1.2.2 // indirect
7885 github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
86+ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
7987 github.com/google/s2a-go v0.1.8 // indirect
8088 github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
8189 github.com/googleapis/gax-go/v2 v2.14.1 // indirect
@@ -83,6 +91,8 @@ require (
8391 github.com/hashicorp/errwrap v1.1.0 // indirect
8492 github.com/hashicorp/go-multierror v1.1.1 // indirect
8593 github.com/inconshreveable/mousetrap v1.1.0 // indirect
94+ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
95+ github.com/kevinburke/ssh_config v1.2.0 // indirect
8696 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
8797 github.com/mattn/go-isatty v0.0.20 // indirect
8898 github.com/mattn/go-localereader v0.0.1 // indirect
@@ -90,10 +100,11 @@ require (
90100 github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
91101 github.com/muesli/cancelreader v0.2.2 // indirect
92102 github.com/pelletier/go-toml/v2 v2.2.3 // indirect
103+ github.com/pjbgf/sha1cd v0.3.2 // indirect
93104 github.com/pmezard/go-difflib v1.0.0 // indirect
94105 github.com/rivo/uniseg v0.4.7 // indirect
95106 github.com/sagikazarmark/locafero v0.7.0 // indirect
96− github.com/sahilm/fuzzy v0.1.1 // indirect
107+ github.com/skeema/knownhosts v1.3.1 // indirect
97108 github.com/sourcegraph/conc v0.3.0 // indirect
98109 github.com/spf13/afero v1.12.0 // indirect
99110 github.com/spf13/cast v1.7.1 // indirect
@@ -103,6 +114,7 @@ require (
103114 github.com/tidwall/match v1.1.1 // indirect
104115 github.com/tidwall/pretty v1.2.1 // indirect
105116 github.com/tidwall/sjson v1.2.5 // indirect
117+ github.com/xanzy/ssh-agent v0.3.3 // indirect
106118 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
107119 github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
108120 github.com/yuin/goldmark v1.7.8 // indirect
@@ -114,21 +126,18 @@ require (
114126 go.opentelemetry.io/otel/trace v1.29.0 // indirect
115127 go.uber.org/atomic v1.9.0 // indirect
116128 go.uber.org/multierr v1.9.0 // indirect
117− golang.design/x/clipboard v0.7.0 // indirect
118− golang.org/x/crypto v0.33.0 // indirect
119− golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect
120− golang.org/x/exp/shiny v0.0.0-20250305212735-054e65f0b394 // indirect
121− golang.org/x/image v0.14.0 // indirect
122− golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a // indirect
129+ golang.org/x/crypto v0.37.0 // indirect
130+ golang.org/x/net v0.39.0 // indirect
123131 golang.org/x/oauth2 v0.25.0 // indirect
124− golang.org/x/sync v0.12.0 // indirect
125− golang.org/x/sys v0.31.0 // indirect
126− golang.org/x/term v0.30.0 // indirect
127− golang.org/x/text v0.23.0 // indirect
132+ golang.org/x/sync v0.13.0 // indirect
133+ golang.org/x/sys v0.32.0 // indirect
134+ golang.org/x/term v0.31.0 // indirect
135+ golang.org/x/text v0.24.0 // indirect
128136 golang.org/x/time v0.8.0 // indirect
129137 google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect
130138 google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 // indirect
131139 google.golang.org/grpc v1.67.3 // indirect
132140 google.golang.org/protobuf v1.36.1 // indirect
141+ gopkg.in/warnings.v0 v0.1.2 // indirect
133142 gopkg.in/yaml.v3 v3.0.1 // indirect
134143)
go.sum+78−34
@@ -10,10 +10,17 @@ cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4
1010cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
1111cloud.google.com/go/longrunning v0.5.7 h1:WLbHekDbjK1fVFD3ibpFFVoyizlLRl73I7YKuAKilhU=
1212cloud.google.com/go/longrunning v0.5.7/go.mod h1:8GClkudohy1Fxm3owmBGid8W0pSgodEMwEAztp38Xng=
13+dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
14+dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
1315github.com/JohannesKaufmann/html-to-markdown v1.6.0 h1:04VXMiE50YYfCfLboJCLcgqF5x+rHJnb1ssNmqpLH/k=
1416github.com/JohannesKaufmann/html-to-markdown v1.6.0/go.mod h1:NUI78lGg/a7vpEJTz/0uOcYMaibytE4BUOQS8k78yPQ=
1517github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
1618github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
19+github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
20+github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
21+github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
22+github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
23+github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
1724github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
1825github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
1926github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
@@ -24,8 +31,12 @@ github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc
2431github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
2532github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
2633github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
34+github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
35+github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
2736github.com/anthropics/anthropic-sdk-go v0.2.0-beta.2 h1:h7qxtumNjKPWFv1QM/HJy60MteeW23iKeEtBoY7bYZk=
2837github.com/anthropics/anthropic-sdk-go v0.2.0-beta.2/go.mod h1:AapDW22irxK2PSumZiQXYUFvsdQgkwIWlpESweWZI/c=
38+github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
39+github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
2940github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
3041github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
3142github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY=
@@ -88,7 +99,11 @@ github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko
8899github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
89100github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
90101github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
102+github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
103+github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
91104github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
105+github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
106+github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
92107github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
93108github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
94109github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -96,6 +111,10 @@ github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yA
96111github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
97112github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
98113github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
114+github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
115+github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
116+github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
117+github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
99118github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
100119github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
101120github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@@ -104,6 +123,16 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
104123github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
105124github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
106125github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
126+github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
127+github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
128+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
129+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
130+github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
131+github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
132+github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
133+github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
134+github.com/go-git/go-git/v5 v5.15.0 h1:f5Qn0W0F7ry1iN0ZwIU5m/n7/BKB4hiZfc+zlZx7ly0=
135+github.com/go-git/go-git/v5 v5.15.0/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
107136github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
108137github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
109138github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -115,10 +144,12 @@ github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIx
115144github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
116145github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
117146github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
147+github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
148+github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
118149github.com/google/generative-ai-go v0.19.0 h1:R71szggh8wHMCUlEMsW2A/3T+5LdEIkiaHSYgSpUgdg=
119150github.com/google/generative-ai-go v0.19.0/go.mod h1:JYolL13VG7j79kM5BtHz4qwONHkeJQzOCkKXnpqtS/E=
120−github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
121−github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
151+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
152+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
122153github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
123154github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
124155github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -138,6 +169,10 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq
138169github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
139170github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
140171github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
172+github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
173+github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
174+github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
175+github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
141176github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
142177github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
143178github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -145,10 +180,6 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
145180github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
146181github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
147182github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
148−github.com/kujtimiihoxha/vimtea v0.0.3-0.20250329221256-a250e98498f9 h1:xYfCLI8KUwmXDFp1pOpNX+XsQczQw9VbEuju1pQF5/A=
149−github.com/kujtimiihoxha/vimtea v0.0.3-0.20250329221256-a250e98498f9/go.mod h1:Ye+kIkTmPO5xuqCQ+PPHDTGIViRRoSpSIlcYgma8YlA=
150−github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
151−github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
152183github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
153184github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
154185github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231 h1:9rjt7AfnrXKNSZhp36A3/4QAZAwGGCGD/p8Bse26zms=
@@ -178,29 +209,37 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
178209github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
179210github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
180211github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
212+github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
213+github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
181214github.com/openai/openai-go v0.1.0-beta.2 h1:Ra5nCFkbEl9w+UJwAciC4kqnIBUCcJazhmMA0/YN894=
182215github.com/openai/openai-go v0.1.0-beta.2/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
183216github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
184217github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
218+github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
219+github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
185220github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
221+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
222+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
186223github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
187224github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
188225github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
189226github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
190227github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
191228github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
192−github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
193−github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
229+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
230+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
194231github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
195232github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
196233github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
197−github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
198−github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
199234github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y=
200235github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
201236github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
202−github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
203237github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
238+github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
239+github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
240+github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
241+github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
242+github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
204243github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
205244github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
206245github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
@@ -214,6 +253,7 @@ github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
214253github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY=
215254github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
216255github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
256+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
217257github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
218258github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
219259github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
@@ -230,6 +270,8 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
230270github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
231271github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
232272github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
273+github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
274+github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
233275github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
234276github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
235277github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
@@ -254,27 +296,21 @@ go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
254296go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
255297go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
256298go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
257−golang.design/x/clipboard v0.7.0 h1:4Je8M/ys9AJumVnl8m+rZnIvstSnYj1fvzqYrU3TXvo=
258−golang.design/x/clipboard v0.7.0/go.mod h1:PQIvqYO9GP29yINEfsEn5zSQKAz3UgXmZKzDA6dnq2E=
259299golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
260300golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
301+golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
261302golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
262303golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
263304golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
264−golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
265−golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
266−golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw=
267−golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
268−golang.org/x/exp/shiny v0.0.0-20250305212735-054e65f0b394 h1:bFYqOIMdeiCEdzPJkLiOoMDzW/v3tjW4AA/RmUZYsL8=
269−golang.org/x/exp/shiny v0.0.0-20250305212735-054e65f0b394/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8=
270−golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
271−golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
272−golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a h1:sYbmY3FwUWCBTodZL1S3JUuOvaW6kM2o+clDzzDNBWg=
273−golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a/go.mod h1:Ede7gF0KGoHlj822RtphAHK1jLdrcuRBZg0sF1Q+SPc=
305+golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
306+golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
307+golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
308+golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
274309golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
275310golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
276311golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
277312golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
313+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
278314golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
279315golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
280316golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
@@ -282,20 +318,24 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
282318golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
283319golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
284320golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
285−golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
286−golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
321+golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
322+golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
287323golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
288324golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
289325golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
290326golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
291327golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
292−golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
293−golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
328+golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
329+golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
294330golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
331+golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
295332golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
333+golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
334+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
296335golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
297336golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
298337golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
338+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
299339golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
300340golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
301341golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -304,8 +344,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
304344golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
305345golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
306346golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
307−golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
308−golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
347+golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
348+golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
309349golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
310350golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
311351golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -314,17 +354,18 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
314354golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
315355golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
316356golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
317−golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
318−golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
357+golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
358+golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
319359golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
320360golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
361+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
321362golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
322363golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
323364golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
324365golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
325366golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
326−golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
327−golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
367+golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
368+golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
328369golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
329370golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
330371golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -343,8 +384,11 @@ google.golang.org/grpc v1.67.3/go.mod h1:YGaHCc6Oap+FzBJTZLBzkGSYt/cvGPFTPxkn7Qf
343384google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
344385google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
345386gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
346−gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
347387gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
388+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
389+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
390+gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
391+gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
348392gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
349393gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
350394gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
internal/app/app.gonew file+99
@@ -0,0 +1,99 @@
1+package app
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "maps"
7+ "sync"
8+ "time"
9+
10+ "github.com/kujtimiihoxha/opencode/internal/config"
11+ "github.com/kujtimiihoxha/opencode/internal/db"
12+ "github.com/kujtimiihoxha/opencode/internal/history"
13+ "github.com/kujtimiihoxha/opencode/internal/llm/agent"
14+ "github.com/kujtimiihoxha/opencode/internal/logging"
15+ "github.com/kujtimiihoxha/opencode/internal/lsp"
16+ "github.com/kujtimiihoxha/opencode/internal/message"
17+ "github.com/kujtimiihoxha/opencode/internal/permission"
18+ "github.com/kujtimiihoxha/opencode/internal/session"
19+)
20+
21+type App struct {
22+ Sessions session.Service
23+ Messages message.Service
24+ History history.Service
25+ Permissions permission.Service
26+
27+ CoderAgent agent.Service
28+
29+ LSPClients map[string]*lsp.Client
30+
31+ clientsMutex sync.RWMutex
32+
33+ watcherCancelFuncs []context.CancelFunc
34+ cancelFuncsMutex sync.Mutex
35+ watcherWG sync.WaitGroup
36+}
37+
38+func New(ctx context.Context, conn *sql.DB) (*App, error) {
39+ q := db.New(conn)
40+ sessions := session.NewService(q)
41+ messages := message.NewService(q)
42+ files := history.NewService(q, conn)
43+
44+ app := &App{
45+ Sessions: sessions,
46+ Messages: messages,
47+ History: files,
48+ Permissions: permission.NewPermissionService(),
49+ LSPClients: make(map[string]*lsp.Client),
50+ }
51+
52+ // Initialize LSP clients in the background
53+ go app.initLSPClients(ctx)
54+
55+ var err error
56+ app.CoderAgent, err = agent.NewAgent(
57+ config.AgentCoder,
58+ app.Sessions,
59+ app.Messages,
60+ agent.CoderAgentTools(
61+ app.Permissions,
62+ app.Sessions,
63+ app.Messages,
64+ app.History,
65+ app.LSPClients,
66+ ),
67+ )
68+ if err != nil {
69+ logging.Error("Failed to create coder agent", err)
70+ return nil, err
71+ }
72+
73+ return app, nil
74+}
75+
76+// Shutdown performs a clean shutdown of the application
77+func (app *App) Shutdown() {
78+ // Cancel all watcher goroutines
79+ app.cancelFuncsMutex.Lock()
80+ for _, cancel := range app.watcherCancelFuncs {
81+ cancel()
82+ }
83+ app.cancelFuncsMutex.Unlock()
84+ app.watcherWG.Wait()
85+
86+ // Perform additional cleanup for LSP clients
87+ app.clientsMutex.RLock()
88+ clients := make(map[string]*lsp.Client, len(app.LSPClients))
89+ maps.Copy(clients, app.LSPClients)
90+ app.clientsMutex.RUnlock()
91+
92+ for name, client := range clients {
93+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
94+ if err := client.Shutdown(shutdownCtx); err != nil {
95+ logging.Error("Failed to shutdown LSP client", "name", name, "error", err)
96+ }
97+ cancel()
98+ }
99+}
internal/app/lsp.gonew file+126
@@ -0,0 +1,126 @@
1+package app
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/kujtimiihoxha/opencode/internal/config"
8+ "github.com/kujtimiihoxha/opencode/internal/logging"
9+ "github.com/kujtimiihoxha/opencode/internal/lsp"
10+ "github.com/kujtimiihoxha/opencode/internal/lsp/watcher"
11+)
12+
13+func (app *App) initLSPClients(ctx context.Context) {
14+ cfg := config.Get()
15+
16+ // Initialize LSP clients
17+ for name, clientConfig := range cfg.LSP {
18+ // Start each client initialization in its own goroutine
19+ go app.createAndStartLSPClient(ctx, name, clientConfig.Command, clientConfig.Args...)
20+ }
21+ logging.Info("LSP clients initialization started in background")
22+}
23+
24+// createAndStartLSPClient creates a new LSP client, initializes it, and starts its workspace watcher
25+func (app *App) createAndStartLSPClient(ctx context.Context, name string, command string, args ...string) {
26+ // Create a specific context for initialization with a timeout
27+ logging.Info("Creating LSP client", "name", name, "command", command, "args", args)
28+
29+ // Create the LSP client
30+ lspClient, err := lsp.NewClient(ctx, command, args...)
31+ if err != nil {
32+ logging.Error("Failed to create LSP client for", name, err)
33+ return
34+ }
35+
36+ // Create a longer timeout for initialization (some servers take time to start)
37+ initCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
38+ defer cancel()
39+
40+ // Initialize with the initialization context
41+ _, err = lspClient.InitializeLSPClient(initCtx, config.WorkingDirectory())
42+ if err != nil {
43+ logging.Error("Initialize failed", "name", name, "error", err)
44+ // Clean up the client to prevent resource leaks
45+ lspClient.Close()
46+ return
47+ }
48+
49+ // Wait for the server to be ready
50+ if err := lspClient.WaitForServerReady(initCtx); err != nil {
51+ logging.Error("Server failed to become ready", "name", name, "error", err)
52+ // We'll continue anyway, as some functionality might still work
53+ lspClient.SetServerState(lsp.StateError)
54+ } else {
55+ logging.Info("LSP server is ready", "name", name)
56+ lspClient.SetServerState(lsp.StateReady)
57+ }
58+
59+ logging.Info("LSP client initialized", "name", name)
60+
61+ // Create a child context that can be canceled when the app is shutting down
62+ watchCtx, cancelFunc := context.WithCancel(ctx)
63+
64+ // Create a context with the server name for better identification
65+ watchCtx = context.WithValue(watchCtx, "serverName", name)
66+
67+ // Create the workspace watcher
68+ workspaceWatcher := watcher.NewWorkspaceWatcher(lspClient)
69+
70+ // Store the cancel function to be called during cleanup
71+ app.cancelFuncsMutex.Lock()
72+ app.watcherCancelFuncs = append(app.watcherCancelFuncs, cancelFunc)
73+ app.cancelFuncsMutex.Unlock()
74+
75+ // Add the watcher to a WaitGroup to track active goroutines
76+ app.watcherWG.Add(1)
77+
78+ // Add to map with mutex protection before starting goroutine
79+ app.clientsMutex.Lock()
80+ app.LSPClients[name] = lspClient
81+ app.clientsMutex.Unlock()
82+
83+ go app.runWorkspaceWatcher(watchCtx, name, workspaceWatcher)
84+}
85+
86+// runWorkspaceWatcher executes the workspace watcher for an LSP client
87+func (app *App) runWorkspaceWatcher(ctx context.Context, name string, workspaceWatcher *watcher.WorkspaceWatcher) {
88+ defer app.watcherWG.Done()
89+ defer logging.RecoverPanic("LSP-"+name, func() {
90+ // Try to restart the client
91+ app.restartLSPClient(ctx, name)
92+ })
93+
94+ workspaceWatcher.WatchWorkspace(ctx, config.WorkingDirectory())
95+ logging.Info("Workspace watcher stopped", "client", name)
96+}
97+
98+// restartLSPClient attempts to restart a crashed or failed LSP client
99+func (app *App) restartLSPClient(ctx context.Context, name string) {
100+ // Get the original configuration
101+ cfg := config.Get()
102+ clientConfig, exists := cfg.LSP[name]
103+ if !exists {
104+ logging.Error("Cannot restart client, configuration not found", "client", name)
105+ return
106+ }
107+
108+ // Clean up the old client if it exists
109+ app.clientsMutex.Lock()
110+ oldClient, exists := app.LSPClients[name]
111+ if exists {
112+ delete(app.LSPClients, name) // Remove from map before potentially slow shutdown
113+ }
114+ app.clientsMutex.Unlock()
115+
116+ if exists && oldClient != nil {
117+ // Try to shut it down gracefully, but don't block on errors
118+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
119+ _ = oldClient.Shutdown(shutdownCtx)
120+ cancel()
121+ }
122+
123+ // Create a new client using the shared function
124+ app.createAndStartLSPClient(ctx, name, clientConfig.Command, clientConfig.Args...)
125+ logging.Info("Successfully restarted LSP client", "client", name)
126+}
internal/app/services.godeleted−60
@@ -1,60 +0,0 @@
1−package app
2−
3−import (
4− "context"
5− "database/sql"
6−
7− "github.com/kujtimiihoxha/termai/internal/config"
8− "github.com/kujtimiihoxha/termai/internal/db"
9− "github.com/kujtimiihoxha/termai/internal/logging"
10− "github.com/kujtimiihoxha/termai/internal/lsp"
11− "github.com/kujtimiihoxha/termai/internal/lsp/watcher"
12− "github.com/kujtimiihoxha/termai/internal/message"
13− "github.com/kujtimiihoxha/termai/internal/permission"
14− "github.com/kujtimiihoxha/termai/internal/session"
15−)
16−
17−type App struct {
18− Context context.Context
19−
20− Sessions session.Service
21− Messages message.Service
22− Permissions permission.Service
23−
24− LSPClients map[string]*lsp.Client
25−}
26−
27−func New(ctx context.Context, conn *sql.DB) *App {
28− cfg := config.Get()
29− logging.Info("Debug mode enabled")
30−
31− q := db.New(conn)
32− sessions := session.NewService(ctx, q)
33− messages := message.NewService(ctx, q)
34−
35− app := &App{
36− Context: ctx,
37− Sessions: sessions,
38− Messages: messages,
39− Permissions: permission.NewPermissionService(),
40− LSPClients: make(map[string]*lsp.Client),
41− }
42−
43− for name, client := range cfg.LSP {
44− lspClient, err := lsp.NewClient(ctx, client.Command, client.Args...)
45− workspaceWatcher := watcher.NewWorkspaceWatcher(lspClient)
46− if err != nil {
47− logging.Error("Failed to create LSP client for", name, err)
48− continue
49− }
50−
51− _, err = lspClient.InitializeLSPClient(ctx, config.WorkingDirectory())
52− if err != nil {
53− logging.Error("Initialize failed", "error", err)
54− continue
55− }
56− go workspaceWatcher.WatchWorkspace(ctx, config.WorkingDirectory())
57− app.LSPClients[name] = lspClient
58− }
59− return app
60−}
internal/config/config.go+490−103
@@ -1,21 +1,27 @@
1+// Package config manages application configuration from various sources.
12package config
23
34import (
45 "fmt"
6+ "log/slog"
57 "os"
68 "strings"
79
8− "github.com/kujtimiihoxha/termai/internal/llm/models"
10+ "github.com/kujtimiihoxha/opencode/internal/llm/models"
11+ "github.com/kujtimiihoxha/opencode/internal/logging"
912 "github.com/spf13/viper"
1013)
1114
15+// MCPType defines the type of MCP (Model Control Protocol) server.
1216type MCPType string
1317
18+// Supported MCP types
1419const (
1520 MCPStdio MCPType = "stdio"
1621 MCPSse MCPType = "sse"
1722)
1823
24+// MCPServer defines the configuration for a Model Control Protocol server.
1925type MCPServer struct {
2026 Command string `json:"command"`
2127 Env []string `json:"env"`
@@ -23,37 +29,35 @@ type MCPServer struct {
2329 Type MCPType `json:"type"`
2430 URL string `json:"url"`
2531 Headers map[string]string `json:"headers"`
26− // TODO: add permissions configuration
27− // TODO: add the ability to specify the tools to import
2832}
2933
30−type Model struct {
31− Coder models.ModelID `json:"coder"`
32− CoderMaxTokens int64 `json:"coderMaxTokens"`
34+type AgentName string
3335
34− Task models.ModelID `json:"task"`
35− TaskMaxTokens int64 `json:"taskMaxTokens"`
36− // TODO: Maybe support multiple models for different purposes
37−}
36+const (
37+ AgentCoder AgentName = "coder"
38+ AgentTask AgentName = "task"
39+ AgentTitle AgentName = "title"
40+)
3841
39−type AnthropicConfig struct {
40− DisableCache bool `json:"disableCache"`
41− UseBedrock bool `json:"useBedrock"`
42+// Agent defines configuration for different LLM models and their token limits.
43+type Agent struct {
44+ Model models.ModelID `json:"model"`
45+ MaxTokens int64 `json:"maxTokens"`
46+ ReasoningEffort string `json:"reasoningEffort"` // For openai models low,medium,heigh
4247}
4348
49+// Provider defines configuration for an LLM provider.
4450type Provider struct {
45− APIKey string `json:"apiKey"`
46− Enabled bool `json:"enabled"`
51+ APIKey string `json:"apiKey"`
52+ Disabled bool `json:"disabled"`
4753}
4854
55+// Data defines storage configuration.
4956type Data struct {
5057 Directory string `json:"directory"`
5158}
5259
53−type Log struct {
54− Level string `json:"level"`
55−}
56−
60+// LSPConfig defines configuration for Language Server Protocol integration.
5761type LSPConfig struct {
5862 Disabled bool `json:"enabled"`
5963 Command string `json:"command"`
@@ -61,41 +65,126 @@ type LSPConfig struct {
6165 Options any `json:"options"`
6266}
6367
68+// Config is the main configuration structure for the application.
6469type Config struct {
65− Data *Data `json:"data,omitempty"`
66− Log *Log `json:"log,omitempty"`
70+ Data Data `json:"data"`
71+ WorkingDir string `json:"wd,omitempty"`
6772 MCPServers map[string]MCPServer `json:"mcpServers,omitempty"`
6873 Providers map[models.ModelProvider]Provider `json:"providers,omitempty"`
69−
70− LSP map[string]LSPConfig `json:"lsp,omitempty"`
71−
72− Model *Model `json:"model,omitempty"`
73−
74− Debug bool `json:"debug,omitempty"`
74+ LSP map[string]LSPConfig `json:"lsp,omitempty"`
75+ Agents map[AgentName]Agent `json:"agents"`
76+ Debug bool `json:"debug,omitempty"`
77+ DebugLSP bool `json:"debugLSP,omitempty"`
7578}
7679
77−var cfg *Config
78−
80+// Application constants
7981const (
80− defaultDataDirectory = ".termai"
82+ defaultDataDirectory = ".opencode"
8183 defaultLogLevel = "info"
82− defaultMaxTokens = int64(5000)
83− termai = "termai"
84+ appName = "opencode"
8485)
8586
86−func Load(debug bool) error {
87+// Global configuration instance
88+var cfg *Config
89+
90+// Load initializes the configuration from environment variables and config files.
91+// If debug is true, debug mode is enabled and log level is set to debug.
92+// It returns an error if configuration loading fails.
93+func Load(workingDir string, debug bool) (*Config, error) {
8794 if cfg != nil {
88− return nil
95+ return cfg, nil
96+ }
97+
98+ cfg = &Config{
99+ WorkingDir: workingDir,
100+ MCPServers: make(map[string]MCPServer),
101+ Providers: make(map[models.ModelProvider]Provider),
102+ LSP: make(map[string]LSPConfig),
103+ }
104+
105+ configureViper()
106+ setDefaults(debug)
107+ setProviderDefaults()
108+
109+ // Read global config
110+ if err := readConfig(viper.ReadInConfig()); err != nil {
111+ return cfg, err
89112 }
90113
91− viper.SetConfigName(fmt.Sprintf(".%s", termai))
114+ // Load and merge local config
115+ mergeLocalConfig(workingDir)
116+
117+ // Apply configuration to the struct
118+ if err := viper.Unmarshal(cfg); err != nil {
119+ return cfg, fmt.Errorf("failed to unmarshal config: %w", err)
120+ }
121+
122+ applyDefaultValues()
123+ defaultLevel := slog.LevelInfo
124+ if cfg.Debug {
125+ defaultLevel = slog.LevelDebug
126+ }
127+ if os.Getenv("OPENCODE_DEV_DEBUG") == "true" {
128+ loggingFile := fmt.Sprintf("%s/%s", cfg.Data.Directory, "debug.log")
129+
130+ // if file does not exist create it
131+ if _, err := os.Stat(loggingFile); os.IsNotExist(err) {
132+ if err := os.MkdirAll(cfg.Data.Directory, 0o755); err != nil {
133+ return cfg, fmt.Errorf("failed to create directory: %w", err)
134+ }
135+ if _, err := os.Create(loggingFile); err != nil {
136+ return cfg, fmt.Errorf("failed to create log file: %w", err)
137+ }
138+ }
139+
140+ sloggingFileWriter, err := os.OpenFile(loggingFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666)
141+ if err != nil {
142+ return cfg, fmt.Errorf("failed to open log file: %w", err)
143+ }
144+ // Configure logger
145+ logger := slog.New(slog.NewTextHandler(sloggingFileWriter, &slog.HandlerOptions{
146+ Level: defaultLevel,
147+ }))
148+ slog.SetDefault(logger)
149+ } else {
150+ // Configure logger
151+ logger := slog.New(slog.NewTextHandler(logging.NewWriter(), &slog.HandlerOptions{
152+ Level: defaultLevel,
153+ }))
154+ slog.SetDefault(logger)
155+ }
156+
157+ // Validate configuration
158+ if err := Validate(); err != nil {
159+ return cfg, fmt.Errorf("config validation failed: %w", err)
160+ }
161+
162+ if cfg.Agents == nil {
163+ cfg.Agents = make(map[AgentName]Agent)
164+ }
165+
166+ // Override the max tokens for title agent
167+ cfg.Agents[AgentTitle] = Agent{
168+ Model: cfg.Agents[AgentTitle].Model,
169+ MaxTokens: 80,
170+ }
171+ return cfg, nil
172+}
173+
174+// configureViper sets up viper's configuration paths and environment variables.
175+func configureViper() {
176+ viper.SetConfigName(fmt.Sprintf(".%s", appName))
92177 viper.SetConfigType("json")
93178 viper.AddConfigPath("$HOME")
94− viper.AddConfigPath(fmt.Sprintf("$XDG_CONFIG_HOME/%s", termai))
95− viper.SetEnvPrefix(strings.ToUpper(termai))
179+ viper.AddConfigPath(fmt.Sprintf("$XDG_CONFIG_HOME/%s", appName))
180+ viper.SetEnvPrefix(strings.ToUpper(appName))
181+ viper.AutomaticEnv()
182+}
96183
97− // Add defaults
184+// setDefaults configures default values for configuration options.
185+func setDefaults(debug bool) {
98186 viper.SetDefault("data.directory", defaultDataDirectory)
187+
99188 if debug {
100189 viper.SetDefault("debug", true)
101190 viper.Set("log.level", "debug")
@@ -103,98 +192,396 @@ func Load(debug bool) error {
103192 viper.SetDefault("debug", false)
104193 viper.SetDefault("log.level", defaultLogLevel)
105194 }
195+}
106196
107− defaultModelSet := false
108− if os.Getenv("ANTHROPIC_API_KEY") != "" {
109− viper.SetDefault("providers.anthropic.apiKey", os.Getenv("ANTHROPIC_API_KEY"))
110− viper.SetDefault("providers.anthropic.enabled", true)
111− viper.SetDefault("model.coder", models.Claude37Sonnet)
112− viper.SetDefault("model.task", models.Claude37Sonnet)
113− defaultModelSet = true
197+// setProviderDefaults configures LLM provider defaults based on environment variables.
198+// the default model priority is:
199+// 1. Anthropic
200+// 2. OpenAI
201+// 3. Google Gemini
202+// 4. AWS Bedrock
203+func setProviderDefaults() {
204+ // Groq configuration
205+ if apiKey := os.Getenv("GROQ_API_KEY"); apiKey != "" {
206+ viper.SetDefault("providers.groq.apiKey", apiKey)
207+ viper.SetDefault("agents.coder.model", models.QWENQwq)
208+ viper.SetDefault("agents.task.model", models.QWENQwq)
209+ viper.SetDefault("agents.title.model", models.QWENQwq)
114210 }
115− if os.Getenv("OPENAI_API_KEY") != "" {
116− viper.SetDefault("providers.openai.apiKey", os.Getenv("OPENAI_API_KEY"))
117− viper.SetDefault("providers.openai.enabled", true)
118− if !defaultModelSet {
119− viper.SetDefault("model.coder", models.GPT41)
120− viper.SetDefault("model.task", models.GPT41)
121− defaultModelSet = true
122− }
211+
212+ // Google Gemini configuration
213+ if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
214+ viper.SetDefault("providers.gemini.apiKey", apiKey)
215+ viper.SetDefault("agents.coder.model", models.Gemini25)
216+ viper.SetDefault("agents.task.model", models.Gemini25Flash)
217+ viper.SetDefault("agents.title.model", models.Gemini25Flash)
123218 }
124− if os.Getenv("GEMINI_API_KEY") != "" {
125− viper.SetDefault("providers.gemini.apiKey", os.Getenv("GEMINI_API_KEY"))
126− viper.SetDefault("providers.gemini.enabled", true)
127− if !defaultModelSet {
128− viper.SetDefault("model.coder", models.GRMINI20Flash)
129− viper.SetDefault("model.task", models.GRMINI20Flash)
130− defaultModelSet = true
131− }
219+
220+ // OpenAI configuration
221+ if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
222+ viper.SetDefault("providers.openai.apiKey", apiKey)
223+ viper.SetDefault("agents.coder.model", models.GPT41)
224+ viper.SetDefault("agents.task.model", models.GPT41Mini)
225+ viper.SetDefault("agents.title.model", models.GPT41Mini)
226+
132227 }
133− if os.Getenv("GROQ_API_KEY") != "" {
134− viper.SetDefault("providers.groq.apiKey", os.Getenv("GROQ_API_KEY"))
135− viper.SetDefault("providers.groq.enabled", true)
136− if !defaultModelSet {
137− viper.SetDefault("model.coder", models.QWENQwq)
138− viper.SetDefault("model.task", models.QWENQwq)
139− defaultModelSet = true
140− }
228+
229+ // Anthropic configuration
230+ if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
231+ viper.SetDefault("providers.anthropic.apiKey", apiKey)
232+ viper.SetDefault("agents.coder.model", models.Claude37Sonnet)
233+ viper.SetDefault("agents.task.model", models.Claude37Sonnet)
234+ viper.SetDefault("agents.title.model", models.Claude37Sonnet)
235+ }
236+
237+ if hasAWSCredentials() {
238+ viper.SetDefault("agents.coder.model", models.BedrockClaude37Sonnet)
239+ viper.SetDefault("agents.task.model", models.BedrockClaude37Sonnet)
240+ viper.SetDefault("agents.title.model", models.BedrockClaude37Sonnet)
141241 }
242+}
142243
143− viper.SetDefault("providers.bedrock.enabled", true)
144− // TODO: add more providers
145− cfg = &Config{}
244+// hasAWSCredentials checks if AWS credentials are available in the environment.
245+func hasAWSCredentials() bool {
246+ // Check for explicit AWS credentials
247+ if os.Getenv("AWS_ACCESS_KEY_ID") != "" && os.Getenv("AWS_SECRET_ACCESS_KEY") != "" {
248+ return true
249+ }
146250
147− err := viper.ReadInConfig()
148− if err != nil {
149− if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
150− return err
151− }
251+ // Check for AWS profile
252+ if os.Getenv("AWS_PROFILE") != "" || os.Getenv("AWS_DEFAULT_PROFILE") != "" {
253+ return true
152254 }
255+
256+ // Check for AWS region
257+ if os.Getenv("AWS_REGION") != "" || os.Getenv("AWS_DEFAULT_REGION") != "" {
258+ return true
259+ }
260+
261+ // Check if running on EC2 with instance profile
262+ if os.Getenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") != "" ||
263+ os.Getenv("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
264+ return true
265+ }
266+
267+ return false
268+}
269+
270+// readConfig handles the result of reading a configuration file.
271+func readConfig(err error) error {
272+ if err == nil {
273+ return nil
274+ }
275+
276+ // It's okay if the config file doesn't exist
277+ if _, ok := err.(viper.ConfigFileNotFoundError); ok {
278+ return nil
279+ }
280+
281+ return fmt.Errorf("failed to read config: %w", err)
282+}
283+
284+// mergeLocalConfig loads and merges configuration from the local directory.
285+func mergeLocalConfig(workingDir string) {
153286 local := viper.New()
154− local.SetConfigName(fmt.Sprintf(".%s", termai))
287+ local.SetConfigName(fmt.Sprintf(".%s", appName))
155288 local.SetConfigType("json")
156− local.AddConfigPath(".")
157− // load local config, this will override the global config
158− if err = local.ReadInConfig(); err == nil {
289+ local.AddConfigPath(workingDir)
290+
291+ // Merge local config if it exists
292+ if err := local.ReadInConfig(); err == nil {
159293 viper.MergeConfigMap(local.AllSettings())
160294 }
161− viper.Unmarshal(cfg)
295+}
162296
163− if cfg.Model != nil && cfg.Model.CoderMaxTokens <= 0 {
164− cfg.Model.CoderMaxTokens = defaultMaxTokens
297+// applyDefaultValues sets default values for configuration fields that need processing.
298+func applyDefaultValues() {
299+ // Set default MCP type if not specified
300+ for k, v := range cfg.MCPServers {
301+ if v.Type == "" {
302+ v.Type = MCPStdio
303+ cfg.MCPServers[k] = v
304+ }
165305 }
166− if cfg.Model != nil && cfg.Model.TaskMaxTokens <= 0 {
167− cfg.Model.TaskMaxTokens = defaultMaxTokens
306+}
307+
308+// Validate checks if the configuration is valid and applies defaults where needed.
309+// It validates model IDs and providers, ensuring they are supported.
310+func Validate() error {
311+ if cfg == nil {
312+ return fmt.Errorf("config not loaded")
168313 }
169314
170− for _, v := range cfg.MCPServers {
171− if v.Type == "" {
172− v.Type = MCPStdio
315+ // Validate agent models
316+ for name, agent := range cfg.Agents {
317+ // Check if model exists
318+ model, modelExists := models.SupportedModels[agent.Model]
319+ if !modelExists {
320+ logging.Warn("unsupported model configured, reverting to default",
321+ "agent", name,
322+ "configured_model", agent.Model)
323+
324+ // Set default model based on available providers
325+ if setDefaultModelForAgent(name) {
326+ logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
327+ } else {
328+ return fmt.Errorf("no valid provider available for agent %s", name)
329+ }
330+ continue
331+ }
332+
333+ // Check if provider for the model is configured
334+ provider := model.Provider
335+ providerCfg, providerExists := cfg.Providers[provider]
336+
337+ if !providerExists {
338+ // Provider not configured, check if we have environment variables
339+ apiKey := getProviderAPIKey(provider)
340+ if apiKey == "" {
341+ logging.Warn("provider not configured for model, reverting to default",
342+ "agent", name,
343+ "model", agent.Model,
344+ "provider", provider)
345+
346+ // Set default model based on available providers
347+ if setDefaultModelForAgent(name) {
348+ logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
349+ } else {
350+ return fmt.Errorf("no valid provider available for agent %s", name)
351+ }
352+ } else {
353+ // Add provider with API key from environment
354+ cfg.Providers[provider] = Provider{
355+ APIKey: apiKey,
356+ }
357+ logging.Info("added provider from environment", "provider", provider)
358+ }
359+ } else if providerCfg.Disabled || providerCfg.APIKey == "" {
360+ // Provider is disabled or has no API key
361+ logging.Warn("provider is disabled or has no API key, reverting to default",
362+ "agent", name,
363+ "model", agent.Model,
364+ "provider", provider)
365+
366+ // Set default model based on available providers
367+ if setDefaultModelForAgent(name) {
368+ logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
369+ } else {
370+ return fmt.Errorf("no valid provider available for agent %s", name)
371+ }
372+ }
373+
374+ // Validate max tokens
375+ if agent.MaxTokens <= 0 {
376+ logging.Warn("invalid max tokens, setting to default",
377+ "agent", name,
378+ "model", agent.Model,
379+ "max_tokens", agent.MaxTokens)
380+
381+ // Update the agent with default max tokens
382+ updatedAgent := cfg.Agents[name]
383+ if model.DefaultMaxTokens > 0 {
384+ updatedAgent.MaxTokens = model.DefaultMaxTokens
385+ } else {
386+ updatedAgent.MaxTokens = 4096 // Fallback default
387+ }
388+ cfg.Agents[name] = updatedAgent
389+ } else if model.ContextWindow > 0 && agent.MaxTokens > model.ContextWindow/2 {
390+ // Ensure max tokens doesn't exceed half the context window (reasonable limit)
391+ logging.Warn("max tokens exceeds half the context window, adjusting",
392+ "agent", name,
393+ "model", agent.Model,
394+ "max_tokens", agent.MaxTokens,
395+ "context_window", model.ContextWindow)
396+
397+ // Update the agent with adjusted max tokens
398+ updatedAgent := cfg.Agents[name]
399+ updatedAgent.MaxTokens = model.ContextWindow / 2
400+ cfg.Agents[name] = updatedAgent
401+ }
402+
403+ // Validate reasoning effort for models that support reasoning
404+ if model.CanReason && provider == models.ProviderOpenAI {
405+ if agent.ReasoningEffort == "" {
406+ // Set default reasoning effort for models that support it
407+ logging.Info("setting default reasoning effort for model that supports reasoning",
408+ "agent", name,
409+ "model", agent.Model)
410+
411+ // Update the agent with default reasoning effort
412+ updatedAgent := cfg.Agents[name]
413+ updatedAgent.ReasoningEffort = "medium"
414+ cfg.Agents[name] = updatedAgent
415+ } else {
416+ // Check if reasoning effort is valid (low, medium, high)
417+ effort := strings.ToLower(agent.ReasoningEffort)
418+ if effort != "low" && effort != "medium" && effort != "high" {
419+ logging.Warn("invalid reasoning effort, setting to medium",
420+ "agent", name,
421+ "model", agent.Model,
422+ "reasoning_effort", agent.ReasoningEffort)
423+
424+ // Update the agent with valid reasoning effort
425+ updatedAgent := cfg.Agents[name]
426+ updatedAgent.ReasoningEffort = "medium"
427+ cfg.Agents[name] = updatedAgent
428+ }
429+ }
430+ } else if !model.CanReason && agent.ReasoningEffort != "" {
431+ // Model doesn't support reasoning but reasoning effort is set
432+ logging.Warn("model doesn't support reasoning but reasoning effort is set, ignoring",
433+ "agent", name,
434+ "model", agent.Model,
435+ "reasoning_effort", agent.ReasoningEffort)
436+
437+ // Update the agent to remove reasoning effort
438+ updatedAgent := cfg.Agents[name]
439+ updatedAgent.ReasoningEffort = ""
440+ cfg.Agents[name] = updatedAgent
173441 }
174442 }
175443
176− workdir, err := os.Getwd()
177− if err != nil {
178− return err
444+ // Validate providers
445+ for provider, providerCfg := range cfg.Providers {
446+ if providerCfg.APIKey == "" && !providerCfg.Disabled {
447+ logging.Warn("provider has no API key, marking as disabled", "provider", provider)
448+ providerCfg.Disabled = true
449+ cfg.Providers[provider] = providerCfg
450+ }
179451 }
180− viper.Set("wd", workdir)
452+
453+ // Validate LSP configurations
454+ for language, lspConfig := range cfg.LSP {
455+ if lspConfig.Command == "" && !lspConfig.Disabled {
456+ logging.Warn("LSP configuration has no command, marking as disabled", "language", language)
457+ lspConfig.Disabled = true
458+ cfg.LSP[language] = lspConfig
459+ }
460+ }
461+
181462 return nil
182463}
183464
184−func Get() *Config {
185− if cfg == nil {
186− err := Load(false)
187− if err != nil {
188− panic(err)
465+// getProviderAPIKey gets the API key for a provider from environment variables
466+func getProviderAPIKey(provider models.ModelProvider) string {
467+ switch provider {
468+ case models.ProviderAnthropic:
469+ return os.Getenv("ANTHROPIC_API_KEY")
470+ case models.ProviderOpenAI:
471+ return os.Getenv("OPENAI_API_KEY")
472+ case models.ProviderGemini:
473+ return os.Getenv("GEMINI_API_KEY")
474+ case models.ProviderGROQ:
475+ return os.Getenv("GROQ_API_KEY")
476+ case models.ProviderBedrock:
477+ if hasAWSCredentials() {
478+ return "aws-credentials-available"
189479 }
190480 }
191− return cfg
481+ return ""
192482}
193483
194−func WorkingDirectory() string {
195− return viper.GetString("wd")
484+// setDefaultModelForAgent sets a default model for an agent based on available providers
485+func setDefaultModelForAgent(agent AgentName) bool {
486+ // Check providers in order of preference
487+ if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
488+ maxTokens := int64(5000)
489+ if agent == AgentTitle {
490+ maxTokens = 80
491+ }
492+ cfg.Agents[agent] = Agent{
493+ Model: models.Claude37Sonnet,
494+ MaxTokens: maxTokens,
495+ }
496+ return true
497+ }
498+
499+ if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
500+ var model models.ModelID
501+ maxTokens := int64(5000)
502+ reasoningEffort := ""
503+
504+ switch agent {
505+ case AgentTitle:
506+ model = models.GPT41Mini
507+ maxTokens = 80
508+ case AgentTask:
509+ model = models.GPT41Mini
510+ default:
511+ model = models.GPT41
512+ }
513+
514+ // Check if model supports reasoning
515+ if modelInfo, ok := models.SupportedModels[model]; ok && modelInfo.CanReason {
516+ reasoningEffort = "medium"
517+ }
518+
519+ cfg.Agents[agent] = Agent{
520+ Model: model,
521+ MaxTokens: maxTokens,
522+ ReasoningEffort: reasoningEffort,
523+ }
524+ return true
525+ }
526+
527+ if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
528+ var model models.ModelID
529+ maxTokens := int64(5000)
530+
531+ if agent == AgentTitle {
532+ model = models.Gemini25Flash
533+ maxTokens = 80
534+ } else {
535+ model = models.Gemini25
536+ }
537+
538+ cfg.Agents[agent] = Agent{
539+ Model: model,
540+ MaxTokens: maxTokens,
541+ }
542+ return true
543+ }
544+
545+ if apiKey := os.Getenv("GROQ_API_KEY"); apiKey != "" {
546+ maxTokens := int64(5000)
547+ if agent == AgentTitle {
548+ maxTokens = 80
549+ }
550+
551+ cfg.Agents[agent] = Agent{
552+ Model: models.QWENQwq,
553+ MaxTokens: maxTokens,
554+ }
555+ return true
556+ }
557+
558+ if hasAWSCredentials() {
559+ maxTokens := int64(5000)
560+ if agent == AgentTitle {
561+ maxTokens = 80
562+ }
563+
564+ cfg.Agents[agent] = Agent{
565+ Model: models.BedrockClaude37Sonnet,
566+ MaxTokens: maxTokens,
567+ ReasoningEffort: "medium", // Claude models support reasoning
568+ }
569+ return true
570+ }
571+
572+ return false
573+}
574+
575+// Get returns the current configuration.
576+// It's safe to call this function multiple times.
577+func Get() *Config {
578+ return cfg
196579}
197580
198−func Write() error {
199− return viper.WriteConfig()
581+// WorkingDirectory returns the current working directory from the configuration.
582+func WorkingDirectory() string {
583+ if cfg == nil {
584+ panic("config not loaded")
585+ }
586+ return cfg.WorkingDir
200587}
internal/config/config_test.godeleted−465
@@ -1,465 +0,0 @@
1−package config
2−
3−import (
4− "fmt"
5− "os"
6− "path/filepath"
7− "testing"
8−
9− "github.com/kujtimiihoxha/termai/internal/llm/models"
10− "github.com/spf13/viper"
11− "github.com/stretchr/testify/assert"
12− "github.com/stretchr/testify/require"
13−)
14−
15−func TestLoad(t *testing.T) {
16− setupTest(t)
17−
18− t.Run("loads configuration successfully", func(t *testing.T) {
19− homeDir := t.TempDir()
20− t.Setenv("HOME", homeDir)
21− configPath := filepath.Join(homeDir, ".termai.json")
22−
23− configContent := `{
24− "data": {
25− "directory": "custom-dir"
26− },
27− "log": {
28− "level": "debug"
29− },
30− "mcpServers": {
31− "test-server": {
32− "command": "test-command",
33− "env": ["TEST_ENV=value"],
34− "args": ["--arg1", "--arg2"],
35− "type": "stdio",
36− "url": "",
37− "headers": {}
38− },
39− "sse-server": {
40− "command": "",
41− "env": [],
42− "args": [],
43− "type": "sse",
44− "url": "https://api.example.com/events",
45− "headers": {
46− "Authorization": "Bearer token123",
47− "Content-Type": "application/json"
48− }
49− }
50− },
51− "providers": {
52− "anthropic": {
53− "apiKey": "test-api-key",
54− "enabled": true
55− }
56− },
57− "model": {
58− "coder": "claude-3-haiku",
59− "task": "claude-3-haiku"
60− }
61− }`
62− err := os.WriteFile(configPath, []byte(configContent), 0o644)
63− require.NoError(t, err)
64−
65− cfg = nil
66− viper.Reset()
67−
68− err = Load(false)
69− require.NoError(t, err)
70−
71− config := Get()
72− assert.NotNil(t, config)
73− assert.Equal(t, "custom-dir", config.Data.Directory)
74− assert.Equal(t, "debug", config.Log.Level)
75−
76− assert.Contains(t, config.MCPServers, "test-server")
77− stdioServer := config.MCPServers["test-server"]
78− assert.Equal(t, "test-command", stdioServer.Command)
79− assert.Equal(t, []string{"TEST_ENV=value"}, stdioServer.Env)
80− assert.Equal(t, []string{"--arg1", "--arg2"}, stdioServer.Args)
81− assert.Equal(t, MCPStdio, stdioServer.Type)
82− assert.Equal(t, "", stdioServer.URL)
83− assert.Empty(t, stdioServer.Headers)
84−
85− assert.Contains(t, config.MCPServers, "sse-server")
86− sseServer := config.MCPServers["sse-server"]
87− assert.Equal(t, "", sseServer.Command)
88− assert.Empty(t, sseServer.Env)
89− assert.Empty(t, sseServer.Args)
90− assert.Equal(t, MCPSse, sseServer.Type)
91− assert.Equal(t, "https://api.example.com/events", sseServer.URL)
92− assert.Equal(t, map[string]string{
93− "authorization": "Bearer token123",
94− "content-type": "application/json",
95− }, sseServer.Headers)
96−
97− assert.Contains(t, config.Providers, models.ModelProvider("anthropic"))
98− provider := config.Providers[models.ModelProvider("anthropic")]
99− assert.Equal(t, "test-api-key", provider.APIKey)
100− assert.True(t, provider.Enabled)
101−
102− assert.NotNil(t, config.Model)
103− assert.Equal(t, models.Claude3Haiku, config.Model.Coder)
104− assert.Equal(t, models.Claude3Haiku, config.Model.Task)
105− assert.Equal(t, defaultMaxTokens, config.Model.CoderMaxTokens)
106− })
107−
108− t.Run("loads configuration with environment variables", func(t *testing.T) {
109− homeDir := t.TempDir()
110− t.Setenv("HOME", homeDir)
111− configPath := filepath.Join(homeDir, ".termai.json")
112− err := os.WriteFile(configPath, []byte("{}"), 0o644)
113− require.NoError(t, err)
114−
115− t.Setenv("ANTHROPIC_API_KEY", "env-anthropic-key")
116− t.Setenv("OPENAI_API_KEY", "env-openai-key")
117− t.Setenv("GEMINI_API_KEY", "env-gemini-key")
118−
119− cfg = nil
120− viper.Reset()
121−
122− err = Load(false)
123− require.NoError(t, err)
124−
125− config := Get()
126− assert.NotNil(t, config)
127−
128− assert.Equal(t, defaultDataDirectory, config.Data.Directory)
129− assert.Equal(t, defaultLogLevel, config.Log.Level)
130−
131− assert.Contains(t, config.Providers, models.ModelProvider("anthropic"))
132− assert.Equal(t, "env-anthropic-key", config.Providers[models.ModelProvider("anthropic")].APIKey)
133− assert.True(t, config.Providers[models.ModelProvider("anthropic")].Enabled)
134−
135− assert.Contains(t, config.Providers, models.ModelProvider("openai"))
136− assert.Equal(t, "env-openai-key", config.Providers[models.ModelProvider("openai")].APIKey)
137− assert.True(t, config.Providers[models.ModelProvider("openai")].Enabled)
138−
139− assert.Contains(t, config.Providers, models.ModelProvider("gemini"))
140− assert.Equal(t, "env-gemini-key", config.Providers[models.ModelProvider("gemini")].APIKey)
141− assert.True(t, config.Providers[models.ModelProvider("gemini")].Enabled)
142−
143− assert.Equal(t, models.Claude37Sonnet, config.Model.Coder)
144− })
145−
146− t.Run("local config overrides global config", func(t *testing.T) {
147− homeDir := t.TempDir()
148− t.Setenv("HOME", homeDir)
149− globalConfigPath := filepath.Join(homeDir, ".termai.json")
150− globalConfig := `{
151− "data": {
152− "directory": "global-dir"
153− },
154− "log": {
155− "level": "info"
156− }
157− }`
158− err := os.WriteFile(globalConfigPath, []byte(globalConfig), 0o644)
159− require.NoError(t, err)
160−
161− workDir := t.TempDir()
162− origDir, err := os.Getwd()
163− require.NoError(t, err)
164− defer os.Chdir(origDir)
165− err = os.Chdir(workDir)
166− require.NoError(t, err)
167−
168− localConfigPath := filepath.Join(workDir, ".termai.json")
169− localConfig := `{
170− "data": {
171− "directory": "local-dir"
172− },
173− "log": {
174− "level": "debug"
175− }
176− }`
177− err = os.WriteFile(localConfigPath, []byte(localConfig), 0o644)
178− require.NoError(t, err)
179−
180− cfg = nil
181− viper.Reset()
182−
183− err = Load(false)
184− require.NoError(t, err)
185−
186− config := Get()
187− assert.NotNil(t, config)
188−
189− assert.Equal(t, "local-dir", config.Data.Directory)
190− assert.Equal(t, "debug", config.Log.Level)
191− })
192−
193− t.Run("missing config file should not return error", func(t *testing.T) {
194− emptyDir := t.TempDir()
195− t.Setenv("HOME", emptyDir)
196−
197− cfg = nil
198− viper.Reset()
199−
200− err := Load(false)
201− assert.NoError(t, err)
202− })
203−
204− t.Run("model priority and fallbacks", func(t *testing.T) {
205− testCases := []struct {
206− name string
207− anthropicKey string
208− openaiKey string
209− geminiKey string
210− expectedModel models.ModelID
211− explicitModel models.ModelID
212− useExplicitModel bool
213− }{
214− {
215− name: "anthropic has priority",
216− anthropicKey: "test-key",
217− openaiKey: "test-key",
218− geminiKey: "test-key",
219− expectedModel: models.Claude37Sonnet,
220− },
221− {
222− name: "fallback to openai when no anthropic",
223− anthropicKey: "",
224− openaiKey: "test-key",
225− geminiKey: "test-key",
226− expectedModel: models.GPT41,
227− },
228− {
229− name: "fallback to gemini when no others",
230− anthropicKey: "",
231− openaiKey: "",
232− geminiKey: "test-key",
233− expectedModel: models.GRMINI20Flash,
234− },
235− {
236− name: "explicit model overrides defaults",
237− anthropicKey: "test-key",
238− openaiKey: "test-key",
239− geminiKey: "test-key",
240− explicitModel: models.GPT41,
241− useExplicitModel: true,
242− expectedModel: models.GPT41,
243− },
244− }
245−
246− for _, tc := range testCases {
247− t.Run(tc.name, func(t *testing.T) {
248− homeDir := t.TempDir()
249− t.Setenv("HOME", homeDir)
250− configPath := filepath.Join(homeDir, ".termai.json")
251−
252− configContent := "{}"
253− if tc.useExplicitModel {
254− configContent = fmt.Sprintf(`{"model":{"coder":"%s"}}`, tc.explicitModel)
255− }
256−
257− err := os.WriteFile(configPath, []byte(configContent), 0o644)
258− require.NoError(t, err)
259−
260− if tc.anthropicKey != "" {
261− t.Setenv("ANTHROPIC_API_KEY", tc.anthropicKey)
262− } else {
263− t.Setenv("ANTHROPIC_API_KEY", "")
264− }
265−
266− if tc.openaiKey != "" {
267− t.Setenv("OPENAI_API_KEY", tc.openaiKey)
268− } else {
269− t.Setenv("OPENAI_API_KEY", "")
270− }
271−
272− if tc.geminiKey != "" {
273− t.Setenv("GEMINI_API_KEY", tc.geminiKey)
274− } else {
275− t.Setenv("GEMINI_API_KEY", "")
276− }
277−
278− cfg = nil
279− viper.Reset()
280−
281− err = Load(false)
282− require.NoError(t, err)
283−
284− config := Get()
285− assert.NotNil(t, config)
286− assert.Equal(t, tc.expectedModel, config.Model.Coder)
287− })
288− }
289− })
290−}
291−
292−func TestGet(t *testing.T) {
293− t.Run("get returns same config instance", func(t *testing.T) {
294− setupTest(t)
295− homeDir := t.TempDir()
296− t.Setenv("HOME", homeDir)
297− configPath := filepath.Join(homeDir, ".termai.json")
298− err := os.WriteFile(configPath, []byte("{}"), 0o644)
299− require.NoError(t, err)
300−
301− cfg = nil
302− viper.Reset()
303−
304− config1 := Get()
305− require.NotNil(t, config1)
306−
307− config2 := Get()
308− require.NotNil(t, config2)
309−
310− assert.Same(t, config1, config2)
311− })
312−
313− t.Run("get loads config if not loaded", func(t *testing.T) {
314− setupTest(t)
315− homeDir := t.TempDir()
316− t.Setenv("HOME", homeDir)
317− configPath := filepath.Join(homeDir, ".termai.json")
318− configContent := `{"data":{"directory":"test-dir"}}`
319− err := os.WriteFile(configPath, []byte(configContent), 0o644)
320− require.NoError(t, err)
321−
322− cfg = nil
323− viper.Reset()
324−
325− config := Get()
326− require.NotNil(t, config)
327− assert.Equal(t, "test-dir", config.Data.Directory)
328− })
329−}
330−
331−func TestWorkingDirectory(t *testing.T) {
332− t.Run("returns current working directory", func(t *testing.T) {
333− setupTest(t)
334− homeDir := t.TempDir()
335− t.Setenv("HOME", homeDir)
336− configPath := filepath.Join(homeDir, ".termai.json")
337− err := os.WriteFile(configPath, []byte("{}"), 0o644)
338− require.NoError(t, err)
339−
340− cfg = nil
341− viper.Reset()
342−
343− err = Load(false)
344− require.NoError(t, err)
345−
346− wd := WorkingDirectory()
347− expectedWd, err := os.Getwd()
348− require.NoError(t, err)
349− assert.Equal(t, expectedWd, wd)
350− })
351−}
352−
353−func TestWrite(t *testing.T) {
354− t.Run("writes config to file", func(t *testing.T) {
355− setupTest(t)
356− homeDir := t.TempDir()
357− t.Setenv("HOME", homeDir)
358− configPath := filepath.Join(homeDir, ".termai.json")
359− err := os.WriteFile(configPath, []byte("{}"), 0o644)
360− require.NoError(t, err)
361−
362− cfg = nil
363− viper.Reset()
364−
365− err = Load(false)
366− require.NoError(t, err)
367−
368− viper.Set("data.directory", "modified-dir")
369−
370− err = Write()
371− require.NoError(t, err)
372−
373− content, err := os.ReadFile(configPath)
374− require.NoError(t, err)
375− assert.Contains(t, string(content), "modified-dir")
376− })
377−}
378−
379−func TestMCPType(t *testing.T) {
380− t.Run("MCPType constants", func(t *testing.T) {
381− assert.Equal(t, MCPType("stdio"), MCPStdio)
382− assert.Equal(t, MCPType("sse"), MCPSse)
383− })
384−
385− t.Run("MCPType JSON unmarshaling", func(t *testing.T) {
386− homeDir := t.TempDir()
387− t.Setenv("HOME", homeDir)
388− configPath := filepath.Join(homeDir, ".termai.json")
389−
390− configContent := `{
391− "mcpServers": {
392− "stdio-server": {
393− "type": "stdio"
394− },
395− "sse-server": {
396− "type": "sse"
397− },
398− "invalid-server": {
399− "type": "invalid"
400− }
401− }
402− }`
403− err := os.WriteFile(configPath, []byte(configContent), 0o644)
404− require.NoError(t, err)
405−
406− cfg = nil
407− viper.Reset()
408−
409− err = Load(false)
410− require.NoError(t, err)
411−
412− config := Get()
413− assert.NotNil(t, config)
414−
415− assert.Equal(t, MCPStdio, config.MCPServers["stdio-server"].Type)
416− assert.Equal(t, MCPSse, config.MCPServers["sse-server"].Type)
417− assert.Equal(t, MCPType("invalid"), config.MCPServers["invalid-server"].Type)
418− })
419−
420− t.Run("default MCPType", func(t *testing.T) {
421− homeDir := t.TempDir()
422− t.Setenv("HOME", homeDir)
423− configPath := filepath.Join(homeDir, ".termai.json")
424−
425− configContent := `{
426− "mcpServers": {
427− "test-server": {
428− "command": "test-command"
429− }
430− }
431− }`
432− err := os.WriteFile(configPath, []byte(configContent), 0o644)
433− require.NoError(t, err)
434−
435− cfg = nil
436− viper.Reset()
437−
438− err = Load(false)
439− require.NoError(t, err)
440−
441− config := Get()
442− assert.NotNil(t, config)
443−
444− assert.Equal(t, MCPType(""), config.MCPServers["test-server"].Type)
445− })
446−}
447−
448−func setupTest(t *testing.T) {
449− origHome := os.Getenv("HOME")
450− origXdgConfigHome := os.Getenv("XDG_CONFIG_HOME")
451− origAnthropicKey := os.Getenv("ANTHROPIC_API_KEY")
452− origOpenAIKey := os.Getenv("OPENAI_API_KEY")
453− origGeminiKey := os.Getenv("GEMINI_API_KEY")
454−
455− t.Cleanup(func() {
456− t.Setenv("HOME", origHome)
457− t.Setenv("XDG_CONFIG_HOME", origXdgConfigHome)
458− t.Setenv("ANTHROPIC_API_KEY", origAnthropicKey)
459− t.Setenv("OPENAI_API_KEY", origOpenAIKey)
460− t.Setenv("GEMINI_API_KEY", origGeminiKey)
461−
462− cfg = nil
463− viper.Reset()
464− })
465−}
internal/config/init.gonew file+61
@@ -0,0 +1,61 @@
1+package config
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+)
8+
9+const (
10+ // InitFlagFilename is the name of the file that indicates whether the project has been initialized
11+ InitFlagFilename = "init"
12+)
13+
14+// ProjectInitFlag represents the initialization status for a project directory
15+type ProjectInitFlag struct {
16+ Initialized bool `json:"initialized"`
17+}
18+
19+// ShouldShowInitDialog checks if the initialization dialog should be shown for the current directory
20+func ShouldShowInitDialog() (bool, error) {
21+ if cfg == nil {
22+ return false, fmt.Errorf("config not loaded")
23+ }
24+
25+ // Create the flag file path
26+ flagFilePath := filepath.Join(cfg.Data.Directory, InitFlagFilename)
27+
28+ // Check if the flag file exists
29+ _, err := os.Stat(flagFilePath)
30+ if err == nil {
31+ // File exists, don't show the dialog
32+ return false, nil
33+ }
34+
35+ // If the error is not "file not found", return the error
36+ if !os.IsNotExist(err) {
37+ return false, fmt.Errorf("failed to check init flag file: %w", err)
38+ }
39+
40+ // File doesn't exist, show the dialog
41+ return true, nil
42+}
43+
44+// MarkProjectInitialized marks the current project as initialized
45+func MarkProjectInitialized() error {
46+ if cfg == nil {
47+ return fmt.Errorf("config not loaded")
48+ }
49+ // Create the flag file path
50+ flagFilePath := filepath.Join(cfg.Data.Directory, InitFlagFilename)
51+
52+ // Create an empty file to mark the project as initialized
53+ file, err := os.Create(flagFilePath)
54+ if err != nil {
55+ return fmt.Errorf("failed to create init flag file: %w", err)
56+ }
57+ defer file.Close()
58+
59+ return nil
60+}
61+
internal/db/connect.go+5−5
@@ -12,8 +12,8 @@ import (
1212 "github.com/golang-migrate/migrate/v4/database/sqlite3"
1313 _ "github.com/mattn/go-sqlite3"
1414
15− "github.com/kujtimiihoxha/termai/internal/config"
16− "github.com/kujtimiihoxha/termai/internal/logging"
15+ "github.com/kujtimiihoxha/opencode/internal/config"
16+ "github.com/kujtimiihoxha/opencode/internal/logging"
1717)
1818
1919func Connect() (*sql.DB, error) {
@@ -24,7 +24,7 @@ func Connect() (*sql.DB, error) {
2424 if err := os.MkdirAll(dataDir, 0o700); err != nil {
2525 return nil, fmt.Errorf("failed to create data directory: %w", err)
2626 }
27− dbPath := filepath.Join(dataDir, "termai.db")
27+ dbPath := filepath.Join(dataDir, "opencode.db")
2828 // Open the SQLite database
2929 db, err := sql.Open("sqlite3", dbPath)
3030 if err != nil {
@@ -48,9 +48,9 @@ func Connect() (*sql.DB, error) {
4848
4949 for _, pragma := range pragmas {
5050 if _, err = db.Exec(pragma); err != nil {
51− logging.Warn("Failed to set pragma", pragma, err)
51+ logging.Error("Failed to set pragma", pragma, err)
5252 } else {
53− logging.Warn("Set pragma", "pragma", pragma)
53+ logging.Debug("Set pragma", "pragma", pragma)
5454 }
5555 }
5656
internal/db/db.go+126−26
@@ -24,33 +24,63 @@ func New(db DBTX) *Queries {
2424func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
2525 q := Queries{db: db}
2626 var err error
27+ if q.createFileStmt, err = db.PrepareContext(ctx, createFile); err != nil {
28+ return nil, fmt.Errorf("error preparing query CreateFile: %w", err)
29+ }
2730 if q.createMessageStmt, err = db.PrepareContext(ctx, createMessage); err != nil {
2831 return nil, fmt.Errorf("error preparing query CreateMessage: %w", err)
2932 }
3033 if q.createSessionStmt, err = db.PrepareContext(ctx, createSession); err != nil {
3134 return nil, fmt.Errorf("error preparing query CreateSession: %w", err)
3235 }
36+ if q.deleteFileStmt, err = db.PrepareContext(ctx, deleteFile); err != nil {
37+ return nil, fmt.Errorf("error preparing query DeleteFile: %w", err)
38+ }
3339 if q.deleteMessageStmt, err = db.PrepareContext(ctx, deleteMessage); err != nil {
3440 return nil, fmt.Errorf("error preparing query DeleteMessage: %w", err)
3541 }
3642 if q.deleteSessionStmt, err = db.PrepareContext(ctx, deleteSession); err != nil {
3743 return nil, fmt.Errorf("error preparing query DeleteSession: %w", err)
3844 }
45+ if q.deleteSessionFilesStmt, err = db.PrepareContext(ctx, deleteSessionFiles); err != nil {
46+ return nil, fmt.Errorf("error preparing query DeleteSessionFiles: %w", err)
47+ }
3948 if q.deleteSessionMessagesStmt, err = db.PrepareContext(ctx, deleteSessionMessages); err != nil {
4049 return nil, fmt.Errorf("error preparing query DeleteSessionMessages: %w", err)
4150 }
51+ if q.getFileStmt, err = db.PrepareContext(ctx, getFile); err != nil {
52+ return nil, fmt.Errorf("error preparing query GetFile: %w", err)
53+ }
54+ if q.getFileByPathAndSessionStmt, err = db.PrepareContext(ctx, getFileByPathAndSession); err != nil {
55+ return nil, fmt.Errorf("error preparing query GetFileByPathAndSession: %w", err)
56+ }
4257 if q.getMessageStmt, err = db.PrepareContext(ctx, getMessage); err != nil {
4358 return nil, fmt.Errorf("error preparing query GetMessage: %w", err)
4459 }
4560 if q.getSessionByIDStmt, err = db.PrepareContext(ctx, getSessionByID); err != nil {
4661 return nil, fmt.Errorf("error preparing query GetSessionByID: %w", err)
4762 }
63+ if q.listFilesByPathStmt, err = db.PrepareContext(ctx, listFilesByPath); err != nil {
64+ return nil, fmt.Errorf("error preparing query ListFilesByPath: %w", err)
65+ }
66+ if q.listFilesBySessionStmt, err = db.PrepareContext(ctx, listFilesBySession); err != nil {
67+ return nil, fmt.Errorf("error preparing query ListFilesBySession: %w", err)
68+ }
69+ if q.listLatestSessionFilesStmt, err = db.PrepareContext(ctx, listLatestSessionFiles); err != nil {
70+ return nil, fmt.Errorf("error preparing query ListLatestSessionFiles: %w", err)
71+ }
4872 if q.listMessagesBySessionStmt, err = db.PrepareContext(ctx, listMessagesBySession); err != nil {
4973 return nil, fmt.Errorf("error preparing query ListMessagesBySession: %w", err)
5074 }
75+ if q.listNewFilesStmt, err = db.PrepareContext(ctx, listNewFiles); err != nil {
76+ return nil, fmt.Errorf("error preparing query ListNewFiles: %w", err)
77+ }
5178 if q.listSessionsStmt, err = db.PrepareContext(ctx, listSessions); err != nil {
5279 return nil, fmt.Errorf("error preparing query ListSessions: %w", err)
5380 }
81+ if q.updateFileStmt, err = db.PrepareContext(ctx, updateFile); err != nil {
82+ return nil, fmt.Errorf("error preparing query UpdateFile: %w", err)
83+ }
5484 if q.updateMessageStmt, err = db.PrepareContext(ctx, updateMessage); err != nil {
5585 return nil, fmt.Errorf("error preparing query UpdateMessage: %w", err)
5686 }
@@ -62,6 +92,11 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
6292
6393func (q *Queries) Close() error {
6494 var err error
95+ if q.createFileStmt != nil {
96+ if cerr := q.createFileStmt.Close(); cerr != nil {
97+ err = fmt.Errorf("error closing createFileStmt: %w", cerr)
98+ }
99+ }
65100 if q.createMessageStmt != nil {
66101 if cerr := q.createMessageStmt.Close(); cerr != nil {
67102 err = fmt.Errorf("error closing createMessageStmt: %w", cerr)
@@ -72,6 +107,11 @@ func (q *Queries) Close() error {
72107 err = fmt.Errorf("error closing createSessionStmt: %w", cerr)
73108 }
74109 }
110+ if q.deleteFileStmt != nil {
111+ if cerr := q.deleteFileStmt.Close(); cerr != nil {
112+ err = fmt.Errorf("error closing deleteFileStmt: %w", cerr)
113+ }
114+ }
75115 if q.deleteMessageStmt != nil {
76116 if cerr := q.deleteMessageStmt.Close(); cerr != nil {
77117 err = fmt.Errorf("error closing deleteMessageStmt: %w", cerr)
@@ -82,11 +122,26 @@ func (q *Queries) Close() error {
82122 err = fmt.Errorf("error closing deleteSessionStmt: %w", cerr)
83123 }
84124 }
125+ if q.deleteSessionFilesStmt != nil {
126+ if cerr := q.deleteSessionFilesStmt.Close(); cerr != nil {
127+ err = fmt.Errorf("error closing deleteSessionFilesStmt: %w", cerr)
128+ }
129+ }
85130 if q.deleteSessionMessagesStmt != nil {
86131 if cerr := q.deleteSessionMessagesStmt.Close(); cerr != nil {
87132 err = fmt.Errorf("error closing deleteSessionMessagesStmt: %w", cerr)
88133 }
89134 }
135+ if q.getFileStmt != nil {
136+ if cerr := q.getFileStmt.Close(); cerr != nil {
137+ err = fmt.Errorf("error closing getFileStmt: %w", cerr)
138+ }
139+ }
140+ if q.getFileByPathAndSessionStmt != nil {
141+ if cerr := q.getFileByPathAndSessionStmt.Close(); cerr != nil {
142+ err = fmt.Errorf("error closing getFileByPathAndSessionStmt: %w", cerr)
143+ }
144+ }
90145 if q.getMessageStmt != nil {
91146 if cerr := q.getMessageStmt.Close(); cerr != nil {
92147 err = fmt.Errorf("error closing getMessageStmt: %w", cerr)
@@ -97,16 +152,41 @@ func (q *Queries) Close() error {
97152 err = fmt.Errorf("error closing getSessionByIDStmt: %w", cerr)
98153 }
99154 }
155+ if q.listFilesByPathStmt != nil {
156+ if cerr := q.listFilesByPathStmt.Close(); cerr != nil {
157+ err = fmt.Errorf("error closing listFilesByPathStmt: %w", cerr)
158+ }
159+ }
160+ if q.listFilesBySessionStmt != nil {
161+ if cerr := q.listFilesBySessionStmt.Close(); cerr != nil {
162+ err = fmt.Errorf("error closing listFilesBySessionStmt: %w", cerr)
163+ }
164+ }
165+ if q.listLatestSessionFilesStmt != nil {
166+ if cerr := q.listLatestSessionFilesStmt.Close(); cerr != nil {
167+ err = fmt.Errorf("error closing listLatestSessionFilesStmt: %w", cerr)
168+ }
169+ }
100170 if q.listMessagesBySessionStmt != nil {
101171 if cerr := q.listMessagesBySessionStmt.Close(); cerr != nil {
102172 err = fmt.Errorf("error closing listMessagesBySessionStmt: %w", cerr)
103173 }
104174 }
175+ if q.listNewFilesStmt != nil {
176+ if cerr := q.listNewFilesStmt.Close(); cerr != nil {
177+ err = fmt.Errorf("error closing listNewFilesStmt: %w", cerr)
178+ }
179+ }
105180 if q.listSessionsStmt != nil {
106181 if cerr := q.listSessionsStmt.Close(); cerr != nil {
107182 err = fmt.Errorf("error closing listSessionsStmt: %w", cerr)
108183 }
109184 }
185+ if q.updateFileStmt != nil {
186+ if cerr := q.updateFileStmt.Close(); cerr != nil {
187+ err = fmt.Errorf("error closing updateFileStmt: %w", cerr)
188+ }
189+ }
110190 if q.updateMessageStmt != nil {
111191 if cerr := q.updateMessageStmt.Close(); cerr != nil {
112192 err = fmt.Errorf("error closing updateMessageStmt: %w", cerr)
@@ -154,35 +234,55 @@ func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, ar
154234}
155235
156236type Queries struct {
157− db DBTX
158− tx *sql.Tx
159− createMessageStmt *sql.Stmt
160− createSessionStmt *sql.Stmt
161− deleteMessageStmt *sql.Stmt
162− deleteSessionStmt *sql.Stmt
163− deleteSessionMessagesStmt *sql.Stmt
164− getMessageStmt *sql.Stmt
165− getSessionByIDStmt *sql.Stmt
166− listMessagesBySessionStmt *sql.Stmt
167− listSessionsStmt *sql.Stmt
168− updateMessageStmt *sql.Stmt
169− updateSessionStmt *sql.Stmt
237+ db DBTX
238+ tx *sql.Tx
239+ createFileStmt *sql.Stmt
240+ createMessageStmt *sql.Stmt
241+ createSessionStmt *sql.Stmt
242+ deleteFileStmt *sql.Stmt
243+ deleteMessageStmt *sql.Stmt
244+ deleteSessionStmt *sql.Stmt
245+ deleteSessionFilesStmt *sql.Stmt
246+ deleteSessionMessagesStmt *sql.Stmt
247+ getFileStmt *sql.Stmt
248+ getFileByPathAndSessionStmt *sql.Stmt
249+ getMessageStmt *sql.Stmt
250+ getSessionByIDStmt *sql.Stmt
251+ listFilesByPathStmt *sql.Stmt
252+ listFilesBySessionStmt *sql.Stmt
253+ listLatestSessionFilesStmt *sql.Stmt
254+ listMessagesBySessionStmt *sql.Stmt
255+ listNewFilesStmt *sql.Stmt
256+ listSessionsStmt *sql.Stmt
257+ updateFileStmt *sql.Stmt
258+ updateMessageStmt *sql.Stmt
259+ updateSessionStmt *sql.Stmt
170260}
171261
172262func (q *Queries) WithTx(tx *sql.Tx) *Queries {
173263 return &Queries{
174− db: tx,
175− tx: tx,
176− createMessageStmt: q.createMessageStmt,
177− createSessionStmt: q.createSessionStmt,
178− deleteMessageStmt: q.deleteMessageStmt,
179− deleteSessionStmt: q.deleteSessionStmt,
180− deleteSessionMessagesStmt: q.deleteSessionMessagesStmt,
181− getMessageStmt: q.getMessageStmt,
182− getSessionByIDStmt: q.getSessionByIDStmt,
183− listMessagesBySessionStmt: q.listMessagesBySessionStmt,
184− listSessionsStmt: q.listSessionsStmt,
185− updateMessageStmt: q.updateMessageStmt,
186− updateSessionStmt: q.updateSessionStmt,
264+ db: tx,
265+ tx: tx,
266+ createFileStmt: q.createFileStmt,
267+ createMessageStmt: q.createMessageStmt,
268+ createSessionStmt: q.createSessionStmt,
269+ deleteFileStmt: q.deleteFileStmt,
270+ deleteMessageStmt: q.deleteMessageStmt,
271+ deleteSessionStmt: q.deleteSessionStmt,
272+ deleteSessionFilesStmt: q.deleteSessionFilesStmt,
273+ deleteSessionMessagesStmt: q.deleteSessionMessagesStmt,
274+ getFileStmt: q.getFileStmt,
275+ getFileByPathAndSessionStmt: q.getFileByPathAndSessionStmt,
276+ getMessageStmt: q.getMessageStmt,
277+ getSessionByIDStmt: q.getSessionByIDStmt,
278+ listFilesByPathStmt: q.listFilesByPathStmt,
279+ listFilesBySessionStmt: q.listFilesBySessionStmt,
280+ listLatestSessionFilesStmt: q.listLatestSessionFilesStmt,
281+ listMessagesBySessionStmt: q.listMessagesBySessionStmt,
282+ listNewFilesStmt: q.listNewFilesStmt,
283+ listSessionsStmt: q.listSessionsStmt,
284+ updateFileStmt: q.updateFileStmt,
285+ updateMessageStmt: q.updateMessageStmt,
286+ updateSessionStmt: q.updateSessionStmt,
187287 }
188288}
internal/db/files.sql.gonew file+311
@@ -0,0 +1,311 @@
1+// Code generated by sqlc. DO NOT EDIT.
2+// versions:
3+// sqlc v1.27.0
4+// source: files.sql
5+
6+package db
7+
8+import (
9+ "context"
10+)
11+
12+const createFile = `-- name: CreateFile :one
13+INSERT INTO files (
14+ id,
15+ session_id,
16+ path,
17+ content,
18+ version,
19+ created_at,
20+ updated_at
21+) VALUES (
22+ ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
23+)
24+RETURNING id, session_id, path, content, version, created_at, updated_at
25+`
26+
27+type CreateFileParams struct {
28+ ID string `json:"id"`
29+ SessionID string `json:"session_id"`
30+ Path string `json:"path"`
31+ Content string `json:"content"`
32+ Version string `json:"version"`
33+}
34+
35+func (q *Queries) CreateFile(ctx context.Context, arg CreateFileParams) (File, error) {
36+ row := q.queryRow(ctx, q.createFileStmt, createFile,
37+ arg.ID,
38+ arg.SessionID,
39+ arg.Path,
40+ arg.Content,
41+ arg.Version,
42+ )
43+ var i File
44+ err := row.Scan(
45+ &i.ID,
46+ &i.SessionID,
47+ &i.Path,
48+ &i.Content,
49+ &i.Version,
50+ &i.CreatedAt,
51+ &i.UpdatedAt,
52+ )
53+ return i, err
54+}
55+
56+const deleteFile = `-- name: DeleteFile :exec
57+DELETE FROM files
58+WHERE id = ?
59+`
60+
61+func (q *Queries) DeleteFile(ctx context.Context, id string) error {
62+ _, err := q.exec(ctx, q.deleteFileStmt, deleteFile, id)
63+ return err
64+}
65+
66+const deleteSessionFiles = `-- name: DeleteSessionFiles :exec
67+DELETE FROM files
68+WHERE session_id = ?
69+`
70+
71+func (q *Queries) DeleteSessionFiles(ctx context.Context, sessionID string) error {
72+ _, err := q.exec(ctx, q.deleteSessionFilesStmt, deleteSessionFiles, sessionID)
73+ return err
74+}
75+
76+const getFile = `-- name: GetFile :one
77+SELECT id, session_id, path, content, version, created_at, updated_at
78+FROM files
79+WHERE id = ? LIMIT 1
80+`
81+
82+func (q *Queries) GetFile(ctx context.Context, id string) (File, error) {
83+ row := q.queryRow(ctx, q.getFileStmt, getFile, id)
84+ var i File
85+ err := row.Scan(
86+ &i.ID,
87+ &i.SessionID,
88+ &i.Path,
89+ &i.Content,
90+ &i.Version,
91+ &i.CreatedAt,
92+ &i.UpdatedAt,
93+ )
94+ return i, err
95+}
96+
97+const getFileByPathAndSession = `-- name: GetFileByPathAndSession :one
98+SELECT id, session_id, path, content, version, created_at, updated_at
99+FROM files
100+WHERE path = ? AND session_id = ?
101+ORDER BY created_at DESC
102+LIMIT 1
103+`
104+
105+type GetFileByPathAndSessionParams struct {
106+ Path string `json:"path"`
107+ SessionID string `json:"session_id"`
108+}
109+
110+func (q *Queries) GetFileByPathAndSession(ctx context.Context, arg GetFileByPathAndSessionParams) (File, error) {
111+ row := q.queryRow(ctx, q.getFileByPathAndSessionStmt, getFileByPathAndSession, arg.Path, arg.SessionID)
112+ var i File
113+ err := row.Scan(
114+ &i.ID,
115+ &i.SessionID,
116+ &i.Path,
117+ &i.Content,
118+ &i.Version,
119+ &i.CreatedAt,
120+ &i.UpdatedAt,
121+ )
122+ return i, err
123+}
124+
125+const listFilesByPath = `-- name: ListFilesByPath :many
126+SELECT id, session_id, path, content, version, created_at, updated_at
127+FROM files
128+WHERE path = ?
129+ORDER BY created_at DESC
130+`
131+
132+func (q *Queries) ListFilesByPath(ctx context.Context, path string) ([]File, error) {
133+ rows, err := q.query(ctx, q.listFilesByPathStmt, listFilesByPath, path)
134+ if err != nil {
135+ return nil, err
136+ }
137+ defer rows.Close()
138+ items := []File{}
139+ for rows.Next() {
140+ var i File
141+ if err := rows.Scan(
142+ &i.ID,
143+ &i.SessionID,
144+ &i.Path,
145+ &i.Content,
146+ &i.Version,
147+ &i.CreatedAt,
148+ &i.UpdatedAt,
149+ ); err != nil {
150+ return nil, err
151+ }
152+ items = append(items, i)
153+ }
154+ if err := rows.Close(); err != nil {
155+ return nil, err
156+ }
157+ if err := rows.Err(); err != nil {
158+ return nil, err
159+ }
160+ return items, nil
161+}
162+
163+const listFilesBySession = `-- name: ListFilesBySession :many
164+SELECT id, session_id, path, content, version, created_at, updated_at
165+FROM files
166+WHERE session_id = ?
167+ORDER BY created_at ASC
168+`
169+
170+func (q *Queries) ListFilesBySession(ctx context.Context, sessionID string) ([]File, error) {
171+ rows, err := q.query(ctx, q.listFilesBySessionStmt, listFilesBySession, sessionID)
172+ if err != nil {
173+ return nil, err
174+ }
175+ defer rows.Close()
176+ items := []File{}
177+ for rows.Next() {
178+ var i File
179+ if err := rows.Scan(
180+ &i.ID,
181+ &i.SessionID,
182+ &i.Path,
183+ &i.Content,
184+ &i.Version,
185+ &i.CreatedAt,
186+ &i.UpdatedAt,
187+ ); err != nil {
188+ return nil, err
189+ }
190+ items = append(items, i)
191+ }
192+ if err := rows.Close(); err != nil {
193+ return nil, err
194+ }
195+ if err := rows.Err(); err != nil {
196+ return nil, err
197+ }
198+ return items, nil
199+}
200+
201+const listLatestSessionFiles = `-- name: ListLatestSessionFiles :many
202+SELECT f.id, f.session_id, f.path, f.content, f.version, f.created_at, f.updated_at
203+FROM files f
204+INNER JOIN (
205+ SELECT path, MAX(created_at) as max_created_at
206+ FROM files
207+ GROUP BY path
208+) latest ON f.path = latest.path AND f.created_at = latest.max_created_at
209+WHERE f.session_id = ?
210+ORDER BY f.path
211+`
212+
213+func (q *Queries) ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error) {
214+ rows, err := q.query(ctx, q.listLatestSessionFilesStmt, listLatestSessionFiles, sessionID)
215+ if err != nil {
216+ return nil, err
217+ }
218+ defer rows.Close()
219+ items := []File{}
220+ for rows.Next() {
221+ var i File
222+ if err := rows.Scan(
223+ &i.ID,
224+ &i.SessionID,
225+ &i.Path,
226+ &i.Content,
227+ &i.Version,
228+ &i.CreatedAt,
229+ &i.UpdatedAt,
230+ ); err != nil {
231+ return nil, err
232+ }
233+ items = append(items, i)
234+ }
235+ if err := rows.Close(); err != nil {
236+ return nil, err
237+ }
238+ if err := rows.Err(); err != nil {
239+ return nil, err
240+ }
241+ return items, nil
242+}
243+
244+const listNewFiles = `-- name: ListNewFiles :many
245+SELECT id, session_id, path, content, version, created_at, updated_at
246+FROM files
247+WHERE is_new = 1
248+ORDER BY created_at DESC
249+`
250+
251+func (q *Queries) ListNewFiles(ctx context.Context) ([]File, error) {
252+ rows, err := q.query(ctx, q.listNewFilesStmt, listNewFiles)
253+ if err != nil {
254+ return nil, err
255+ }
256+ defer rows.Close()
257+ items := []File{}
258+ for rows.Next() {
259+ var i File
260+ if err := rows.Scan(
261+ &i.ID,
262+ &i.SessionID,
263+ &i.Path,
264+ &i.Content,
265+ &i.Version,
266+ &i.CreatedAt,
267+ &i.UpdatedAt,
268+ ); err != nil {
269+ return nil, err
270+ }
271+ items = append(items, i)
272+ }
273+ if err := rows.Close(); err != nil {
274+ return nil, err
275+ }
276+ if err := rows.Err(); err != nil {
277+ return nil, err
278+ }
279+ return items, nil
280+}
281+
282+const updateFile = `-- name: UpdateFile :one
283+UPDATE files
284+SET
285+ content = ?,
286+ version = ?,
287+ updated_at = strftime('%s', 'now')
288+WHERE id = ?
289+RETURNING id, session_id, path, content, version, created_at, updated_at
290+`
291+
292+type UpdateFileParams struct {
293+ Content string `json:"content"`
294+ Version string `json:"version"`
295+ ID string `json:"id"`
296+}
297+
298+func (q *Queries) UpdateFile(ctx context.Context, arg UpdateFileParams) (File, error) {
299+ row := q.queryRow(ctx, q.updateFileStmt, updateFile, arg.Content, arg.Version, arg.ID)
300+ var i File
301+ err := row.Scan(
302+ &i.ID,
303+ &i.SessionID,
304+ &i.Path,
305+ &i.Content,
306+ &i.Version,
307+ &i.CreatedAt,
308+ &i.UpdatedAt,
309+ )
310+ return i, err
311+}
internal/db/messages.sql.go+23−11
@@ -7,6 +7,7 @@ package db
77
88import (
99 "context"
10+ "database/sql"
1011)
1112
1213const createMessage = `-- name: CreateMessage :one
@@ -15,19 +16,21 @@ INSERT INTO messages (
1516 session_id,
1617 role,
1718 parts,
19+ model,
1820 created_at,
1921 updated_at
2022) VALUES (
21− ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
23+ ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
2224)
23−RETURNING id, session_id, role, parts, created_at, updated_at
25+RETURNING id, session_id, role, parts, model, created_at, updated_at, finished_at
2426`
2527
2628type CreateMessageParams struct {
27− ID string `json:"id"`
28− SessionID string `json:"session_id"`
29− Role string `json:"role"`
30− Parts string `json:"parts"`
29+ ID string `json:"id"`
30+ SessionID string `json:"session_id"`
31+ Role string `json:"role"`
32+ Parts string `json:"parts"`
33+ Model sql.NullString `json:"model"`
3134}
3235
3336func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error) {
@@ -36,6 +39,7 @@ func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (M
3639 arg.SessionID,
3740 arg.Role,
3841 arg.Parts,
42+ arg.Model,
3943 )
4044 var i Message
4145 err := row.Scan(
@@ -43,8 +47,10 @@ func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (M
4347 &i.SessionID,
4448 &i.Role,
4549 &i.Parts,
50+ &i.Model,
4651 &i.CreatedAt,
4752 &i.UpdatedAt,
53+ &i.FinishedAt,
4854 )
4955 return i, err
5056}
@@ -70,7 +76,7 @@ func (q *Queries) DeleteSessionMessages(ctx context.Context, sessionID string) e
7076}
7177
7278const getMessage = `-- name: GetMessage :one
73−SELECT id, session_id, role, parts, created_at, updated_at
79+SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at
7480FROM messages
7581WHERE id = ? LIMIT 1
7682`
@@ -83,14 +89,16 @@ func (q *Queries) GetMessage(ctx context.Context, id string) (Message, error) {
8389 &i.SessionID,
8490 &i.Role,
8591 &i.Parts,
92+ &i.Model,
8693 &i.CreatedAt,
8794 &i.UpdatedAt,
95+ &i.FinishedAt,
8896 )
8997 return i, err
9098}
9199
92100const listMessagesBySession = `-- name: ListMessagesBySession :many
93−SELECT id, session_id, role, parts, created_at, updated_at
101+SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at
94102FROM messages
95103WHERE session_id = ?
96104ORDER BY created_at ASC
@@ -110,8 +118,10 @@ func (q *Queries) ListMessagesBySession(ctx context.Context, sessionID string) (
110118 &i.SessionID,
111119 &i.Role,
112120 &i.Parts,
121+ &i.Model,
113122 &i.CreatedAt,
114123 &i.UpdatedAt,
124+ &i.FinishedAt,
115125 ); err != nil {
116126 return nil, err
117127 }
@@ -130,16 +140,18 @@ const updateMessage = `-- name: UpdateMessage :exec
130140UPDATE messages
131141SET
132142 parts = ?,
143+ finished_at = ?,
133144 updated_at = strftime('%s', 'now')
134145WHERE id = ?
135146`
136147
137148type UpdateMessageParams struct {
138− Parts string `json:"parts"`
139− ID string `json:"id"`
149+ Parts string `json:"parts"`
150+ FinishedAt sql.NullInt64 `json:"finished_at"`
151+ ID string `json:"id"`
140152}
141153
142154func (q *Queries) UpdateMessage(ctx context.Context, arg UpdateMessageParams) error {
143− _, err := q.exec(ctx, q.updateMessageStmt, updateMessage, arg.Parts, arg.ID)
155+ _, err := q.exec(ctx, q.updateMessageStmt, updateMessage, arg.Parts, arg.FinishedAt, arg.ID)
144156 return err
145157}
internal/db/migrations/000001_initial.down.sql+2
@@ -1,8 +1,10 @@
11DROP TRIGGER IF EXISTS update_sessions_updated_at;
22DROP TRIGGER IF EXISTS update_messages_updated_at;
3+DROP TRIGGER IF EXISTS update_files_updated_at;
34
45DROP TRIGGER IF EXISTS update_session_message_count_on_delete;
56DROP TRIGGER IF EXISTS update_session_message_count_on_insert;
67
78DROP TABLE IF EXISTS sessions;
89DROP TABLE IF EXISTS messages;
10+DROP TABLE IF EXISTS files;
internal/db/migrations/000001_initial.up.sql+25
@@ -18,14 +18,39 @@ UPDATE sessions SET updated_at = strftime('%s', 'now')
1818WHERE id = new.id;
1919END;
2020
21+-- Files
22+CREATE TABLE IF NOT EXISTS files (
23+ id TEXT PRIMARY KEY,
24+ session_id TEXT NOT NULL,
25+ path TEXT NOT NULL,
26+ content TEXT NOT NULL,
27+ version TEXT NOT NULL,
28+ created_at INTEGER NOT NULL, -- Unix timestamp in milliseconds
29+ updated_at INTEGER NOT NULL, -- Unix timestamp in milliseconds
30+ FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE,
31+ UNIQUE(path, session_id, version)
32+);
33+
34+CREATE INDEX IF NOT EXISTS idx_files_session_id ON files (session_id);
35+CREATE INDEX IF NOT EXISTS idx_files_path ON files (path);
36+
37+CREATE TRIGGER IF NOT EXISTS update_files_updated_at
38+AFTER UPDATE ON files
39+BEGIN
40+UPDATE files SET updated_at = strftime('%s', 'now')
41+WHERE id = new.id;
42+END;
43+
2144-- Messages
2245CREATE TABLE IF NOT EXISTS messages (
2346 id TEXT PRIMARY KEY,
2447 session_id TEXT NOT NULL,
2548 role TEXT NOT NULL,
2649 parts TEXT NOT NULL default '[]',
50+ model TEXT,
2751 created_at INTEGER NOT NULL, -- Unix timestamp in milliseconds
2852 updated_at INTEGER NOT NULL, -- Unix timestamp in milliseconds
53+ finished_at INTEGER, -- Unix timestamp in milliseconds
2954 FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE
3055);
3156
internal/db/models.go+15−3
@@ -8,15 +8,27 @@ import (
88 "database/sql"
99)
1010
11−type Message struct {
11+type File struct {
1212 ID string `json:"id"`
1313 SessionID string `json:"session_id"`
14− Role string `json:"role"`
15− Parts string `json:"parts"`
14+ Path string `json:"path"`
15+ Content string `json:"content"`
16+ Version string `json:"version"`
1617 CreatedAt int64 `json:"created_at"`
1718 UpdatedAt int64 `json:"updated_at"`
1819}
1920
21+type Message struct {
22+ ID string `json:"id"`
23+ SessionID string `json:"session_id"`
24+ Role string `json:"role"`
25+ Parts string `json:"parts"`
26+ Model sql.NullString `json:"model"`
27+ CreatedAt int64 `json:"created_at"`
28+ UpdatedAt int64 `json:"updated_at"`
29+ FinishedAt sql.NullInt64 `json:"finished_at"`
30+}
31+
2032type Session struct {
2133 ID string `json:"id"`
2234 ParentSessionID sql.NullString `json:"parent_session_id"`
internal/db/querier.go+10
@@ -9,15 +9,25 @@ import (
99)
1010
1111type Querier interface {
12+ CreateFile(ctx context.Context, arg CreateFileParams) (File, error)
1213 CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error)
1314 CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error)
15+ DeleteFile(ctx context.Context, id string) error
1416 DeleteMessage(ctx context.Context, id string) error
1517 DeleteSession(ctx context.Context, id string) error
18+ DeleteSessionFiles(ctx context.Context, sessionID string) error
1619 DeleteSessionMessages(ctx context.Context, sessionID string) error
20+ GetFile(ctx context.Context, id string) (File, error)
21+ GetFileByPathAndSession(ctx context.Context, arg GetFileByPathAndSessionParams) (File, error)
1722 GetMessage(ctx context.Context, id string) (Message, error)
1823 GetSessionByID(ctx context.Context, id string) (Session, error)
24+ ListFilesByPath(ctx context.Context, path string) ([]File, error)
25+ ListFilesBySession(ctx context.Context, sessionID string) ([]File, error)
26+ ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error)
1927 ListMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
28+ ListNewFiles(ctx context.Context) ([]File, error)
2029 ListSessions(ctx context.Context) ([]Session, error)
30+ UpdateFile(ctx context.Context, arg UpdateFileParams) (File, error)
2131 UpdateMessage(ctx context.Context, arg UpdateMessageParams) error
2232 UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error)
2333}
internal/db/sql/files.sqlnew file+71
@@ -0,0 +1,71 @@
1+-- name: GetFile :one
2+SELECT *
3+FROM files
4+WHERE id = ? LIMIT 1;
5+
6+-- name: GetFileByPathAndSession :one
7+SELECT *
8+FROM files
9+WHERE path = ? AND session_id = ?
10+ORDER BY created_at DESC
11+LIMIT 1;
12+
13+-- name: ListFilesBySession :many
14+SELECT *
15+FROM files
16+WHERE session_id = ?
17+ORDER BY created_at ASC;
18+
19+-- name: ListFilesByPath :many
20+SELECT *
21+FROM files
22+WHERE path = ?
23+ORDER BY created_at DESC;
24+
25+-- name: CreateFile :one
26+INSERT INTO files (
27+ id,
28+ session_id,
29+ path,
30+ content,
31+ version,
32+ created_at,
33+ updated_at
34+) VALUES (
35+ ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
36+)
37+RETURNING *;
38+
39+-- name: UpdateFile :one
40+UPDATE files
41+SET
42+ content = ?,
43+ version = ?,
44+ updated_at = strftime('%s', 'now')
45+WHERE id = ?
46+RETURNING *;
47+
48+-- name: DeleteFile :exec
49+DELETE FROM files
50+WHERE id = ?;
51+
52+-- name: DeleteSessionFiles :exec
53+DELETE FROM files
54+WHERE session_id = ?;
55+
56+-- name: ListLatestSessionFiles :many
57+SELECT f.*
58+FROM files f
59+INNER JOIN (
60+ SELECT path, MAX(created_at) as max_created_at
61+ FROM files
62+ GROUP BY path
63+) latest ON f.path = latest.path AND f.created_at = latest.max_created_at
64+WHERE f.session_id = ?
65+ORDER BY f.path;
66+
67+-- name: ListNewFiles :many
68+SELECT *
69+FROM files
70+WHERE is_new = 1
71+ORDER BY created_at DESC;
internal/db/sql/messages.sql+4−1
@@ -15,10 +15,11 @@ INSERT INTO messages (
1515 session_id,
1616 role,
1717 parts,
18+ model,
1819 created_at,
1920 updated_at
2021) VALUES (
21− ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
22+ ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
2223)
2324RETURNING *;
2425
@@ -26,9 +27,11 @@ RETURNING *;
2627UPDATE messages
2728SET
2829 parts = ?,
30+ finished_at = ?,
2931 updated_at = strftime('%s', 'now')
3032WHERE id = ?;
3133
34+
3235-- name: DeleteMessage :exec
3336DELETE FROM messages
3437WHERE id = ?;
internal/diff/diff.gonew file+1047
@@ -0,0 +1,1047 @@
1+package diff
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "io"
7+ "os"
8+ "path/filepath"
9+ "regexp"
10+ "strconv"
11+ "strings"
12+ "time"
13+
14+ "github.com/alecthomas/chroma/v2"
15+ "github.com/alecthomas/chroma/v2/formatters"
16+ "github.com/alecthomas/chroma/v2/lexers"
17+ "github.com/alecthomas/chroma/v2/styles"
18+ "github.com/charmbracelet/lipgloss"
19+ "github.com/charmbracelet/x/ansi"
20+ "github.com/go-git/go-git/v5"
21+ "github.com/go-git/go-git/v5/plumbing/object"
22+ "github.com/kujtimiihoxha/opencode/internal/config"
23+ "github.com/kujtimiihoxha/opencode/internal/logging"
24+ "github.com/sergi/go-diff/diffmatchpatch"
25+)
26+
27+// -------------------------------------------------------------------------
28+// Core Types
29+// -------------------------------------------------------------------------
30+
31+// LineType represents the kind of line in a diff.
32+type LineType int
33+
34+const (
35+ LineContext LineType = iota // Line exists in both files
36+ LineAdded // Line added in the new file
37+ LineRemoved // Line removed from the old file
38+)
39+
40+// Segment represents a portion of a line for intra-line highlighting
41+type Segment struct {
42+ Start int
43+ End int
44+ Type LineType
45+ Text string
46+}
47+
48+// DiffLine represents a single line in a diff
49+type DiffLine struct {
50+ OldLineNo int // Line number in old file (0 for added lines)
51+ NewLineNo int // Line number in new file (0 for removed lines)
52+ Kind LineType // Type of line (added, removed, context)
53+ Content string // Content of the line
54+ Segments []Segment // Segments for intraline highlighting
55+}
56+
57+// Hunk represents a section of changes in a diff
58+type Hunk struct {
59+ Header string
60+ Lines []DiffLine
61+}
62+
63+// DiffResult contains the parsed result of a diff
64+type DiffResult struct {
65+ OldFile string
66+ NewFile string
67+ Hunks []Hunk
68+}
69+
70+// linePair represents a pair of lines for side-by-side display
71+type linePair struct {
72+ left *DiffLine
73+ right *DiffLine
74+}
75+
76+// -------------------------------------------------------------------------
77+// Style Configuration
78+// -------------------------------------------------------------------------
79+
80+// StyleConfig defines styling for diff rendering
81+type StyleConfig struct {
82+ ShowHeader bool
83+ ShowHunkHeader bool
84+ FileNameFg lipgloss.Color
85+ // Background colors
86+ RemovedLineBg lipgloss.Color
87+ AddedLineBg lipgloss.Color
88+ ContextLineBg lipgloss.Color
89+ HunkLineBg lipgloss.Color
90+ RemovedLineNumberBg lipgloss.Color
91+ AddedLineNamerBg lipgloss.Color
92+
93+ // Foreground colors
94+ HunkLineFg lipgloss.Color
95+ RemovedFg lipgloss.Color
96+ AddedFg lipgloss.Color
97+ LineNumberFg lipgloss.Color
98+ RemovedHighlightFg lipgloss.Color
99+ AddedHighlightFg lipgloss.Color
100+
101+ // Highlight settings
102+ HighlightStyle string
103+ RemovedHighlightBg lipgloss.Color
104+ AddedHighlightBg lipgloss.Color
105+}
106+
107+// StyleOption is a function that modifies a StyleConfig
108+type StyleOption func(*StyleConfig)
109+
110+// NewStyleConfig creates a StyleConfig with default values
111+func NewStyleConfig(opts ...StyleOption) StyleConfig {
112+ // Default color scheme
113+ config := StyleConfig{
114+ ShowHeader: true,
115+ ShowHunkHeader: true,
116+ FileNameFg: lipgloss.Color("#a0a0a0"),
117+ RemovedLineBg: lipgloss.Color("#3A3030"),
118+ AddedLineBg: lipgloss.Color("#303A30"),
119+ ContextLineBg: lipgloss.Color("#212121"),
120+ HunkLineBg: lipgloss.Color("#212121"),
121+ HunkLineFg: lipgloss.Color("#a0a0a0"),
122+ RemovedFg: lipgloss.Color("#7C4444"),
123+ AddedFg: lipgloss.Color("#478247"),
124+ LineNumberFg: lipgloss.Color("#888888"),
125+ HighlightStyle: "dracula",
126+ RemovedHighlightBg: lipgloss.Color("#612726"),
127+ AddedHighlightBg: lipgloss.Color("#256125"),
128+ RemovedLineNumberBg: lipgloss.Color("#332929"),
129+ AddedLineNamerBg: lipgloss.Color("#293229"),
130+ RemovedHighlightFg: lipgloss.Color("#FADADD"),
131+ AddedHighlightFg: lipgloss.Color("#DAFADA"),
132+ }
133+
134+ // Apply all provided options
135+ for _, opt := range opts {
136+ opt(&config)
137+ }
138+
139+ return config
140+}
141+
142+// Style option functions
143+func WithFileNameFg(color lipgloss.Color) StyleOption {
144+ return func(s *StyleConfig) { s.FileNameFg = color }
145+}
146+
147+func WithRemovedLineBg(color lipgloss.Color) StyleOption {
148+ return func(s *StyleConfig) { s.RemovedLineBg = color }
149+}
150+
151+func WithAddedLineBg(color lipgloss.Color) StyleOption {
152+ return func(s *StyleConfig) { s.AddedLineBg = color }
153+}
154+
155+func WithContextLineBg(color lipgloss.Color) StyleOption {
156+ return func(s *StyleConfig) { s.ContextLineBg = color }
157+}
158+
159+func WithRemovedFg(color lipgloss.Color) StyleOption {
160+ return func(s *StyleConfig) { s.RemovedFg = color }
161+}
162+
163+func WithAddedFg(color lipgloss.Color) StyleOption {
164+ return func(s *StyleConfig) { s.AddedFg = color }
165+}
166+
167+func WithLineNumberFg(color lipgloss.Color) StyleOption {
168+ return func(s *StyleConfig) { s.LineNumberFg = color }
169+}
170+
171+func WithHighlightStyle(style string) StyleOption {
172+ return func(s *StyleConfig) { s.HighlightStyle = style }
173+}
174+
175+func WithRemovedHighlightColors(bg, fg lipgloss.Color) StyleOption {
176+ return func(s *StyleConfig) {
177+ s.RemovedHighlightBg = bg
178+ s.RemovedHighlightFg = fg
179+ }
180+}
181+
182+func WithAddedHighlightColors(bg, fg lipgloss.Color) StyleOption {
183+ return func(s *StyleConfig) {
184+ s.AddedHighlightBg = bg
185+ s.AddedHighlightFg = fg
186+ }
187+}
188+
189+func WithRemovedLineNumberBg(color lipgloss.Color) StyleOption {
190+ return func(s *StyleConfig) { s.RemovedLineNumberBg = color }
191+}
192+
193+func WithAddedLineNumberBg(color lipgloss.Color) StyleOption {
194+ return func(s *StyleConfig) { s.AddedLineNamerBg = color }
195+}
196+
197+func WithHunkLineBg(color lipgloss.Color) StyleOption {
198+ return func(s *StyleConfig) { s.HunkLineBg = color }
199+}
200+
201+func WithHunkLineFg(color lipgloss.Color) StyleOption {
202+ return func(s *StyleConfig) { s.HunkLineFg = color }
203+}
204+
205+func WithShowHeader(show bool) StyleOption {
206+ return func(s *StyleConfig) { s.ShowHeader = show }
207+}
208+
209+func WithShowHunkHeader(show bool) StyleOption {
210+ return func(s *StyleConfig) { s.ShowHunkHeader = show }
211+}
212+
213+// -------------------------------------------------------------------------
214+// Parse Configuration
215+// -------------------------------------------------------------------------
216+
217+// ParseConfig configures the behavior of diff parsing
218+type ParseConfig struct {
219+ ContextSize int // Number of context lines to include
220+}
221+
222+// ParseOption modifies a ParseConfig
223+type ParseOption func(*ParseConfig)
224+
225+// WithContextSize sets the number of context lines to include
226+func WithContextSize(size int) ParseOption {
227+ return func(p *ParseConfig) {
228+ if size >= 0 {
229+ p.ContextSize = size
230+ }
231+ }
232+}
233+
234+// -------------------------------------------------------------------------
235+// Side-by-Side Configuration
236+// -------------------------------------------------------------------------
237+
238+// SideBySideConfig configures the rendering of side-by-side diffs
239+type SideBySideConfig struct {
240+ TotalWidth int
241+ Style StyleConfig
242+}
243+
244+// SideBySideOption modifies a SideBySideConfig
245+type SideBySideOption func(*SideBySideConfig)
246+
247+// NewSideBySideConfig creates a SideBySideConfig with default values
248+func NewSideBySideConfig(opts ...SideBySideOption) SideBySideConfig {
249+ config := SideBySideConfig{
250+ TotalWidth: 160, // Default width for side-by-side view
251+ Style: NewStyleConfig(),
252+ }
253+
254+ for _, opt := range opts {
255+ opt(&config)
256+ }
257+
258+ return config
259+}
260+
261+// WithTotalWidth sets the total width for side-by-side view
262+func WithTotalWidth(width int) SideBySideOption {
263+ return func(s *SideBySideConfig) {
264+ if width > 0 {
265+ s.TotalWidth = width
266+ }
267+ }
268+}
269+
270+// WithStyle sets the styling configuration
271+func WithStyle(style StyleConfig) SideBySideOption {
272+ return func(s *SideBySideConfig) {
273+ s.Style = style
274+ }
275+}
276+
277+// WithStyleOptions applies the specified style options
278+func WithStyleOptions(opts ...StyleOption) SideBySideOption {
279+ return func(s *SideBySideConfig) {
280+ s.Style = NewStyleConfig(opts...)
281+ }
282+}
283+
284+// -------------------------------------------------------------------------
285+// Diff Parsing
286+// -------------------------------------------------------------------------
287+
288+// ParseUnifiedDiff parses a unified diff format string into structured data
289+func ParseUnifiedDiff(diff string) (DiffResult, error) {
290+ var result DiffResult
291+ var currentHunk *Hunk
292+
293+ hunkHeaderRe := regexp.MustCompile(`^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@`)
294+ lines := strings.Split(diff, "\n")
295+
296+ var oldLine, newLine int
297+ inFileHeader := true
298+
299+ for _, line := range lines {
300+ // Parse file headers
301+ if inFileHeader {
302+ if strings.HasPrefix(line, "--- a/") {
303+ result.OldFile = strings.TrimPrefix(line, "--- a/")
304+ continue
305+ }
306+ if strings.HasPrefix(line, "+++ b/") {
307+ result.NewFile = strings.TrimPrefix(line, "+++ b/")
308+ inFileHeader = false
309+ continue
310+ }
311+ }
312+
313+ // Parse hunk headers
314+ if matches := hunkHeaderRe.FindStringSubmatch(line); matches != nil {
315+ if currentHunk != nil {
316+ result.Hunks = append(result.Hunks, *currentHunk)
317+ }
318+ currentHunk = &Hunk{
319+ Header: line,
320+ Lines: []DiffLine{},
321+ }
322+
323+ oldStart, _ := strconv.Atoi(matches[1])
324+ newStart, _ := strconv.Atoi(matches[3])
325+ oldLine = oldStart
326+ newLine = newStart
327+ continue
328+ }
329+
330+ // Ignore "No newline at end of file" markers
331+ if strings.HasPrefix(line, "\\ No newline at end of file") {
332+ continue
333+ }
334+
335+ if currentHunk == nil {
336+ continue
337+ }
338+
339+ // Process the line based on its prefix
340+ if len(line) > 0 {
341+ switch line[0] {
342+ case '+':
343+ currentHunk.Lines = append(currentHunk.Lines, DiffLine{
344+ OldLineNo: 0,
345+ NewLineNo: newLine,
346+ Kind: LineAdded,
347+ Content: line[1:],
348+ })
349+ newLine++
350+ case '-':
351+ currentHunk.Lines = append(currentHunk.Lines, DiffLine{
352+ OldLineNo: oldLine,
353+ NewLineNo: 0,
354+ Kind: LineRemoved,
355+ Content: line[1:],
356+ })
357+ oldLine++
358+ default:
359+ currentHunk.Lines = append(currentHunk.Lines, DiffLine{
360+ OldLineNo: oldLine,
361+ NewLineNo: newLine,
362+ Kind: LineContext,
363+ Content: line,
364+ })
365+ oldLine++
366+ newLine++
367+ }
368+ } else {
369+ // Handle empty lines
370+ currentHunk.Lines = append(currentHunk.Lines, DiffLine{
371+ OldLineNo: oldLine,
372+ NewLineNo: newLine,
373+ Kind: LineContext,
374+ Content: "",
375+ })
376+ oldLine++
377+ newLine++
378+ }
379+ }
380+
381+ // Add the last hunk if there is one
382+ if currentHunk != nil {
383+ result.Hunks = append(result.Hunks, *currentHunk)
384+ }
385+
386+ return result, nil
387+}
388+
389+// HighlightIntralineChanges updates lines in a hunk to show character-level differences
390+func HighlightIntralineChanges(h *Hunk, style StyleConfig) {
391+ var updated []DiffLine
392+ dmp := diffmatchpatch.New()
393+
394+ for i := 0; i < len(h.Lines); i++ {
395+ // Look for removed line followed by added line
396+ if i+1 < len(h.Lines) &&
397+ h.Lines[i].Kind == LineRemoved &&
398+ h.Lines[i+1].Kind == LineAdded {
399+
400+ oldLine := h.Lines[i]
401+ newLine := h.Lines[i+1]
402+
403+ // Find character-level differences
404+ patches := dmp.DiffMain(oldLine.Content, newLine.Content, false)
405+ patches = dmp.DiffCleanupSemantic(patches)
406+ patches = dmp.DiffCleanupMerge(patches)
407+ patches = dmp.DiffCleanupEfficiency(patches)
408+
409+ segments := make([]Segment, 0)
410+
411+ removeStart := 0
412+ addStart := 0
413+ for _, patch := range patches {
414+ switch patch.Type {
415+ case diffmatchpatch.DiffDelete:
416+ segments = append(segments, Segment{
417+ Start: removeStart,
418+ End: removeStart + len(patch.Text),
419+ Type: LineRemoved,
420+ Text: patch.Text,
421+ })
422+ removeStart += len(patch.Text)
423+ case diffmatchpatch.DiffInsert:
424+ segments = append(segments, Segment{
425+ Start: addStart,
426+ End: addStart + len(patch.Text),
427+ Type: LineAdded,
428+ Text: patch.Text,
429+ })
430+ addStart += len(patch.Text)
431+ default:
432+ // Context text, no highlighting needed
433+ removeStart += len(patch.Text)
434+ addStart += len(patch.Text)
435+ }
436+ }
437+ oldLine.Segments = segments
438+ newLine.Segments = segments
439+
440+ updated = append(updated, oldLine, newLine)
441+ i++ // Skip the next line as we've already processed it
442+ } else {
443+ updated = append(updated, h.Lines[i])
444+ }
445+ }
446+
447+ h.Lines = updated
448+}
449+
450+// pairLines converts a flat list of diff lines to pairs for side-by-side display
451+func pairLines(lines []DiffLine) []linePair {
452+ var pairs []linePair
453+ i := 0
454+
455+ for i < len(lines) {
456+ switch lines[i].Kind {
457+ case LineRemoved:
458+ // Check if the next line is an addition, if so pair them
459+ if i+1 < len(lines) && lines[i+1].Kind == LineAdded {
460+ pairs = append(pairs, linePair{left: &lines[i], right: &lines[i+1]})
461+ i += 2
462+ } else {
463+ pairs = append(pairs, linePair{left: &lines[i], right: nil})
464+ i++
465+ }
466+ case LineAdded:
467+ pairs = append(pairs, linePair{left: nil, right: &lines[i]})
468+ i++
469+ case LineContext:
470+ pairs = append(pairs, linePair{left: &lines[i], right: &lines[i]})
471+ i++
472+ }
473+ }
474+
475+ return pairs
476+}
477+
478+// -------------------------------------------------------------------------
479+// Syntax Highlighting
480+// -------------------------------------------------------------------------
481+
482+// SyntaxHighlight applies syntax highlighting to text based on file extension
483+func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg lipgloss.TerminalColor) error {
484+ // Determine the language lexer to use
485+ l := lexers.Match(fileName)
486+ if l == nil {
487+ l = lexers.Analyse(source)
488+ }
489+ if l == nil {
490+ l = lexers.Fallback
491+ }
492+ l = chroma.Coalesce(l)
493+
494+ // Get the formatter
495+ f := formatters.Get(formatter)
496+ if f == nil {
497+ f = formatters.Fallback
498+ }
499+ theme := `
500+ <style name="vscode-dark-plus">
501+ <!-- Base colors -->
502+ <entry type="Background" style="bg:#1E1E1E"/>
503+ <entry type="Text" style="#D4D4D4"/>
504+ <entry type="Other" style="#D4D4D4"/>
505+ <entry type="Error" style="#F44747"/>
506+ <!-- Keywords - using the Control flow / Special keywords color -->
507+ <entry type="Keyword" style="#C586C0"/>
508+ <entry type="KeywordConstant" style="#4FC1FF"/>
509+ <entry type="KeywordDeclaration" style="#C586C0"/>
510+ <entry type="KeywordNamespace" style="#C586C0"/>
511+ <entry type="KeywordPseudo" style="#C586C0"/>
512+ <entry type="KeywordReserved" style="#C586C0"/>
513+ <entry type="KeywordType" style="#4EC9B0"/>
514+ <!-- Names -->
515+ <entry type="Name" style="#D4D4D4"/>
516+ <entry type="NameAttribute" style="#9CDCFE"/>
517+ <entry type="NameBuiltin" style="#4EC9B0"/>
518+ <entry type="NameBuiltinPseudo" style="#9CDCFE"/>
519+ <entry type="NameClass" style="#4EC9B0"/>
520+ <entry type="NameConstant" style="#4FC1FF"/>
521+ <entry type="NameDecorator" style="#DCDCAA"/>
522+ <entry type="NameEntity" style="#9CDCFE"/>
523+ <entry type="NameException" style="#4EC9B0"/>
524+ <entry type="NameFunction" style="#DCDCAA"/>
525+ <entry type="NameLabel" style="#C8C8C8"/>
526+ <entry type="NameNamespace" style="#4EC9B0"/>
527+ <entry type="NameOther" style="#9CDCFE"/>
528+ <entry type="NameTag" style="#569CD6"/>
529+ <entry type="NameVariable" style="#9CDCFE"/>
530+ <entry type="NameVariableClass" style="#9CDCFE"/>
531+ <entry type="NameVariableGlobal" style="#9CDCFE"/>
532+ <entry type="NameVariableInstance" style="#9CDCFE"/>
533+ <!-- Literals -->
534+ <entry type="Literal" style="#CE9178"/>
535+ <entry type="LiteralDate" style="#CE9178"/>
536+ <entry type="LiteralString" style="#CE9178"/>
537+ <entry type="LiteralStringBacktick" style="#CE9178"/>
538+ <entry type="LiteralStringChar" style="#CE9178"/>
539+ <entry type="LiteralStringDoc" style="#CE9178"/>
540+ <entry type="LiteralStringDouble" style="#CE9178"/>
541+ <entry type="LiteralStringEscape" style="#d7ba7d"/>
542+ <entry type="LiteralStringHeredoc" style="#CE9178"/>
543+ <entry type="LiteralStringInterpol" style="#CE9178"/>
544+ <entry type="LiteralStringOther" style="#CE9178"/>
545+ <entry type="LiteralStringRegex" style="#d16969"/>
546+ <entry type="LiteralStringSingle" style="#CE9178"/>
547+ <entry type="LiteralStringSymbol" style="#CE9178"/>
548+ <!-- Numbers - using the numberLiteral color -->
549+ <entry type="LiteralNumber" style="#b5cea8"/>
550+ <entry type="LiteralNumberBin" style="#b5cea8"/>
551+ <entry type="LiteralNumberFloat" style="#b5cea8"/>
552+ <entry type="LiteralNumberHex" style="#b5cea8"/>
553+ <entry type="LiteralNumberInteger" style="#b5cea8"/>
554+ <entry type="LiteralNumberIntegerLong" style="#b5cea8"/>
555+ <entry type="LiteralNumberOct" style="#b5cea8"/>
556+ <!-- Operators -->
557+ <entry type="Operator" style="#D4D4D4"/>
558+ <entry type="OperatorWord" style="#C586C0"/>
559+ <entry type="Punctuation" style="#D4D4D4"/>
560+ <!-- Comments - standard VSCode Dark+ comment color -->
561+ <entry type="Comment" style="#6A9955"/>
562+ <entry type="CommentHashbang" style="#6A9955"/>
563+ <entry type="CommentMultiline" style="#6A9955"/>
564+ <entry type="CommentSingle" style="#6A9955"/>
565+ <entry type="CommentSpecial" style="#6A9955"/>
566+ <entry type="CommentPreproc" style="#C586C0"/>
567+ <!-- Generic styles -->
568+ <entry type="Generic" style="#D4D4D4"/>
569+ <entry type="GenericDeleted" style="#F44747"/>
570+ <entry type="GenericEmph" style="italic #D4D4D4"/>
571+ <entry type="GenericError" style="#F44747"/>
572+ <entry type="GenericHeading" style="bold #D4D4D4"/>
573+ <entry type="GenericInserted" style="#b5cea8"/>
574+ <entry type="GenericOutput" style="#808080"/>
575+ <entry type="GenericPrompt" style="#D4D4D4"/>
576+ <entry type="GenericStrong" style="bold #D4D4D4"/>
577+ <entry type="GenericSubheading" style="bold #D4D4D4"/>
578+ <entry type="GenericTraceback" style="#F44747"/>
579+ <entry type="GenericUnderline" style="underline"/>
580+ <entry type="TextWhitespace" style="#D4D4D4"/>
581+</style>
582+`
583+
584+ r := strings.NewReader(theme)
585+ style := chroma.MustNewXMLStyle(r)
586+ // Modify the style to use the provided background
587+ s, err := style.Builder().Transform(
588+ func(t chroma.StyleEntry) chroma.StyleEntry {
589+ r, g, b, _ := bg.RGBA()
590+ t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8))
591+ return t
592+ },
593+ ).Build()
594+ if err != nil {
595+ s = styles.Fallback
596+ }
597+
598+ // Tokenize and format
599+ it, err := l.Tokenise(nil, source)
600+ if err != nil {
601+ return err
602+ }
603+
604+ return f.Format(w, s, it)
605+}
606+
607+// highlightLine applies syntax highlighting to a single line
608+func highlightLine(fileName string, line string, bg lipgloss.TerminalColor) string {
609+ var buf bytes.Buffer
610+ err := SyntaxHighlight(&buf, line, fileName, "terminal16m", bg)
611+ if err != nil {
612+ return line
613+ }
614+ return buf.String()
615+}
616+
617+// createStyles generates the lipgloss styles needed for rendering diffs
618+func createStyles(config StyleConfig) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle lipgloss.Style) {
619+ removedLineStyle = lipgloss.NewStyle().Background(config.RemovedLineBg)
620+ addedLineStyle = lipgloss.NewStyle().Background(config.AddedLineBg)
621+ contextLineStyle = lipgloss.NewStyle().Background(config.ContextLineBg)
622+ lineNumberStyle = lipgloss.NewStyle().Foreground(config.LineNumberFg)
623+
624+ return
625+}
626+
627+// -------------------------------------------------------------------------
628+// Rendering Functions
629+// -------------------------------------------------------------------------
630+
631+// applyHighlighting applies intra-line highlighting to a piece of text
632+func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg lipgloss.Color,
633+) string {
634+ // Find all ANSI sequences in the content
635+ ansiRegex := regexp.MustCompile(`\x1b(?:[@-Z\\-_]|\[[0-9?]*(?:;[0-9?]*)*[@-~])`)
636+ ansiMatches := ansiRegex.FindAllStringIndex(content, -1)
637+
638+ // Build a mapping of visible character positions to their actual indices
639+ visibleIdx := 0
640+ ansiSequences := make(map[int]string)
641+ lastAnsiSeq := "\x1b[0m" // Default reset sequence
642+
643+ for i := 0; i < len(content); {
644+ isAnsi := false
645+ for _, match := range ansiMatches {
646+ if match[0] == i {
647+ ansiSequences[visibleIdx] = content[match[0]:match[1]]
648+ lastAnsiSeq = content[match[0]:match[1]]
649+ i = match[1]
650+ isAnsi = true
651+ break
652+ }
653+ }
654+ if isAnsi {
655+ continue
656+ }
657+
658+ // For non-ANSI positions, store the last ANSI sequence
659+ if _, exists := ansiSequences[visibleIdx]; !exists {
660+ ansiSequences[visibleIdx] = lastAnsiSeq
661+ }
662+ visibleIdx++
663+ i++
664+ }
665+
666+ // Apply highlighting
667+ var sb strings.Builder
668+ inSelection := false
669+ currentPos := 0
670+
671+ for i := 0; i < len(content); {
672+ // Check if we're at an ANSI sequence
673+ isAnsi := false
674+ for _, match := range ansiMatches {
675+ if match[0] == i {
676+ sb.WriteString(content[match[0]:match[1]]) // Preserve ANSI sequence
677+ i = match[1]
678+ isAnsi = true
679+ break
680+ }
681+ }
682+ if isAnsi {
683+ continue
684+ }
685+
686+ // Check for segment boundaries
687+ for _, seg := range segments {
688+ if seg.Type == segmentType {
689+ if currentPos == seg.Start {
690+ inSelection = true
691+ }
692+ if currentPos == seg.End {
693+ inSelection = false
694+ }
695+ }
696+ }
697+
698+ // Get current character
699+ char := string(content[i])
700+
701+ if inSelection {
702+ // Get the current styling
703+ currentStyle := ansiSequences[currentPos]
704+
705+ // Apply background highlight
706+ sb.WriteString("\x1b[48;2;")
707+ r, g, b, _ := highlightBg.RGBA()
708+ sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
709+ sb.WriteString(char)
710+ sb.WriteString("\x1b[49m") // Reset only background
711+
712+ // Reapply the original ANSI sequence
713+ sb.WriteString(currentStyle)
714+ } else {
715+ // Not in selection, just copy the character
716+ sb.WriteString(char)
717+ }
718+
719+ currentPos++
720+ i++
721+ }
722+
723+ return sb.String()
724+}
725+
726+// renderLeftColumn formats the left side of a side-by-side diff
727+func renderLeftColumn(fileName string, dl *DiffLine, colWidth int, styles StyleConfig) string {
728+ if dl == nil {
729+ contextLineStyle := lipgloss.NewStyle().Background(styles.ContextLineBg)
730+ return contextLineStyle.Width(colWidth).Render("")
731+ }
732+
733+ removedLineStyle, _, contextLineStyle, lineNumberStyle := createStyles(styles)
734+
735+ // Determine line style based on line type
736+ var marker string
737+ var bgStyle lipgloss.Style
738+ switch dl.Kind {
739+ case LineRemoved:
740+ marker = removedLineStyle.Foreground(styles.RemovedFg).Render("-")
741+ bgStyle = removedLineStyle
742+ lineNumberStyle = lineNumberStyle.Foreground(styles.RemovedFg).Background(styles.RemovedLineNumberBg)
743+ case LineAdded:
744+ marker = "?"
745+ bgStyle = contextLineStyle
746+ case LineContext:
747+ marker = contextLineStyle.Render(" ")
748+ bgStyle = contextLineStyle
749+ }
750+
751+ // Format line number
752+ lineNum := ""
753+ if dl.OldLineNo > 0 {
754+ lineNum = fmt.Sprintf("%6d", dl.OldLineNo)
755+ }
756+
757+ // Create the line prefix
758+ prefix := lineNumberStyle.Render(lineNum + " " + marker)
759+
760+ // Apply syntax highlighting
761+ content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
762+
763+ // Apply intra-line highlighting for removed lines
764+ if dl.Kind == LineRemoved && len(dl.Segments) > 0 {
765+ content = applyHighlighting(content, dl.Segments, LineRemoved, styles.RemovedHighlightBg)
766+ }
767+
768+ // Add a padding space for removed lines
769+ if dl.Kind == LineRemoved {
770+ content = bgStyle.Render(" ") + content
771+ }
772+
773+ // Create the final line and truncate if needed
774+ lineText := prefix + content
775+ return bgStyle.MaxHeight(1).Width(colWidth).Render(
776+ ansi.Truncate(
777+ lineText,
778+ colWidth,
779+ lipgloss.NewStyle().Background(styles.HunkLineBg).Foreground(styles.HunkLineFg).Render("..."),
780+ ),
781+ )
782+}
783+
784+// renderRightColumn formats the right side of a side-by-side diff
785+func renderRightColumn(fileName string, dl *DiffLine, colWidth int, styles StyleConfig) string {
786+ if dl == nil {
787+ contextLineStyle := lipgloss.NewStyle().Background(styles.ContextLineBg)
788+ return contextLineStyle.Width(colWidth).Render("")
789+ }
790+
791+ _, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(styles)
792+
793+ // Determine line style based on line type
794+ var marker string
795+ var bgStyle lipgloss.Style
796+ switch dl.Kind {
797+ case LineAdded:
798+ marker = addedLineStyle.Foreground(styles.AddedFg).Render("+")
799+ bgStyle = addedLineStyle
800+ lineNumberStyle = lineNumberStyle.Foreground(styles.AddedFg).Background(styles.AddedLineNamerBg)
801+ case LineRemoved:
802+ marker = "?"
803+ bgStyle = contextLineStyle
804+ case LineContext:
805+ marker = contextLineStyle.Render(" ")
806+ bgStyle = contextLineStyle
807+ }
808+
809+ // Format line number
810+ lineNum := ""
811+ if dl.NewLineNo > 0 {
812+ lineNum = fmt.Sprintf("%6d", dl.NewLineNo)
813+ }
814+
815+ // Create the line prefix
816+ prefix := lineNumberStyle.Render(lineNum + " " + marker)
817+
818+ // Apply syntax highlighting
819+ content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
820+
821+ // Apply intra-line highlighting for added lines
822+ if dl.Kind == LineAdded && len(dl.Segments) > 0 {
823+ content = applyHighlighting(content, dl.Segments, LineAdded, styles.AddedHighlightBg)
824+ }
825+
826+ // Add a padding space for added lines
827+ if dl.Kind == LineAdded {
828+ content = bgStyle.Render(" ") + content
829+ }
830+
831+ // Create the final line and truncate if needed
832+ lineText := prefix + content
833+ return bgStyle.MaxHeight(1).Width(colWidth).Render(
834+ ansi.Truncate(
835+ lineText,
836+ colWidth,
837+ lipgloss.NewStyle().Background(styles.HunkLineBg).Foreground(styles.HunkLineFg).Render("..."),
838+ ),
839+ )
840+}
841+
842+// -------------------------------------------------------------------------
843+// Public API
844+// -------------------------------------------------------------------------
845+
846+// RenderSideBySideHunk formats a hunk for side-by-side display
847+func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) string {
848+ // Apply options to create the configuration
849+ config := NewSideBySideConfig(opts...)
850+
851+ // Make a copy of the hunk so we don't modify the original
852+ hunkCopy := Hunk{Lines: make([]DiffLine, len(h.Lines))}
853+ copy(hunkCopy.Lines, h.Lines)
854+
855+ // Highlight changes within lines
856+ HighlightIntralineChanges(&hunkCopy, config.Style)
857+
858+ // Pair lines for side-by-side display
859+ pairs := pairLines(hunkCopy.Lines)
860+
861+ // Calculate column width
862+ colWidth := config.TotalWidth / 2
863+
864+ leftWidth := colWidth
865+ rightWidth := config.TotalWidth - colWidth
866+ var sb strings.Builder
867+ for _, p := range pairs {
868+ leftStr := renderLeftColumn(fileName, p.left, leftWidth, config.Style)
869+ rightStr := renderRightColumn(fileName, p.right, rightWidth, config.Style)
870+ sb.WriteString(leftStr + rightStr + "\n")
871+ }
872+
873+ return sb.String()
874+}
875+
876+// FormatDiff creates a side-by-side formatted view of a diff
877+func FormatDiff(diffText string, opts ...SideBySideOption) (string, error) {
878+ diffResult, err := ParseUnifiedDiff(diffText)
879+ if err != nil {
880+ return "", err
881+ }
882+
883+ var sb strings.Builder
884+ config := NewSideBySideConfig(opts...)
885+
886+ if config.Style.ShowHeader {
887+ removeIcon := lipgloss.NewStyle().
888+ Background(config.Style.RemovedLineBg).
889+ Foreground(config.Style.RemovedFg).
890+ Render("⏹")
891+ addIcon := lipgloss.NewStyle().
892+ Background(config.Style.AddedLineBg).
893+ Foreground(config.Style.AddedFg).
894+ Render("⏹")
895+
896+ fileName := lipgloss.NewStyle().
897+ Background(config.Style.ContextLineBg).
898+ Foreground(config.Style.FileNameFg).
899+ Render(" " + diffResult.OldFile)
900+ sb.WriteString(
901+ lipgloss.NewStyle().
902+ Background(config.Style.ContextLineBg).
903+ Padding(0, 1, 0, 1).
904+ Foreground(config.Style.FileNameFg).
905+ BorderStyle(lipgloss.NormalBorder()).
906+ BorderTop(true).
907+ BorderBottom(true).
908+ BorderForeground(config.Style.FileNameFg).
909+ BorderBackground(config.Style.ContextLineBg).
910+ Width(config.TotalWidth).
911+ Render(
912+ lipgloss.JoinHorizontal(lipgloss.Top,
913+ removeIcon,
914+ addIcon,
915+ fileName,
916+ ),
917+ ) + "\n",
918+ )
919+ }
920+
921+ for _, h := range diffResult.Hunks {
922+ // Render hunk header
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+ }
932+ sb.WriteString(RenderSideBySideHunk(diffResult.OldFile, h, opts...))
933+ }
934+
935+ return sb.String(), nil
936+}
937+
938+// GenerateDiff creates a unified diff from two file contents
939+func GenerateDiff(beforeContent, afterContent, fileName string) (string, int, int) {
940+ // remove the cwd prefix and ensure consistent path format
941+ // this prevents issues with absolute paths in different environments
942+ cwd := config.WorkingDirectory()
943+ fileName = strings.TrimPrefix(fileName, cwd)
944+ fileName = strings.TrimPrefix(fileName, "/")
945+ // Create temporary directory for git operations
946+ tempDir, err := os.MkdirTemp("", fmt.Sprintf("git-diff-%d", time.Now().UnixNano()))
947+ if err != nil {
948+ logging.Error("Failed to create temp directory for git diff", "error", err)
949+ return "", 0, 0
950+ }
951+ defer os.RemoveAll(tempDir)
952+
953+ // Initialize git repo
954+ repo, err := git.PlainInit(tempDir, false)
955+ if err != nil {
956+ logging.Error("Failed to initialize git repository", "error", err)
957+ return "", 0, 0
958+ }
959+
960+ wt, err := repo.Worktree()
961+ if err != nil {
962+ logging.Error("Failed to get git worktree", "error", err)
963+ return "", 0, 0
964+ }
965+
966+ // Write the "before" content and commit it
967+ fullPath := filepath.Join(tempDir, fileName)
968+ if err = os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
969+ logging.Error("Failed to create directory for file", "error", err)
970+ return "", 0, 0
971+ }
972+ if err = os.WriteFile(fullPath, []byte(beforeContent), 0o644); err != nil {
973+ logging.Error("Failed to write before content to file", "error", err)
974+ return "", 0, 0
975+ }
976+
977+ _, err = wt.Add(fileName)
978+ if err != nil {
979+ logging.Error("Failed to add file to git", "error", err)
980+ return "", 0, 0
981+ }
982+
983+ beforeCommit, err := wt.Commit("Before", &git.CommitOptions{
984+ Author: &object.Signature{
985+ Name: "OpenCode",
986+ Email: "coder@opencode.ai",
987+ When: time.Now(),
988+ },
989+ })
990+ if err != nil {
991+ logging.Error("Failed to commit before content", "error", err)
992+ return "", 0, 0
993+ }
994+
995+ // Write the "after" content and commit it
996+ if err = os.WriteFile(fullPath, []byte(afterContent), 0o644); err != nil {
997+ logging.Error("Failed to write after content to file", "error", err)
998+ return "", 0, 0
999+ }
1000+
1001+ _, err = wt.Add(fileName)
1002+ if err != nil {
1003+ logging.Error("Failed to add file to git", "error", err)
1004+ return "", 0, 0
1005+ }
1006+
1007+ afterCommit, err := wt.Commit("After", &git.CommitOptions{
1008+ Author: &object.Signature{
1009+ Name: "OpenCode",
1010+ Email: "coder@opencode.ai",
1011+ When: time.Now(),
1012+ },
1013+ })
1014+ if err != nil {
1015+ logging.Error("Failed to commit after content", "error", err)
1016+ return "", 0, 0
1017+ }
1018+
1019+ // Get the diff between the two commits
1020+ beforeCommitObj, err := repo.CommitObject(beforeCommit)
1021+ if err != nil {
1022+ logging.Error("Failed to get before commit object", "error", err)
1023+ return "", 0, 0
1024+ }
1025+
1026+ afterCommitObj, err := repo.CommitObject(afterCommit)
1027+ if err != nil {
1028+ logging.Error("Failed to get after commit object", "error", err)
1029+ return "", 0, 0
1030+ }
1031+
1032+ patch, err := beforeCommitObj.Patch(afterCommitObj)
1033+ if err != nil {
1034+ logging.Error("Failed to create git diff patch", "error", err)
1035+ return "", 0, 0
1036+ }
1037+
1038+ // Count additions and removals
1039+ additions := 0
1040+ removals := 0
1041+ for _, fileStat := range patch.Stats() {
1042+ additions += fileStat.Addition
1043+ removals += fileStat.Deletion
1044+ }
1045+
1046+ return patch.String(), additions, removals
1047+}
internal/diff/patch.gonew file+740
@@ -0,0 +1,740 @@
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+ for _, prefix := range prefixes {
95+ if strings.HasPrefix(p.lines[p.index], prefix) {
96+ return true
97+ }
98+ }
99+ return false
100+}
101+
102+func (p *Parser) startsWith(prefix any) bool {
103+ var prefixes []string
104+ switch v := prefix.(type) {
105+ case string:
106+ prefixes = []string{v}
107+ case []string:
108+ prefixes = v
109+ }
110+
111+ for _, pfx := range prefixes {
112+ if strings.HasPrefix(p.lines[p.index], pfx) {
113+ return true
114+ }
115+ }
116+ return false
117+}
118+
119+func (p *Parser) readStr(prefix string, returnEverything bool) string {
120+ if p.index >= len(p.lines) {
121+ return "" // Changed from panic to return empty string for safer operation
122+ }
123+ if strings.HasPrefix(p.lines[p.index], prefix) {
124+ var text string
125+ if returnEverything {
126+ text = p.lines[p.index]
127+ } else {
128+ text = p.lines[p.index][len(prefix):]
129+ }
130+ p.index++
131+ return text
132+ }
133+ return ""
134+}
135+
136+func (p *Parser) Parse() error {
137+ endPatchPrefixes := []string{"*** End Patch"}
138+
139+ for !p.isDone(endPatchPrefixes) {
140+ path := p.readStr("*** Update File: ", false)
141+ if path != "" {
142+ if _, exists := p.patch.Actions[path]; exists {
143+ return fileError("Update", "Duplicate Path", path)
144+ }
145+ moveTo := p.readStr("*** Move to: ", false)
146+ if _, exists := p.currentFiles[path]; !exists {
147+ return fileError("Update", "Missing File", path)
148+ }
149+ text := p.currentFiles[path]
150+ action, err := p.parseUpdateFile(text)
151+ if err != nil {
152+ return err
153+ }
154+ if moveTo != "" {
155+ action.MovePath = &moveTo
156+ }
157+ p.patch.Actions[path] = action
158+ continue
159+ }
160+
161+ path = p.readStr("*** Delete File: ", false)
162+ if path != "" {
163+ if _, exists := p.patch.Actions[path]; exists {
164+ return fileError("Delete", "Duplicate Path", path)
165+ }
166+ if _, exists := p.currentFiles[path]; !exists {
167+ return fileError("Delete", "Missing File", path)
168+ }
169+ p.patch.Actions[path] = PatchAction{Type: ActionDelete, Chunks: []Chunk{}}
170+ continue
171+ }
172+
173+ path = p.readStr("*** Add File: ", false)
174+ if path != "" {
175+ if _, exists := p.patch.Actions[path]; exists {
176+ return fileError("Add", "Duplicate Path", path)
177+ }
178+ if _, exists := p.currentFiles[path]; exists {
179+ return fileError("Add", "File already exists", path)
180+ }
181+ action, err := p.parseAddFile()
182+ if err != nil {
183+ return err
184+ }
185+ p.patch.Actions[path] = action
186+ continue
187+ }
188+
189+ return NewDiffError(fmt.Sprintf("Unknown Line: %s", p.lines[p.index]))
190+ }
191+
192+ if !p.startsWith("*** End Patch") {
193+ return NewDiffError("Missing End Patch")
194+ }
195+ p.index++
196+
197+ return nil
198+}
199+
200+func (p *Parser) parseUpdateFile(text string) (PatchAction, error) {
201+ action := PatchAction{Type: ActionUpdate, Chunks: []Chunk{}}
202+ fileLines := strings.Split(text, "\n")
203+ index := 0
204+
205+ endPrefixes := []string{
206+ "*** End Patch",
207+ "*** Update File:",
208+ "*** Delete File:",
209+ "*** Add File:",
210+ "*** End of File",
211+ }
212+
213+ for !p.isDone(endPrefixes) {
214+ defStr := p.readStr("@@ ", false)
215+ sectionStr := ""
216+ if defStr == "" && p.index < len(p.lines) && p.lines[p.index] == "@@" {
217+ sectionStr = p.lines[p.index]
218+ p.index++
219+ }
220+ if defStr == "" && sectionStr == "" && index != 0 {
221+ return action, NewDiffError(fmt.Sprintf("Invalid Line:\n%s", p.lines[p.index]))
222+ }
223+ if strings.TrimSpace(defStr) != "" {
224+ found := false
225+ for i := range fileLines[:index] {
226+ if fileLines[i] == defStr {
227+ found = true
228+ break
229+ }
230+ }
231+
232+ if !found {
233+ for i := index; i < len(fileLines); i++ {
234+ if fileLines[i] == defStr {
235+ index = i + 1
236+ found = true
237+ break
238+ }
239+ }
240+ }
241+
242+ if !found {
243+ for i := range fileLines[:index] {
244+ if strings.TrimSpace(fileLines[i]) == strings.TrimSpace(defStr) {
245+ found = true
246+ break
247+ }
248+ }
249+ }
250+
251+ if !found {
252+ for i := index; i < len(fileLines); i++ {
253+ if strings.TrimSpace(fileLines[i]) == strings.TrimSpace(defStr) {
254+ index = i + 1
255+ p.fuzz++
256+ found = true
257+ break
258+ }
259+ }
260+ }
261+ }
262+
263+ nextChunkContext, chunks, endPatchIndex, eof := peekNextSection(p.lines, p.index)
264+ newIndex, fuzz := findContext(fileLines, nextChunkContext, index, eof)
265+ if newIndex == -1 {
266+ ctxText := strings.Join(nextChunkContext, "\n")
267+ return action, contextError(index, ctxText, eof)
268+ }
269+ p.fuzz += fuzz
270+
271+ for _, ch := range chunks {
272+ ch.OrigIndex += newIndex
273+ action.Chunks = append(action.Chunks, ch)
274+ }
275+ index = newIndex + len(nextChunkContext)
276+ p.index = endPatchIndex
277+ }
278+ return action, nil
279+}
280+
281+func (p *Parser) parseAddFile() (PatchAction, error) {
282+ lines := make([]string, 0, 16) // Preallocate space for better performance
283+ endPrefixes := []string{
284+ "*** End Patch",
285+ "*** Update File:",
286+ "*** Delete File:",
287+ "*** Add File:",
288+ }
289+
290+ for !p.isDone(endPrefixes) {
291+ s := p.readStr("", true)
292+ if !strings.HasPrefix(s, "+") {
293+ return PatchAction{}, NewDiffError(fmt.Sprintf("Invalid Add File Line: %s", s))
294+ }
295+ lines = append(lines, s[1:])
296+ }
297+
298+ newFile := strings.Join(lines, "\n")
299+ return PatchAction{
300+ Type: ActionAdd,
301+ NewFile: &newFile,
302+ Chunks: []Chunk{},
303+ }, nil
304+}
305+
306+// Refactored to use a matcher function for each comparison type
307+func findContextCore(lines []string, context []string, start int) (int, int) {
308+ if len(context) == 0 {
309+ return start, 0
310+ }
311+
312+ // Try exact match
313+ if idx, fuzz := tryFindMatch(lines, context, start, func(a, b string) bool {
314+ return a == b
315+ }); idx >= 0 {
316+ return idx, fuzz
317+ }
318+
319+ // Try trimming right whitespace
320+ if idx, fuzz := tryFindMatch(lines, context, start, func(a, b string) bool {
321+ return strings.TrimRight(a, " \t") == strings.TrimRight(b, " \t")
322+ }); idx >= 0 {
323+ return idx, fuzz
324+ }
325+
326+ // Try trimming all whitespace
327+ if idx, fuzz := tryFindMatch(lines, context, start, func(a, b string) bool {
328+ return strings.TrimSpace(a) == strings.TrimSpace(b)
329+ }); idx >= 0 {
330+ return idx, fuzz
331+ }
332+
333+ return -1, 0
334+}
335+
336+// Helper function to DRY up the match logic
337+func tryFindMatch(lines []string, context []string, start int,
338+ compareFunc func(string, string) bool,
339+) (int, int) {
340+ for i := start; i < len(lines); i++ {
341+ if i+len(context) <= len(lines) {
342+ match := true
343+ for j := range context {
344+ if !compareFunc(lines[i+j], context[j]) {
345+ match = false
346+ break
347+ }
348+ }
349+ if match {
350+ // Return fuzz level: 0 for exact, 1 for trimRight, 100 for trimSpace
351+ var fuzz int
352+ if compareFunc("a ", "a") && !compareFunc("a", "b") {
353+ fuzz = 1
354+ } else if compareFunc("a ", "a") {
355+ fuzz = 100
356+ }
357+ return i, fuzz
358+ }
359+ }
360+ }
361+ return -1, 0
362+}
363+
364+func findContext(lines []string, context []string, start int, eof bool) (int, int) {
365+ if eof {
366+ newIndex, fuzz := findContextCore(lines, context, len(lines)-len(context))
367+ if newIndex != -1 {
368+ return newIndex, fuzz
369+ }
370+ newIndex, fuzz = findContextCore(lines, context, start)
371+ return newIndex, fuzz + 10000
372+ }
373+ return findContextCore(lines, context, start)
374+}
375+
376+func peekNextSection(lines []string, initialIndex int) ([]string, []Chunk, int, bool) {
377+ index := initialIndex
378+ old := make([]string, 0, 32) // Preallocate for better performance
379+ delLines := make([]string, 0, 8)
380+ insLines := make([]string, 0, 8)
381+ chunks := make([]Chunk, 0, 4)
382+ mode := "keep"
383+
384+ // End conditions for the section
385+ endSectionConditions := func(s string) bool {
386+ return strings.HasPrefix(s, "@@") ||
387+ strings.HasPrefix(s, "*** End Patch") ||
388+ strings.HasPrefix(s, "*** Update File:") ||
389+ strings.HasPrefix(s, "*** Delete File:") ||
390+ strings.HasPrefix(s, "*** Add File:") ||
391+ strings.HasPrefix(s, "*** End of File") ||
392+ s == "***" ||
393+ strings.HasPrefix(s, "***")
394+ }
395+
396+ for index < len(lines) {
397+ s := lines[index]
398+ if endSectionConditions(s) {
399+ break
400+ }
401+ index++
402+ lastMode := mode
403+ line := s
404+
405+ if len(line) > 0 {
406+ switch line[0] {
407+ case '+':
408+ mode = "add"
409+ case '-':
410+ mode = "delete"
411+ case ' ':
412+ mode = "keep"
413+ default:
414+ mode = "keep"
415+ line = " " + line
416+ }
417+ } else {
418+ mode = "keep"
419+ line = " "
420+ }
421+
422+ line = line[1:]
423+ if mode == "keep" && lastMode != mode {
424+ if len(insLines) > 0 || len(delLines) > 0 {
425+ chunks = append(chunks, Chunk{
426+ OrigIndex: len(old) - len(delLines),
427+ DelLines: delLines,
428+ InsLines: insLines,
429+ })
430+ }
431+ delLines = make([]string, 0, 8)
432+ insLines = make([]string, 0, 8)
433+ }
434+ switch mode {
435+ case "delete":
436+ delLines = append(delLines, line)
437+ old = append(old, line)
438+ case "add":
439+ insLines = append(insLines, line)
440+ default:
441+ old = append(old, line)
442+ }
443+ }
444+
445+ if len(insLines) > 0 || len(delLines) > 0 {
446+ chunks = append(chunks, Chunk{
447+ OrigIndex: len(old) - len(delLines),
448+ DelLines: delLines,
449+ InsLines: insLines,
450+ })
451+ }
452+
453+ if index < len(lines) && lines[index] == "*** End of File" {
454+ index++
455+ return old, chunks, index, true
456+ }
457+ return old, chunks, index, false
458+}
459+
460+func TextToPatch(text string, orig map[string]string) (Patch, int, error) {
461+ text = strings.TrimSpace(text)
462+ lines := strings.Split(text, "\n")
463+ if len(lines) < 2 || !strings.HasPrefix(lines[0], "*** Begin Patch") || lines[len(lines)-1] != "*** End Patch" {
464+ return Patch{}, 0, NewDiffError("Invalid patch text")
465+ }
466+ parser := NewParser(orig, lines)
467+ parser.index = 1
468+ if err := parser.Parse(); err != nil {
469+ return Patch{}, 0, err
470+ }
471+ return parser.patch, parser.fuzz, nil
472+}
473+
474+func IdentifyFilesNeeded(text string) []string {
475+ text = strings.TrimSpace(text)
476+ lines := strings.Split(text, "\n")
477+ result := make(map[string]bool)
478+
479+ for _, line := range lines {
480+ if strings.HasPrefix(line, "*** Update File: ") {
481+ result[line[len("*** Update File: "):]] = true
482+ }
483+ if strings.HasPrefix(line, "*** Delete File: ") {
484+ result[line[len("*** Delete File: "):]] = true
485+ }
486+ }
487+
488+ files := make([]string, 0, len(result))
489+ for file := range result {
490+ files = append(files, file)
491+ }
492+ return files
493+}
494+
495+func IdentifyFilesAdded(text string) []string {
496+ text = strings.TrimSpace(text)
497+ lines := strings.Split(text, "\n")
498+ result := make(map[string]bool)
499+
500+ for _, line := range lines {
501+ if strings.HasPrefix(line, "*** Add File: ") {
502+ result[line[len("*** Add File: "):]] = true
503+ }
504+ }
505+
506+ files := make([]string, 0, len(result))
507+ for file := range result {
508+ files = append(files, file)
509+ }
510+ return files
511+}
512+
513+func getUpdatedFile(text string, action PatchAction, path string) (string, error) {
514+ if action.Type != ActionUpdate {
515+ return "", errors.New("expected UPDATE action")
516+ }
517+ origLines := strings.Split(text, "\n")
518+ destLines := make([]string, 0, len(origLines)) // Preallocate with capacity
519+ origIndex := 0
520+
521+ for _, chunk := range action.Chunks {
522+ if chunk.OrigIndex > len(origLines) {
523+ return "", NewDiffError(fmt.Sprintf("%s: chunk.orig_index %d > len(lines) %d", path, chunk.OrigIndex, len(origLines)))
524+ }
525+ if origIndex > chunk.OrigIndex {
526+ return "", NewDiffError(fmt.Sprintf("%s: orig_index %d > chunk.orig_index %d", path, origIndex, chunk.OrigIndex))
527+ }
528+ destLines = append(destLines, origLines[origIndex:chunk.OrigIndex]...)
529+ delta := chunk.OrigIndex - origIndex
530+ origIndex += delta
531+
532+ if len(chunk.InsLines) > 0 {
533+ destLines = append(destLines, chunk.InsLines...)
534+ }
535+ origIndex += len(chunk.DelLines)
536+ }
537+
538+ destLines = append(destLines, origLines[origIndex:]...)
539+ return strings.Join(destLines, "\n"), nil
540+}
541+
542+func PatchToCommit(patch Patch, orig map[string]string) (Commit, error) {
543+ commit := Commit{Changes: make(map[string]FileChange, len(patch.Actions))}
544+ for pathKey, action := range patch.Actions {
545+ switch action.Type {
546+ case ActionDelete:
547+ oldContent := orig[pathKey]
548+ commit.Changes[pathKey] = FileChange{
549+ Type: ActionDelete,
550+ OldContent: &oldContent,
551+ }
552+ case ActionAdd:
553+ commit.Changes[pathKey] = FileChange{
554+ Type: ActionAdd,
555+ NewContent: action.NewFile,
556+ }
557+ case 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+ switch change.Type {
623+ case ActionDelete:
624+ if err := removeFn(p); err != nil {
625+ return err
626+ }
627+ case ActionAdd:
628+ if change.NewContent == nil {
629+ return NewDiffError(fmt.Sprintf("Add action for %s has nil new_content", p))
630+ }
631+ if err := writeFn(p, *change.NewContent); err != nil {
632+ return err
633+ }
634+ case ActionUpdate:
635+ if change.NewContent == nil {
636+ return NewDiffError(fmt.Sprintf("Update action for %s has nil new_content", p))
637+ }
638+ if change.MovePath != nil {
639+ if err := writeFn(*change.MovePath, *change.NewContent); err != nil {
640+ return err
641+ }
642+ if err := removeFn(p); err != nil {
643+ return err
644+ }
645+ } else {
646+ if err := writeFn(p, *change.NewContent); err != nil {
647+ return err
648+ }
649+ }
650+ }
651+ }
652+ return nil
653+}
654+
655+func ProcessPatch(text string, openFn func(string) (string, error), writeFn func(string, string) error, removeFn func(string) error) (string, error) {
656+ if !strings.HasPrefix(text, "*** Begin Patch") {
657+ return "", NewDiffError("Patch must start with *** Begin Patch")
658+ }
659+ paths := IdentifyFilesNeeded(text)
660+ orig, err := LoadFiles(paths, openFn)
661+ if err != nil {
662+ return "", err
663+ }
664+
665+ patch, fuzz, err := TextToPatch(text, orig)
666+ if err != nil {
667+ return "", err
668+ }
669+
670+ if fuzz > 0 {
671+ return "", NewDiffError(fmt.Sprintf("Patch contains fuzzy matches (fuzz level: %d)", fuzz))
672+ }
673+
674+ commit, err := PatchToCommit(patch, orig)
675+ if err != nil {
676+ return "", err
677+ }
678+
679+ if err := ApplyCommit(commit, writeFn, removeFn); err != nil {
680+ return "", err
681+ }
682+
683+ return "Patch applied successfully", nil
684+}
685+
686+func OpenFile(p string) (string, error) {
687+ data, err := os.ReadFile(p)
688+ if err != nil {
689+ return "", err
690+ }
691+ return string(data), nil
692+}
693+
694+func WriteFile(p string, content string) error {
695+ if filepath.IsAbs(p) {
696+ return NewDiffError("We do not support absolute paths.")
697+ }
698+
699+ dir := filepath.Dir(p)
700+ if dir != "." {
701+ if err := os.MkdirAll(dir, 0o755); err != nil {
702+ return err
703+ }
704+ }
705+
706+ return os.WriteFile(p, []byte(content), 0o644)
707+}
708+
709+func RemoveFile(p string) error {
710+ return os.Remove(p)
711+}
712+
713+func ValidatePatch(patchText string, files map[string]string) (bool, string, error) {
714+ if !strings.HasPrefix(patchText, "*** Begin Patch") {
715+ return false, "Patch must start with *** Begin Patch", nil
716+ }
717+
718+ neededFiles := IdentifyFilesNeeded(patchText)
719+ for _, filePath := range neededFiles {
720+ if _, exists := files[filePath]; !exists {
721+ return false, fmt.Sprintf("File not found: %s", filePath), nil
722+ }
723+ }
724+
725+ patch, fuzz, err := TextToPatch(patchText, files)
726+ if err != nil {
727+ return false, err.Error(), nil
728+ }
729+
730+ if fuzz > 0 {
731+ return false, fmt.Sprintf("Patch contains fuzzy matches (fuzz level: %d)", fuzz), nil
732+ }
733+
734+ _, err = PatchToCommit(patch, files)
735+ if err != nil {
736+ return false, err.Error(), nil
737+ }
738+
739+ return true, "Patch is valid", nil
740+}
internal/history/file.gonew file+252
@@ -0,0 +1,252 @@
1+package history
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "fmt"
7+ "strconv"
8+ "strings"
9+ "time"
10+
11+ "github.com/google/uuid"
12+ "github.com/kujtimiihoxha/opencode/internal/db"
13+ "github.com/kujtimiihoxha/opencode/internal/pubsub"
14+)
15+
16+const (
17+ InitialVersion = "initial"
18+)
19+
20+type File struct {
21+ ID string
22+ SessionID string
23+ Path string
24+ Content string
25+ Version string
26+ CreatedAt int64
27+ UpdatedAt int64
28+}
29+
30+type Service interface {
31+ pubsub.Suscriber[File]
32+ Create(ctx context.Context, sessionID, path, content string) (File, error)
33+ CreateVersion(ctx context.Context, sessionID, path, content string) (File, error)
34+ Get(ctx context.Context, id string) (File, error)
35+ GetByPathAndSession(ctx context.Context, path, sessionID string) (File, error)
36+ ListBySession(ctx context.Context, sessionID string) ([]File, error)
37+ ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error)
38+ Update(ctx context.Context, file File) (File, error)
39+ Delete(ctx context.Context, id string) error
40+ DeleteSessionFiles(ctx context.Context, sessionID string) error
41+}
42+
43+type service struct {
44+ *pubsub.Broker[File]
45+ db *sql.DB
46+ q *db.Queries
47+}
48+
49+func NewService(q *db.Queries, db *sql.DB) Service {
50+ return &service{
51+ Broker: pubsub.NewBroker[File](),
52+ q: q,
53+ db: db,
54+ }
55+}
56+
57+func (s *service) Create(ctx context.Context, sessionID, path, content string) (File, error) {
58+ return s.createWithVersion(ctx, sessionID, path, content, InitialVersion)
59+}
60+
61+func (s *service) CreateVersion(ctx context.Context, sessionID, path, content string) (File, error) {
62+ // Get the latest version for this path
63+ files, err := s.q.ListFilesByPath(ctx, path)
64+ if err != nil {
65+ return File{}, err
66+ }
67+
68+ if len(files) == 0 {
69+ // No previous versions, create initial
70+ return s.Create(ctx, sessionID, path, content)
71+ }
72+
73+ // Get the latest version
74+ latestFile := files[0] // Files are ordered by created_at DESC
75+ latestVersion := latestFile.Version
76+
77+ // Generate the next version
78+ var nextVersion string
79+ if latestVersion == InitialVersion {
80+ nextVersion = "v1"
81+ } else if strings.HasPrefix(latestVersion, "v") {
82+ versionNum, err := strconv.Atoi(latestVersion[1:])
83+ if err != nil {
84+ // If we can't parse the version, just use a timestamp-based version
85+ nextVersion = fmt.Sprintf("v%d", latestFile.CreatedAt)
86+ } else {
87+ nextVersion = fmt.Sprintf("v%d", versionNum+1)
88+ }
89+ } else {
90+ // If the version format is unexpected, use a timestamp-based version
91+ nextVersion = fmt.Sprintf("v%d", latestFile.CreatedAt)
92+ }
93+
94+ return s.createWithVersion(ctx, sessionID, path, content, nextVersion)
95+}
96+
97+func (s *service) createWithVersion(ctx context.Context, sessionID, path, content, version string) (File, error) {
98+ // Maximum number of retries for transaction conflicts
99+ const maxRetries = 3
100+ var file File
101+ var err error
102+
103+ // Retry loop for transaction conflicts
104+ for attempt := range maxRetries {
105+ // Start a transaction
106+ tx, txErr := s.db.Begin()
107+ if txErr != nil {
108+ return File{}, fmt.Errorf("failed to begin transaction: %w", txErr)
109+ }
110+
111+ // Create a new queries instance with the transaction
112+ qtx := s.q.WithTx(tx)
113+
114+ // Try to create the file within the transaction
115+ dbFile, txErr := qtx.CreateFile(ctx, db.CreateFileParams{
116+ ID: uuid.New().String(),
117+ SessionID: sessionID,
118+ Path: path,
119+ Content: content,
120+ Version: version,
121+ })
122+ if txErr != nil {
123+ // Rollback the transaction
124+ tx.Rollback()
125+
126+ // Check if this is a uniqueness constraint violation
127+ if strings.Contains(txErr.Error(), "UNIQUE constraint failed") {
128+ if attempt < maxRetries-1 {
129+ // If we have retries left, generate a new version and try again
130+ if strings.HasPrefix(version, "v") {
131+ versionNum, parseErr := strconv.Atoi(version[1:])
132+ if parseErr == nil {
133+ version = fmt.Sprintf("v%d", versionNum+1)
134+ continue
135+ }
136+ }
137+ // If we can't parse the version, use a timestamp-based version
138+ version = fmt.Sprintf("v%d", time.Now().Unix())
139+ continue
140+ }
141+ }
142+ return File{}, txErr
143+ }
144+
145+ // Commit the transaction
146+ if txErr = tx.Commit(); txErr != nil {
147+ return File{}, fmt.Errorf("failed to commit transaction: %w", txErr)
148+ }
149+
150+ file = s.fromDBItem(dbFile)
151+ s.Publish(pubsub.CreatedEvent, file)
152+ return file, nil
153+ }
154+
155+ return file, err
156+}
157+
158+func (s *service) Get(ctx context.Context, id string) (File, error) {
159+ dbFile, err := s.q.GetFile(ctx, id)
160+ if err != nil {
161+ return File{}, err
162+ }
163+ return s.fromDBItem(dbFile), nil
164+}
165+
166+func (s *service) GetByPathAndSession(ctx context.Context, path, sessionID string) (File, error) {
167+ dbFile, err := s.q.GetFileByPathAndSession(ctx, db.GetFileByPathAndSessionParams{
168+ Path: path,
169+ SessionID: sessionID,
170+ })
171+ if err != nil {
172+ return File{}, err
173+ }
174+ return s.fromDBItem(dbFile), nil
175+}
176+
177+func (s *service) ListBySession(ctx context.Context, sessionID string) ([]File, error) {
178+ dbFiles, err := s.q.ListFilesBySession(ctx, sessionID)
179+ if err != nil {
180+ return nil, err
181+ }
182+ files := make([]File, len(dbFiles))
183+ for i, dbFile := range dbFiles {
184+ files[i] = s.fromDBItem(dbFile)
185+ }
186+ return files, nil
187+}
188+
189+func (s *service) ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error) {
190+ dbFiles, err := s.q.ListLatestSessionFiles(ctx, sessionID)
191+ if err != nil {
192+ return nil, err
193+ }
194+ files := make([]File, len(dbFiles))
195+ for i, dbFile := range dbFiles {
196+ files[i] = s.fromDBItem(dbFile)
197+ }
198+ return files, nil
199+}
200+
201+func (s *service) Update(ctx context.Context, file File) (File, error) {
202+ dbFile, err := s.q.UpdateFile(ctx, db.UpdateFileParams{
203+ ID: file.ID,
204+ Content: file.Content,
205+ Version: file.Version,
206+ })
207+ if err != nil {
208+ return File{}, err
209+ }
210+ updatedFile := s.fromDBItem(dbFile)
211+ s.Publish(pubsub.UpdatedEvent, updatedFile)
212+ return updatedFile, nil
213+}
214+
215+func (s *service) Delete(ctx context.Context, id string) error {
216+ file, err := s.Get(ctx, id)
217+ if err != nil {
218+ return err
219+ }
220+ err = s.q.DeleteFile(ctx, id)
221+ if err != nil {
222+ return err
223+ }
224+ s.Publish(pubsub.DeletedEvent, file)
225+ return nil
226+}
227+
228+func (s *service) DeleteSessionFiles(ctx context.Context, sessionID string) error {
229+ files, err := s.ListBySession(ctx, sessionID)
230+ if err != nil {
231+ return err
232+ }
233+ for _, file := range files {
234+ err = s.Delete(ctx, file.ID)
235+ if err != nil {
236+ return err
237+ }
238+ }
239+ return nil
240+}
241+
242+func (s *service) fromDBItem(item db.File) File {
243+ return File{
244+ ID: item.ID,
245+ SessionID: item.SessionID,
246+ Path: item.Path,
247+ Content: item.Content,
248+ Version: item.Version,
249+ CreatedAt: item.CreatedAt,
250+ UpdatedAt: item.UpdatedAt,
251+ }
252+}
internal/llm/agent/agent-tool.go+36−27
@@ -5,14 +5,17 @@ import (
55 "encoding/json"
66 "fmt"
77
8− "github.com/kujtimiihoxha/termai/internal/app"
9− "github.com/kujtimiihoxha/termai/internal/llm/tools"
10− "github.com/kujtimiihoxha/termai/internal/message"
8+ "github.com/kujtimiihoxha/opencode/internal/config"
9+ "github.com/kujtimiihoxha/opencode/internal/llm/tools"
10+ "github.com/kujtimiihoxha/opencode/internal/lsp"
11+ "github.com/kujtimiihoxha/opencode/internal/message"
12+ "github.com/kujtimiihoxha/opencode/internal/session"
1113)
1214
1315type agentTool struct {
14− parentSessionID string
15− app *app.App
16+ sessions session.Service
17+ messages message.Service
18+ lspClients map[string]*lsp.Client
1619}
1720
1821const (
@@ -46,57 +49,63 @@ func (b *agentTool) Run(ctx context.Context, call tools.ToolCall) (tools.ToolRes
4649 return tools.NewTextErrorResponse("prompt is required"), nil
4750 }
4851
49− agent, err := NewTaskAgent(b.app)
50− if err != nil {
51− return tools.NewTextErrorResponse(fmt.Sprintf("error creating agent: %s", err)), nil
52+ sessionID, messageID := tools.GetContextValues(ctx)
53+ if sessionID == "" || messageID == "" {
54+ return tools.ToolResponse{}, fmt.Errorf("session_id and message_id are required")
5255 }
5356
54− session, err := b.app.Sessions.CreateTaskSession(call.ID, b.parentSessionID, "New Agent Session")
57+ agent, err := NewAgent(config.AgentTask, b.sessions, b.messages, TaskAgentTools(b.lspClients))
5558 if err != nil {
56− return tools.NewTextErrorResponse(fmt.Sprintf("error creating session: %s", err)), nil
59+ return tools.ToolResponse{}, fmt.Errorf("error creating agent: %s", err)
5760 }
5861
59− err = agent.Generate(ctx, session.ID, params.Prompt)
62+ session, err := b.sessions.CreateTaskSession(ctx, call.ID, sessionID, "New Agent Session")
6063 if err != nil {
61− return tools.NewTextErrorResponse(fmt.Sprintf("error generating agent: %s", err)), nil
64+ return tools.ToolResponse{}, fmt.Errorf("error creating session: %s", err)
6265 }
6366
64− messages, err := b.app.Messages.List(session.ID)
67+ done, err := agent.Run(ctx, session.ID, params.Prompt)
6568 if err != nil {
66− return tools.NewTextErrorResponse(fmt.Sprintf("error listing messages: %s", err)), nil
69+ return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", err)
6770 }
68− if len(messages) == 0 {
69− return tools.NewTextErrorResponse("no messages found"), nil
71+ result := <-done
72+ if result.Err() != nil {
73+ return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", result.Err())
7074 }
7175
72− response := messages[len(messages)-1]
76+ response := result.Response()
7377 if response.Role != message.Assistant {
74− return tools.NewTextErrorResponse("no assistant message found"), nil
78+ return tools.NewTextErrorResponse("no response"), nil
7579 }
7680
77− updatedSession, err := b.app.Sessions.Get(session.ID)
81+ updatedSession, err := b.sessions.Get(ctx, session.ID)
7882 if err != nil {
79− return tools.NewTextErrorResponse(fmt.Sprintf("error: %s", err)), nil
83+ return tools.ToolResponse{}, fmt.Errorf("error getting session: %s", err)
8084 }
81− parentSession, err := b.app.Sessions.Get(b.parentSessionID)
85+ parentSession, err := b.sessions.Get(ctx, sessionID)
8286 if err != nil {
83− return tools.NewTextErrorResponse(fmt.Sprintf("error: %s", err)), nil
87+ return tools.ToolResponse{}, fmt.Errorf("error getting parent session: %s", err)
8488 }
8589
8690 parentSession.Cost += updatedSession.Cost
8791 parentSession.PromptTokens += updatedSession.PromptTokens
8892 parentSession.CompletionTokens += updatedSession.CompletionTokens
8993
90− _, err = b.app.Sessions.Save(parentSession)
94+ _, err = b.sessions.Save(ctx, parentSession)
9195 if err != nil {
92− return tools.NewTextErrorResponse(fmt.Sprintf("error: %s", err)), nil
96+ return tools.ToolResponse{}, fmt.Errorf("error saving parent session: %s", err)
9397 }
9498 return tools.NewTextResponse(response.Content().String()), nil
9599}
96100
97−func NewAgentTool(parentSessionID string, app *app.App) tools.BaseTool {
101+func NewAgentTool(
102+ Sessions session.Service,
103+ Messages message.Service,
104+ LspClients map[string]*lsp.Client,
105+) tools.BaseTool {
98106 return &agentTool{
99− parentSessionID: parentSessionID,
100− app: app,
107+ sessions: Sessions,
108+ messages: Messages,
109+ lspClients: LspClients,
101110 }
102111}
internal/llm/agent/agent.go+395−421
@@ -7,30 +7,123 @@ import (
77 "strings"
88 "sync"
99
10− "github.com/kujtimiihoxha/termai/internal/app"
11− "github.com/kujtimiihoxha/termai/internal/config"
12− "github.com/kujtimiihoxha/termai/internal/llm/models"
13− "github.com/kujtimiihoxha/termai/internal/llm/prompt"
14− "github.com/kujtimiihoxha/termai/internal/llm/provider"
15− "github.com/kujtimiihoxha/termai/internal/llm/tools"
16− "github.com/kujtimiihoxha/termai/internal/logging"
17− "github.com/kujtimiihoxha/termai/internal/message"
10+ "github.com/kujtimiihoxha/opencode/internal/config"
11+ "github.com/kujtimiihoxha/opencode/internal/llm/models"
12+ "github.com/kujtimiihoxha/opencode/internal/llm/prompt"
13+ "github.com/kujtimiihoxha/opencode/internal/llm/provider"
14+ "github.com/kujtimiihoxha/opencode/internal/llm/tools"
15+ "github.com/kujtimiihoxha/opencode/internal/logging"
16+ "github.com/kujtimiihoxha/opencode/internal/message"
17+ "github.com/kujtimiihoxha/opencode/internal/permission"
18+ "github.com/kujtimiihoxha/opencode/internal/session"
1819)
1920
20−type Agent interface {
21− Generate(ctx context.Context, sessionID string, content string) error
21+// Common errors
22+var (
23+ ErrRequestCancelled = errors.New("request cancelled by user")
24+ ErrSessionBusy = errors.New("session is currently processing another request")
25+)
26+
27+type AgentEvent struct {
28+ message message.Message
29+ err error
30+}
31+
32+func (e *AgentEvent) Err() error {
33+ return e.err
34+}
35+
36+func (e *AgentEvent) Response() message.Message {
37+ return e.message
38+}
39+
40+type Service interface {
41+ Run(ctx context.Context, sessionID string, content string) (<-chan AgentEvent, error)
42+ Cancel(sessionID string)
43+ IsSessionBusy(sessionID string) bool
44+ IsBusy() bool
2245}
2346
2447type agent struct {
25− *app.App
26− model models.Model
27− tools []tools.BaseTool
28− agent provider.Provider
29− titleGenerator provider.Provider
48+ sessions session.Service
49+ messages message.Service
50+
51+ tools []tools.BaseTool
52+ provider provider.Provider
53+
54+ titleProvider provider.Provider
55+
56+ activeRequests sync.Map
57+}
58+
59+func NewAgent(
60+ agentName config.AgentName,
61+ sessions session.Service,
62+ messages message.Service,
63+ agentTools []tools.BaseTool,
64+) (Service, error) {
65+ agentProvider, err := createAgentProvider(agentName)
66+ if err != nil {
67+ return nil, err
68+ }
69+ var titleProvider provider.Provider
70+ // Only generate titles for the coder agent
71+ if agentName == config.AgentCoder {
72+ titleProvider, err = createAgentProvider(config.AgentTitle)
73+ if err != nil {
74+ return nil, err
75+ }
76+ }
77+
78+ agent := &agent{
79+ provider: agentProvider,
80+ messages: messages,
81+ sessions: sessions,
82+ tools: agentTools,
83+ titleProvider: titleProvider,
84+ activeRequests: sync.Map{},
85+ }
86+
87+ return agent, nil
88+}
89+
90+func (a *agent) Cancel(sessionID string) {
91+ if cancelFunc, exists := a.activeRequests.LoadAndDelete(sessionID); exists {
92+ if cancel, ok := cancelFunc.(context.CancelFunc); ok {
93+ logging.InfoPersist(fmt.Sprintf("Request cancellation initiated for session: %s", sessionID))
94+ cancel()
95+ }
96+ }
97+}
98+
99+func (a *agent) IsBusy() bool {
100+ busy := false
101+ a.activeRequests.Range(func(key, value interface{}) bool {
102+ if cancelFunc, ok := value.(context.CancelFunc); ok {
103+ if cancelFunc != nil {
104+ busy = true
105+ return false // Stop iterating
106+ }
107+ }
108+ return true // Continue iterating
109+ })
110+ return busy
111+}
112+
113+func (a *agent) IsSessionBusy(sessionID string) bool {
114+ _, busy := a.activeRequests.Load(sessionID)
115+ return busy
30116}
31117
32−func (c *agent) handleTitleGeneration(ctx context.Context, sessionID, content string) {
33− response, err := c.titleGenerator.SendMessages(
118+func (a *agent) generateTitle(ctx context.Context, sessionID string, content string) error {
119+ if a.titleProvider == nil {
120+ return nil
121+ }
122+ session, err := a.sessions.Get(ctx, sessionID)
123+ if err != nil {
124+ return err
125+ }
126+ response, err := a.titleProvider.SendMessages(
34127 ctx,
35128 []message.Message{
36129 {
@@ -42,476 +135,357 @@ func (c *agent) handleTitleGeneration(ctx context.Context, sessionID, content st
42135 },
43136 },
44137 },
45− nil,
138+ make([]tools.BaseTool, 0),
46139 )
47− if err != nil {
48− return
49− }
50−
51− session, err := c.Sessions.Get(sessionID)
52− if err != nil {
53− return
54− }
55− if response.Content != "" {
56− session.Title = response.Content
57− session.Title = strings.TrimSpace(session.Title)
58− session.Title = strings.ReplaceAll(session.Title, "\n", " ")
59− c.Sessions.Save(session)
60− }
61−}
62−
63−func (c *agent) TrackUsage(sessionID string, model models.Model, usage provider.TokenUsage) error {
64− session, err := c.Sessions.Get(sessionID)
65140 if err != nil {
66141 return err
67142 }
68143
69− cost := model.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) +
70− model.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) +
71− model.CostPer1MIn/1e6*float64(usage.InputTokens) +
72− model.CostPer1MOut/1e6*float64(usage.OutputTokens)
73−
74− session.Cost += cost
75− session.CompletionTokens += usage.OutputTokens
76− session.PromptTokens += usage.InputTokens
144+ title := strings.TrimSpace(strings.ReplaceAll(response.Content, "\n", " "))
145+ if title == "" {
146+ return nil
147+ }
77148
78− _, err = c.Sessions.Save(session)
149+ session.Title = title
150+ _, err = a.sessions.Save(ctx, session)
79151 return err
80152}
81153
82−func (c *agent) processEvent(
83− sessionID string,
84− assistantMsg *message.Message,
85− event provider.ProviderEvent,
86−) error {
87− switch event.Type {
88− case provider.EventThinkingDelta:
89− assistantMsg.AppendReasoningContent(event.Content)
90− return c.Messages.Update(*assistantMsg)
91− case provider.EventContentDelta:
92− assistantMsg.AppendContent(event.Content)
93− return c.Messages.Update(*assistantMsg)
94− case provider.EventError:
95− if errors.Is(event.Error, context.Canceled) {
96− return nil
97− }
98− logging.ErrorPersist(event.Error.Error())
99− return event.Error
100− case provider.EventWarning:
101− logging.WarnPersist(event.Info)
102− return nil
103− case provider.EventInfo:
104− logging.InfoPersist(event.Info)
105− case provider.EventComplete:
106− assistantMsg.SetToolCalls(event.Response.ToolCalls)
107− assistantMsg.AddFinish(event.Response.FinishReason)
108− err := c.Messages.Update(*assistantMsg)
109− if err != nil {
110− return err
111− }
112− return c.TrackUsage(sessionID, c.model, event.Response.Usage)
154+func (a *agent) err(err error) AgentEvent {
155+ return AgentEvent{
156+ err: err,
113157 }
114−
115− return nil
116158}
117159
118−func (c *agent) ExecuteTools(ctx context.Context, toolCalls []message.ToolCall, tls []tools.BaseTool) ([]message.ToolResult, error) {
119− var wg sync.WaitGroup
120− toolResults := make([]message.ToolResult, len(toolCalls))
121− mutex := &sync.Mutex{}
122− errChan := make(chan error, 1)
123−
124− // Create a child context that can be canceled
125− ctx, cancel := context.WithCancel(ctx)
126− defer cancel()
127−
128− for i, tc := range toolCalls {
129− wg.Add(1)
130− go func(index int, toolCall message.ToolCall) {
131− defer wg.Done()
132−
133− // Check if context is already canceled
134− select {
135− case <-ctx.Done():
136− mutex.Lock()
137− toolResults[index] = message.ToolResult{
138− ToolCallID: toolCall.ID,
139− Content: "Tool execution canceled",
140− IsError: true,
141− }
142− mutex.Unlock()
143−
144− // Send cancellation error to error channel if it's empty
145− select {
146− case errChan <- ctx.Err():
147− default:
148− }
149− return
150− default:
151− }
152−
153− response := ""
154− isError := false
155− found := false
156−
157− for _, tool := range tls {
158− if tool.Info().Name == toolCall.Name {
159− found = true
160− toolResult, toolErr := tool.Run(ctx, tools.ToolCall{
161− ID: toolCall.ID,
162− Name: toolCall.Name,
163− Input: toolCall.Input,
164− })
165−
166− if toolErr != nil {
167− if errors.Is(toolErr, context.Canceled) {
168− response = "Tool execution canceled"
169−
170− // Send cancellation error to error channel if it's empty
171− select {
172− case errChan <- ctx.Err():
173− default:
174− }
175− } else {
176− response = fmt.Sprintf("error running tool: %s", toolErr)
177− }
178− isError = true
179− } else {
180− response = toolResult.Content
181− isError = toolResult.IsError
182− }
183− break
184− }
185− }
186−
187− if !found {
188− response = fmt.Sprintf("tool not found: %s", toolCall.Name)
189− isError = true
190− }
191−
192− mutex.Lock()
193− defer mutex.Unlock()
194−
195− toolResults[index] = message.ToolResult{
196− ToolCallID: toolCall.ID,
197− Content: response,
198− IsError: isError,
199− }
200− }(i, tc)
160+func (a *agent) Run(ctx context.Context, sessionID string, content string) (<-chan AgentEvent, error) {
161+ events := make(chan AgentEvent)
162+ if a.IsSessionBusy(sessionID) {
163+ return nil, ErrSessionBusy
201164 }
202165
203− // Wait for all goroutines to finish or context to be canceled
204− done := make(chan struct{})
205− go func() {
206− wg.Wait()
207− close(done)
208− }()
166+ genCtx, cancel := context.WithCancel(ctx)
209167
210− select {
211− case <-done:
212− // All tools completed successfully
213− case err := <-errChan:
214− // One of the tools encountered a cancellation
215− return toolResults, err
216− case <-ctx.Done():
217− // Context was canceled externally
218− return toolResults, ctx.Err()
219− }
168+ a.activeRequests.Store(sessionID, cancel)
169+ go func() {
170+ logging.Debug("Request started", "sessionID", sessionID)
171+ defer logging.RecoverPanic("agent.Run", func() {
172+ events <- a.err(fmt.Errorf("panic while running the agent"))
173+ })
220174
221− return toolResults, nil
175+ result := a.processGeneration(genCtx, sessionID, content)
176+ if result.Err() != nil && !errors.Is(result.Err(), ErrRequestCancelled) && !errors.Is(result.Err(), context.Canceled) {
177+ logging.ErrorPersist(fmt.Sprintf("Generation error for session %s: %v", sessionID, result))
178+ }
179+ logging.Debug("Request completed", "sessionID", sessionID)
180+ a.activeRequests.Delete(sessionID)
181+ cancel()
182+ events <- result
183+ close(events)
184+ }()
185+ return events, nil
222186}
223187
224−func (c *agent) handleToolExecution(
225− ctx context.Context,
226− assistantMsg message.Message,
227−) (*message.Message, error) {
228− if len(assistantMsg.ToolCalls()) == 0 {
229− return nil, nil
230− }
231−
232− toolResults, err := c.ExecuteTools(ctx, assistantMsg.ToolCalls(), c.tools)
188+func (a *agent) processGeneration(ctx context.Context, sessionID, content string) AgentEvent {
189+ // List existing messages; if none, start title generation asynchronously.
190+ msgs, err := a.messages.List(ctx, sessionID)
233191 if err != nil {
234− return nil, err
192+ return a.err(fmt.Errorf("failed to list messages: %w", err))
235193 }
236− parts := make([]message.ContentPart, 0)
237− for _, toolResult := range toolResults {
238− parts = append(parts, toolResult)
194+ if len(msgs) == 0 {
195+ go func() {
196+ defer logging.RecoverPanic("agent.Run", func() {
197+ logging.ErrorPersist("panic while generating title")
198+ })
199+ titleErr := a.generateTitle(context.Background(), sessionID, content)
200+ if titleErr != nil {
201+ logging.ErrorPersist(fmt.Sprintf("failed to generate title: %v", titleErr))
202+ }
203+ }()
239204 }
240− msg, err := c.Messages.Create(assistantMsg.SessionID, message.CreateMessageParams{
241− Role: message.Tool,
242− Parts: parts,
243− })
244−
245− return &msg, err
246−}
247205
248−func (c *agent) generate(ctx context.Context, sessionID string, content string) error {
249− messages, err := c.Messages.List(sessionID)
206+ userMsg, err := a.createUserMessage(ctx, sessionID, content)
250207 if err != nil {
251− return err
208+ return a.err(fmt.Errorf("failed to create user message: %w", err))
252209 }
253210
254− if len(messages) == 0 {
255− go c.handleTitleGeneration(ctx, sessionID, content)
211+ // Append the new user message to the conversation history.
212+ msgHistory := append(msgs, userMsg)
213+ for {
214+ // Check for cancellation before each iteration
215+ select {
216+ case <-ctx.Done():
217+ return a.err(ctx.Err())
218+ default:
219+ // Continue processing
220+ }
221+ agentMessage, toolResults, err := a.streamAndHandleEvents(ctx, sessionID, msgHistory)
222+ if err != nil {
223+ if errors.Is(err, context.Canceled) {
224+ agentMessage.AddFinish(message.FinishReasonCanceled)
225+ a.messages.Update(context.Background(), agentMessage)
226+ return a.err(ErrRequestCancelled)
227+ }
228+ return a.err(fmt.Errorf("failed to process events: %w", err))
229+ }
230+ logging.Info("Result", "message", agentMessage.FinishReason(), "toolResults", toolResults)
231+ if (agentMessage.FinishReason() == message.FinishReasonToolUse) && toolResults != nil {
232+ // We are not done, we need to respond with the tool response
233+ msgHistory = append(msgHistory, agentMessage, *toolResults)
234+ continue
235+ }
236+ return AgentEvent{
237+ message: agentMessage,
238+ }
256239 }
240+}
257241
258− userMsg, err := c.Messages.Create(sessionID, message.CreateMessageParams{
242+func (a *agent) createUserMessage(ctx context.Context, sessionID, content string) (message.Message, error) {
243+ return a.messages.Create(ctx, sessionID, message.CreateMessageParams{
259244 Role: message.User,
260245 Parts: []message.ContentPart{
261− message.TextContent{
262− Text: content,
263− },
246+ message.TextContent{Text: content},
264247 },
265248 })
249+}
250+
251+func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msgHistory []message.Message) (message.Message, *message.Message, error) {
252+ eventChan := a.provider.StreamResponse(ctx, msgHistory, a.tools)
253+
254+ assistantMsg, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
255+ Role: message.Assistant,
256+ Parts: []message.ContentPart{},
257+ Model: a.provider.Model().ID,
258+ })
266259 if err != nil {
267− return err
260+ return assistantMsg, nil, fmt.Errorf("failed to create assistant message: %w", err)
268261 }
269262
270− messages = append(messages, userMsg)
271− for {
263+ // Add the session and message ID into the context if needed by tools.
264+ ctx = context.WithValue(ctx, tools.MessageIDContextKey, assistantMsg.ID)
265+ ctx = context.WithValue(ctx, tools.SessionIDContextKey, sessionID)
266+
267+ // Process each event in the stream.
268+ for event := range eventChan {
269+ if processErr := a.processEvent(ctx, sessionID, &assistantMsg, event); processErr != nil {
270+ a.finishMessage(ctx, &assistantMsg, message.FinishReasonCanceled)
271+ return assistantMsg, nil, processErr
272+ }
273+ if ctx.Err() != nil {
274+ a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled)
275+ return assistantMsg, nil, ctx.Err()
276+ }
277+ }
278+
279+ toolResults := make([]message.ToolResult, len(assistantMsg.ToolCalls()))
280+ toolCalls := assistantMsg.ToolCalls()
281+ for i, toolCall := range toolCalls {
272282 select {
273283 case <-ctx.Done():
274− assistantMsg, err := c.Messages.Create(sessionID, message.CreateMessageParams{
275− Role: message.Assistant,
276− Parts: []message.ContentPart{},
277− })
278− if err != nil {
279− return err
284+ a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled)
285+ // Make all future tool calls cancelled
286+ for j := i; j < len(toolCalls); j++ {
287+ toolResults[j] = message.ToolResult{
288+ ToolCallID: toolCalls[j].ID,
289+ Content: "Tool execution canceled by user",
290+ IsError: true,
291+ }
280292 }
281− assistantMsg.AddFinish("canceled")
282− c.Messages.Update(assistantMsg)
283− return context.Canceled
293+ goto out
284294 default:
285295 // Continue processing
286− }
287−
288− eventChan, err := c.agent.StreamResponse(ctx, messages, c.tools)
289− if err != nil {
290− if errors.Is(err, context.Canceled) {
291− assistantMsg, err := c.Messages.Create(sessionID, message.CreateMessageParams{
292− Role: message.Assistant,
293− Parts: []message.ContentPart{},
294− })
295− if err != nil {
296− return err
296+ var tool tools.BaseTool
297+ for _, availableTools := range a.tools {
298+ if availableTools.Info().Name == toolCall.Name {
299+ tool = availableTools
297300 }
298− assistantMsg.AddFinish("canceled")
299− c.Messages.Update(assistantMsg)
300− return context.Canceled
301301 }
302− return err
303− }
304302
305− assistantMsg, err := c.Messages.Create(sessionID, message.CreateMessageParams{
306− Role: message.Assistant,
307− Parts: []message.ContentPart{},
308− })
309− if err != nil {
310− return err
311− }
312− for event := range eventChan {
313− err = c.processEvent(sessionID, &assistantMsg, event)
314− if err != nil {
315− if errors.Is(err, context.Canceled) {
316− assistantMsg.AddFinish("canceled")
317− c.Messages.Update(assistantMsg)
318− return context.Canceled
303+ // Tool not found
304+ if tool == nil {
305+ toolResults[i] = message.ToolResult{
306+ ToolCallID: toolCall.ID,
307+ Content: fmt.Sprintf("Tool not found: %s", toolCall.Name),
308+ IsError: true,
319309 }
320− assistantMsg.AddFinish("error:" + err.Error())
321− c.Messages.Update(assistantMsg)
322− return err
310+ continue
323311 }
324312
325− select {
326− case <-ctx.Done():
327− assistantMsg.AddFinish("canceled")
328− c.Messages.Update(assistantMsg)
329− return context.Canceled
330− default:
313+ toolResult, toolErr := tool.Run(ctx, tools.ToolCall{
314+ ID: toolCall.ID,
315+ Name: toolCall.Name,
316+ Input: toolCall.Input,
317+ })
318+ if toolErr != nil {
319+ if errors.Is(toolErr, permission.ErrorPermissionDenied) {
320+ toolResults[i] = message.ToolResult{
321+ ToolCallID: toolCall.ID,
322+ Content: "Permission denied",
323+ IsError: true,
324+ }
325+ for j := i + 1; j < len(toolCalls); j++ {
326+ toolResults[j] = message.ToolResult{
327+ ToolCallID: toolCalls[j].ID,
328+ Content: "Tool execution canceled by user",
329+ IsError: true,
330+ }
331+ }
332+ a.finishMessage(ctx, &assistantMsg, message.FinishReasonPermissionDenied)
333+ break
334+ }
331335 }
332− }
333−
334− // Check for context cancellation before tool execution
335− select {
336− case <-ctx.Done():
337− assistantMsg.AddFinish("canceled")
338− c.Messages.Update(assistantMsg)
339− return context.Canceled
340− default:
341− // Continue processing
342− }
343−
344− msg, err := c.handleToolExecution(ctx, assistantMsg)
345− if err != nil {
346− if errors.Is(err, context.Canceled) {
347− assistantMsg.AddFinish("canceled")
348− c.Messages.Update(assistantMsg)
349− return context.Canceled
336+ toolResults[i] = message.ToolResult{
337+ ToolCallID: toolCall.ID,
338+ Content: toolResult.Content,
339+ Metadata: toolResult.Metadata,
340+ IsError: toolResult.IsError,
350341 }
351− return err
352342 }
343+ }
344+out:
345+ if len(toolResults) == 0 {
346+ return assistantMsg, nil, nil
347+ }
348+ parts := make([]message.ContentPart, 0)
349+ for _, tr := range toolResults {
350+ parts = append(parts, tr)
351+ }
352+ msg, err := a.messages.Create(context.Background(), assistantMsg.SessionID, message.CreateMessageParams{
353+ Role: message.Tool,
354+ Parts: parts,
355+ })
356+ if err != nil {
357+ return assistantMsg, nil, fmt.Errorf("failed to create cancelled tool message: %w", err)
358+ }
353359
354− c.Messages.Update(assistantMsg)
360+ return assistantMsg, &msg, err
361+}
355362
356− if len(assistantMsg.ToolCalls()) == 0 {
357− break
358− }
363+func (a *agent) finishMessage(ctx context.Context, msg *message.Message, finishReson message.FinishReason) {
364+ msg.AddFinish(finishReson)
365+ _ = a.messages.Update(ctx, *msg)
366+}
359367
360− messages = append(messages, assistantMsg)
361− if msg != nil {
362− messages = append(messages, *msg)
363− }
368+func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg *message.Message, event provider.ProviderEvent) error {
369+ select {
370+ case <-ctx.Done():
371+ return ctx.Err()
372+ default:
373+ // Continue processing.
374+ }
364375
365− // Check for context cancellation after tool execution
366− select {
367− case <-ctx.Done():
368− assistantMsg.AddFinish("canceled")
369− c.Messages.Update(assistantMsg)
376+ switch event.Type {
377+ case provider.EventThinkingDelta:
378+ assistantMsg.AppendReasoningContent(event.Content)
379+ return a.messages.Update(ctx, *assistantMsg)
380+ case provider.EventContentDelta:
381+ assistantMsg.AppendContent(event.Content)
382+ return a.messages.Update(ctx, *assistantMsg)
383+ case provider.EventToolUseStart:
384+ assistantMsg.AddToolCall(*event.ToolCall)
385+ return a.messages.Update(ctx, *assistantMsg)
386+ // TODO: see how to handle this
387+ // case provider.EventToolUseDelta:
388+ // tm := time.Unix(assistantMsg.UpdatedAt, 0)
389+ // assistantMsg.AppendToolCallInput(event.ToolCall.ID, event.ToolCall.Input)
390+ // if time.Since(tm) > 1000*time.Millisecond {
391+ // err := a.messages.Update(ctx, *assistantMsg)
392+ // assistantMsg.UpdatedAt = time.Now().Unix()
393+ // return err
394+ // }
395+ case provider.EventToolUseStop:
396+ assistantMsg.FinishToolCall(event.ToolCall.ID)
397+ return a.messages.Update(ctx, *assistantMsg)
398+ case provider.EventError:
399+ if errors.Is(event.Error, context.Canceled) {
400+ logging.InfoPersist(fmt.Sprintf("Event processing canceled for session: %s", sessionID))
370401 return context.Canceled
371− default:
372− // Continue processing
373402 }
403+ logging.ErrorPersist(event.Error.Error())
404+ return event.Error
405+ case provider.EventComplete:
406+ assistantMsg.SetToolCalls(event.Response.ToolCalls)
407+ assistantMsg.AddFinish(event.Response.FinishReason)
408+ if err := a.messages.Update(ctx, *assistantMsg); err != nil {
409+ return fmt.Errorf("failed to update message: %w", err)
410+ }
411+ return a.TrackUsage(ctx, sessionID, a.provider.Model(), event.Response.Usage)
374412 }
413+
375414 return nil
376415}
377416
378−func getAgentProviders(ctx context.Context, model models.Model) (provider.Provider, provider.Provider, error) {
379− maxTokens := config.Get().Model.CoderMaxTokens
380−
381− providerConfig, ok := config.Get().Providers[model.Provider]
382− if !ok || !providerConfig.Enabled {
383− return nil, nil, errors.New("provider is not enabled")
417+func (a *agent) TrackUsage(ctx context.Context, sessionID string, model models.Model, usage provider.TokenUsage) error {
418+ sess, err := a.sessions.Get(ctx, sessionID)
419+ if err != nil {
420+ return fmt.Errorf("failed to get session: %w", err)
384421 }
385− var agentProvider provider.Provider
386− var titleGenerator provider.Provider
387422
388− switch model.Provider {
389− case models.ProviderOpenAI:
390− var err error
391− agentProvider, err = provider.NewOpenAIProvider(
392− provider.WithOpenAISystemMessage(
393− prompt.CoderOpenAISystemPrompt(),
394− ),
395− provider.WithOpenAIMaxTokens(maxTokens),
396− provider.WithOpenAIModel(model),
397− provider.WithOpenAIKey(providerConfig.APIKey),
398− )
399− if err != nil {
400− return nil, nil, err
401− }
402− titleGenerator, err = provider.NewOpenAIProvider(
403− provider.WithOpenAISystemMessage(
404− prompt.TitlePrompt(),
405− ),
406− provider.WithOpenAIMaxTokens(80),
407− provider.WithOpenAIModel(model),
408− provider.WithOpenAIKey(providerConfig.APIKey),
409− )
410− if err != nil {
411− return nil, nil, err
412− }
413− case models.ProviderAnthropic:
414− var err error
415− agentProvider, err = provider.NewAnthropicProvider(
416− provider.WithAnthropicSystemMessage(
417− prompt.CoderAnthropicSystemPrompt(),
418− ),
419− provider.WithAnthropicMaxTokens(maxTokens),
420− provider.WithAnthropicKey(providerConfig.APIKey),
421− provider.WithAnthropicModel(model),
422− )
423− if err != nil {
424− return nil, nil, err
425− }
426− titleGenerator, err = provider.NewAnthropicProvider(
427− provider.WithAnthropicSystemMessage(
428− prompt.TitlePrompt(),
429− ),
430− provider.WithAnthropicMaxTokens(80),
431− provider.WithAnthropicKey(providerConfig.APIKey),
432− provider.WithAnthropicModel(model),
433− )
434− if err != nil {
435− return nil, nil, err
436− }
423+ cost := model.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) +
424+ model.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) +
425+ model.CostPer1MIn/1e6*float64(usage.InputTokens) +
426+ model.CostPer1MOut/1e6*float64(usage.OutputTokens)
437427
438− case models.ProviderGemini:
439− var err error
440− agentProvider, err = provider.NewGeminiProvider(
441− ctx,
442− provider.WithGeminiSystemMessage(
443− prompt.CoderOpenAISystemPrompt(),
444− ),
445− provider.WithGeminiMaxTokens(int32(maxTokens)),
446− provider.WithGeminiKey(providerConfig.APIKey),
447− provider.WithGeminiModel(model),
448− )
449− if err != nil {
450− return nil, nil, err
451− }
452− titleGenerator, err = provider.NewGeminiProvider(
453− ctx,
454− provider.WithGeminiSystemMessage(
455− prompt.TitlePrompt(),
456− ),
457− provider.WithGeminiMaxTokens(80),
458− provider.WithGeminiKey(providerConfig.APIKey),
459− provider.WithGeminiModel(model),
460− )
461− if err != nil {
462− return nil, nil, err
463− }
464− case models.ProviderGROQ:
465− var err error
466− agentProvider, err = provider.NewOpenAIProvider(
467− provider.WithOpenAISystemMessage(
468− prompt.CoderAnthropicSystemPrompt(),
469− ),
470− provider.WithOpenAIMaxTokens(maxTokens),
471− provider.WithOpenAIModel(model),
472− provider.WithOpenAIKey(providerConfig.APIKey),
473− provider.WithOpenAIBaseURL("https://api.groq.com/openai/v1"),
474− )
475− if err != nil {
476− return nil, nil, err
477− }
478− titleGenerator, err = provider.NewOpenAIProvider(
479− provider.WithOpenAISystemMessage(
480− prompt.TitlePrompt(),
481− ),
482− provider.WithOpenAIMaxTokens(80),
483− provider.WithOpenAIModel(model),
484− provider.WithOpenAIKey(providerConfig.APIKey),
485− provider.WithOpenAIBaseURL("https://api.groq.com/openai/v1"),
486− )
487− if err != nil {
488− return nil, nil, err
489− }
428+ sess.Cost += cost
429+ sess.CompletionTokens += usage.OutputTokens
430+ sess.PromptTokens += usage.InputTokens
431+
432+ _, err = a.sessions.Save(ctx, sess)
433+ if err != nil {
434+ return fmt.Errorf("failed to save session: %w", err)
435+ }
436+ return nil
437+}
438+
439+func createAgentProvider(agentName config.AgentName) (provider.Provider, error) {
440+ cfg := config.Get()
441+ agentConfig, ok := cfg.Agents[agentName]
442+ if !ok {
443+ return nil, fmt.Errorf("agent %s not found", agentName)
444+ }
445+ model, ok := models.SupportedModels[agentConfig.Model]
446+ if !ok {
447+ return nil, fmt.Errorf("model %s not supported", agentConfig.Model)
448+ }
490449
491− case models.ProviderBedrock:
492− var err error
493− agentProvider, err = provider.NewBedrockProvider(
494− provider.WithBedrockSystemMessage(
495− prompt.CoderAnthropicSystemPrompt(),
450+ providerCfg, ok := cfg.Providers[model.Provider]
451+ if !ok {
452+ return nil, fmt.Errorf("provider %s not supported", model.Provider)
453+ }
454+ if providerCfg.Disabled {
455+ return nil, fmt.Errorf("provider %s is not enabled", model.Provider)
456+ }
457+ maxTokens := model.DefaultMaxTokens
458+ if agentConfig.MaxTokens > 0 {
459+ maxTokens = agentConfig.MaxTokens
460+ }
461+ opts := []provider.ProviderClientOption{
462+ provider.WithAPIKey(providerCfg.APIKey),
463+ provider.WithModel(model),
464+ provider.WithSystemMessage(prompt.GetAgentPrompt(agentName, model.Provider)),
465+ provider.WithMaxTokens(maxTokens),
466+ }
467+ if model.Provider == models.ProviderOpenAI && model.CanReason {
468+ opts = append(
469+ opts,
470+ provider.WithOpenAIOptions(
471+ provider.WithReasoningEffort(agentConfig.ReasoningEffort),
496472 ),
497− provider.WithBedrockMaxTokens(maxTokens),
498− provider.WithBedrockModel(model),
499473 )
500− if err != nil {
501− return nil, nil, err
502− }
503− titleGenerator, err = provider.NewBedrockProvider(
504− provider.WithBedrockSystemMessage(
505− prompt.TitlePrompt(),
474+ } else if model.Provider == models.ProviderAnthropic && model.CanReason && agentName == config.AgentCoder {
475+ opts = append(
476+ opts,
477+ provider.WithAnthropicOptions(
478+ provider.WithAnthropicShouldThinkFn(provider.DefaultShouldThinkFn),
506479 ),
507− provider.WithBedrockMaxTokens(maxTokens),
508− provider.WithBedrockModel(model),
509480 )
510− if err != nil {
511− return nil, nil, err
512− }
513−
481+ }
482+ agentProvider, err := provider.NewProvider(
483+ model.Provider,
484+ opts...,
485+ )
486+ if err != nil {
487+ return nil, fmt.Errorf("could not create provider: %v", err)
514488 }
515489
516− return agentProvider, titleGenerator, nil
490+ return agentProvider, nil
517491}
internal/llm/agent/coder.godeleted−73
@@ -1,73 +0,0 @@
1−package agent
2−
3−import (
4− "context"
5− "errors"
6−
7− "github.com/kujtimiihoxha/termai/internal/app"
8− "github.com/kujtimiihoxha/termai/internal/config"
9− "github.com/kujtimiihoxha/termai/internal/llm/models"
10− "github.com/kujtimiihoxha/termai/internal/llm/tools"
11−)
12−
13−type coderAgent struct {
14− *agent
15−}
16−
17−func (c *coderAgent) setAgentTool(sessionID string) {
18− inx := -1
19− for i, tool := range c.tools {
20− if tool.Info().Name == AgentToolName {
21− inx = i
22− break
23− }
24− }
25− if inx == -1 {
26− c.tools = append(c.tools, NewAgentTool(sessionID, c.App))
27− } else {
28− c.tools[inx] = NewAgentTool(sessionID, c.App)
29− }
30−}
31−
32−func (c *coderAgent) Generate(ctx context.Context, sessionID string, content string) error {
33− c.setAgentTool(sessionID)
34− return c.generate(ctx, sessionID, content)
35−}
36−
37−func NewCoderAgent(app *app.App) (Agent, error) {
38− model, ok := models.SupportedModels[config.Get().Model.Coder]
39− if !ok {
40− return nil, errors.New("model not supported")
41− }
42−
43− agentProvider, titleGenerator, err := getAgentProviders(app.Context, model)
44− if err != nil {
45− return nil, err
46− }
47−
48− otherTools := GetMcpTools(app.Context, app.Permissions)
49− if len(app.LSPClients) > 0 {
50− otherTools = append(otherTools, tools.NewDiagnosticsTool(app.LSPClients))
51− }
52− return &coderAgent{
53− agent: &agent{
54− App: app,
55− tools: append(
56− []tools.BaseTool{
57− tools.NewBashTool(app.Permissions),
58− tools.NewEditTool(app.LSPClients, app.Permissions),
59− tools.NewFetchTool(app.Permissions),
60− tools.NewGlobTool(),
61− tools.NewGrepTool(),
62− tools.NewLsTool(),
63− tools.NewSourcegraphTool(),
64− tools.NewViewTool(app.LSPClients),
65− tools.NewWriteTool(app.LSPClients, app.Permissions),
66− }, otherTools...,
67− ),
68− model: model,
69− agent: agentProvider,
70− titleGenerator: titleGenerator,
71− },
72− }, nil
73−}
internal/llm/agent/mcp-tools.go+12−7
@@ -5,11 +5,11 @@ import (
55 "encoding/json"
66 "fmt"
77
8− "github.com/kujtimiihoxha/termai/internal/config"
9− "github.com/kujtimiihoxha/termai/internal/llm/tools"
10− "github.com/kujtimiihoxha/termai/internal/logging"
11− "github.com/kujtimiihoxha/termai/internal/permission"
12− "github.com/kujtimiihoxha/termai/internal/version"
8+ "github.com/kujtimiihoxha/opencode/internal/config"
9+ "github.com/kujtimiihoxha/opencode/internal/llm/tools"
10+ "github.com/kujtimiihoxha/opencode/internal/logging"
11+ "github.com/kujtimiihoxha/opencode/internal/permission"
12+ "github.com/kujtimiihoxha/opencode/internal/version"
1313
1414 "github.com/mark3labs/mcp-go/client"
1515 "github.com/mark3labs/mcp-go/mcp"
@@ -46,7 +46,7 @@ func runTool(ctx context.Context, c MCPClient, toolName string, input string) (t
4646 initRequest := mcp.InitializeRequest{}
4747 initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
4848 initRequest.Params.ClientInfo = mcp.Implementation{
49− Name: "termai",
49+ Name: "OpenCode",
5050 Version: version.Version,
5151 }
5252
@@ -80,9 +80,14 @@ func runTool(ctx context.Context, c MCPClient, toolName string, input string) (t
8080}
8181
8282func (b *mcpTool) Run(ctx context.Context, params tools.ToolCall) (tools.ToolResponse, error) {
83+ sessionID, messageID := tools.GetContextValues(ctx)
84+ if sessionID == "" || messageID == "" {
85+ return tools.ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
86+ }
8387 permissionDescription := fmt.Sprintf("execute %s with the following parameters: %s", b.Info().Name, params.Input)
8488 p := b.permissions.Request(
8589 permission.CreatePermissionRequest{
90+ SessionID: sessionID,
8691 Path: config.WorkingDirectory(),
8792 ToolName: b.Info().Name,
8893 Action: "execute",
@@ -135,7 +140,7 @@ func getTools(ctx context.Context, name string, m config.MCPServer, permissions
135140 initRequest := mcp.InitializeRequest{}
136141 initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
137142 initRequest.Params.ClientInfo = mcp.Implementation{
138− Name: "termai",
143+ Name: "OpenCode",
139144 Version: version.Version,
140145 }
141146
internal/llm/agent/task.godeleted−46
@@ -1,46 +0,0 @@
1−package agent
2−
3−import (
4− "context"
5− "errors"
6−
7− "github.com/kujtimiihoxha/termai/internal/app"
8− "github.com/kujtimiihoxha/termai/internal/config"
9− "github.com/kujtimiihoxha/termai/internal/llm/models"
10− "github.com/kujtimiihoxha/termai/internal/llm/tools"
11−)
12−
13−type taskAgent struct {
14− *agent
15−}
16−
17−func (c *taskAgent) Generate(ctx context.Context, sessionID string, content string) error {
18− return c.generate(ctx, sessionID, content)
19−}
20−
21−func NewTaskAgent(app *app.App) (Agent, error) {
22− model, ok := models.SupportedModels[config.Get().Model.Coder]
23− if !ok {
24− return nil, errors.New("model not supported")
25− }
26−
27− agentProvider, titleGenerator, err := getAgentProviders(app.Context, model)
28− if err != nil {
29− return nil, err
30− }
31− return &taskAgent{
32− agent: &agent{
33− App: app,
34− tools: []tools.BaseTool{
35− tools.NewGlobTool(),
36− tools.NewGrepTool(),
37− tools.NewLsTool(),
38− tools.NewSourcegraphTool(),
39− tools.NewViewTool(app.LSPClients),
40− },
41− model: model,
42− agent: agentProvider,
43− titleGenerator: titleGenerator,
44− },
45− }, nil
46−}
internal/llm/agent/tools.gonew file+51
@@ -0,0 +1,51 @@
1+package agent
2+
3+import (
4+ "context"
5+
6+ "github.com/kujtimiihoxha/opencode/internal/history"
7+ "github.com/kujtimiihoxha/opencode/internal/llm/tools"
8+ "github.com/kujtimiihoxha/opencode/internal/lsp"
9+ "github.com/kujtimiihoxha/opencode/internal/message"
10+ "github.com/kujtimiihoxha/opencode/internal/permission"
11+ "github.com/kujtimiihoxha/opencode/internal/session"
12+)
13+
14+func CoderAgentTools(
15+ permissions permission.Service,
16+ sessions session.Service,
17+ messages message.Service,
18+ history history.Service,
19+ lspClients map[string]*lsp.Client,
20+) []tools.BaseTool {
21+ ctx := context.Background()
22+ otherTools := GetMcpTools(ctx, permissions)
23+ if len(lspClients) > 0 {
24+ otherTools = append(otherTools, tools.NewDiagnosticsTool(lspClients))
25+ }
26+ return append(
27+ []tools.BaseTool{
28+ tools.NewBashTool(permissions),
29+ tools.NewEditTool(lspClients, permissions, history),
30+ tools.NewFetchTool(permissions),
31+ tools.NewGlobTool(),
32+ tools.NewGrepTool(),
33+ tools.NewLsTool(),
34+ tools.NewSourcegraphTool(),
35+ tools.NewViewTool(lspClients),
36+ tools.NewPatchTool(lspClients, permissions, history),
37+ tools.NewWriteTool(lspClients, permissions, history),
38+ NewAgentTool(sessions, messages, lspClients),
39+ }, otherTools...,
40+ )
41+}
42+
43+func TaskAgentTools(lspClients map[string]*lsp.Client) []tools.BaseTool {
44+ return []tools.BaseTool{
45+ tools.NewGlobTool(),
46+ tools.NewGrepTool(),
47+ tools.NewLsTool(),
48+ tools.NewSourcegraphTool(),
49+ tools.NewViewTool(lspClients),
50+ }
51+}
internal/llm/models/anthropic.gonew file+77
@@ -0,0 +1,77 @@
1+package models
2+
3+const (
4+ ProviderAnthropic ModelProvider = "anthropic"
5+
6+ // Models
7+ Claude35Sonnet ModelID = "claude-3.5-sonnet"
8+ Claude3Haiku ModelID = "claude-3-haiku"
9+ Claude37Sonnet ModelID = "claude-3.7-sonnet"
10+ Claude35Haiku ModelID = "claude-3.5-haiku"
11+ Claude3Opus ModelID = "claude-3-opus"
12+)
13+
14+var AnthropicModels = map[ModelID]Model{
15+ // Anthropic
16+ Claude35Sonnet: {
17+ ID: Claude35Sonnet,
18+ Name: "Claude 3.5 Sonnet",
19+ Provider: ProviderAnthropic,
20+ APIModel: "claude-3-5-sonnet-latest",
21+ CostPer1MIn: 3.0,
22+ CostPer1MInCached: 3.75,
23+ CostPer1MOutCached: 0.30,
24+ CostPer1MOut: 15.0,
25+ ContextWindow: 200000,
26+ DefaultMaxTokens: 5000,
27+ },
28+ Claude3Haiku: {
29+ ID: Claude3Haiku,
30+ Name: "Claude 3 Haiku",
31+ Provider: ProviderAnthropic,
32+ APIModel: "claude-3-haiku-latest",
33+ CostPer1MIn: 0.25,
34+ CostPer1MInCached: 0.30,
35+ CostPer1MOutCached: 0.03,
36+ CostPer1MOut: 1.25,
37+ ContextWindow: 200000,
38+ DefaultMaxTokens: 5000,
39+ },
40+ Claude37Sonnet: {
41+ ID: Claude37Sonnet,
42+ Name: "Claude 3.7 Sonnet",
43+ Provider: ProviderAnthropic,
44+ APIModel: "claude-3-7-sonnet-latest",
45+ CostPer1MIn: 3.0,
46+ CostPer1MInCached: 3.75,
47+ CostPer1MOutCached: 0.30,
48+ CostPer1MOut: 15.0,
49+ ContextWindow: 200000,
50+ DefaultMaxTokens: 50000,
51+ CanReason: true,
52+ },
53+ Claude35Haiku: {
54+ ID: Claude35Haiku,
55+ Name: "Claude 3.5 Haiku",
56+ Provider: ProviderAnthropic,
57+ APIModel: "claude-3-5-haiku-latest",
58+ CostPer1MIn: 0.80,
59+ CostPer1MInCached: 1.0,
60+ CostPer1MOutCached: 0.08,
61+ CostPer1MOut: 4.0,
62+ ContextWindow: 200000,
63+ DefaultMaxTokens: 4096,
64+ },
65+ Claude3Opus: {
66+ ID: Claude3Opus,
67+ Name: "Claude 3 Opus",
68+ Provider: ProviderAnthropic,
69+ APIModel: "claude-3-opus-latest",
70+ CostPer1MIn: 15.0,
71+ CostPer1MInCached: 18.75,
72+ CostPer1MOutCached: 1.50,
73+ CostPer1MOut: 75.0,
74+ ContextWindow: 200000,
75+ DefaultMaxTokens: 4096,
76+ },
77+}
internal/llm/models/gemini.gonew file+63
@@ -0,0 +1,63 @@
1+package models
2+
3+const (
4+ ProviderGemini ModelProvider = "gemini"
5+
6+ // Models
7+ Gemini25Flash ModelID = "gemini-2.5-flash"
8+ Gemini25 ModelID = "gemini-2.5"
9+ Gemini20Flash ModelID = "gemini-2.0-flash"
10+ Gemini20FlashLite ModelID = "gemini-2.0-flash-lite"
11+)
12+
13+var GeminiModels = map[ModelID]Model{
14+ Gemini25Flash: {
15+ ID: Gemini25Flash,
16+ Name: "Gemini 2.5 Flash",
17+ Provider: ProviderGemini,
18+ APIModel: "gemini-2.5-flash-preview-04-17",
19+ CostPer1MIn: 0.15,
20+ CostPer1MInCached: 0,
21+ CostPer1MOutCached: 0,
22+ CostPer1MOut: 0.60,
23+ ContextWindow: 1000000,
24+ DefaultMaxTokens: 50000,
25+ },
26+ Gemini25: {
27+ ID: Gemini25,
28+ Name: "Gemini 2.5 Pro",
29+ Provider: ProviderGemini,
30+ APIModel: "gemini-2.5-pro-preview-03-25",
31+ CostPer1MIn: 1.25,
32+ CostPer1MInCached: 0,
33+ CostPer1MOutCached: 0,
34+ CostPer1MOut: 10,
35+ ContextWindow: 1000000,
36+ DefaultMaxTokens: 50000,
37+ },
38+
39+ Gemini20Flash: {
40+ ID: Gemini20Flash,
41+ Name: "Gemini 2.0 Flash",
42+ Provider: ProviderGemini,
43+ APIModel: "gemini-2.0-flash",
44+ CostPer1MIn: 0.10,
45+ CostPer1MInCached: 0,
46+ CostPer1MOutCached: 0,
47+ CostPer1MOut: 0.40,
48+ ContextWindow: 1000000,
49+ DefaultMaxTokens: 6000,
50+ },
51+ Gemini20FlashLite: {
52+ ID: Gemini20FlashLite,
53+ Name: "Gemini 2.0 Flash Lite",
54+ Provider: ProviderGemini,
55+ APIModel: "gemini-2.0-flash-lite",
56+ CostPer1MIn: 0.05,
57+ CostPer1MInCached: 0,
58+ CostPer1MOutCached: 0,
59+ CostPer1MOut: 0.30,
60+ ContextWindow: 1000000,
61+ DefaultMaxTokens: 6000,
62+ },
63+}
internal/llm/models/models.go+54−97
@@ -1,5 +1,7 @@
11package models
22
3+import "maps"
4+
35type (
46 ModelID string
57 ModelProvider string
@@ -14,21 +16,13 @@ type Model struct {
1416 CostPer1MOut float64 `json:"cost_per_1m_out"`
1517 CostPer1MInCached float64 `json:"cost_per_1m_in_cached"`
1618 CostPer1MOutCached float64 `json:"cost_per_1m_out_cached"`
19+ ContextWindow int64 `json:"context_window"`
20+ DefaultMaxTokens int64 `json:"default_max_tokens"`
21+ CanReason bool `json:"can_reason"`
1722}
1823
1924// Model IDs
20−const (
21− // Anthropic
22− Claude35Sonnet ModelID = "claude-3.5-sonnet"
23− Claude3Haiku ModelID = "claude-3-haiku"
24− Claude37Sonnet ModelID = "claude-3.7-sonnet"
25− // OpenAI
26− GPT41 ModelID = "gpt-4.1"
27−
28− // GEMINI
29− GEMINI25 ModelID = "gemini-2.5"
30− GRMINI20Flash ModelID = "gemini-2.0-flash"
31−
25+const ( // GEMINI
3226 // GROQ
3327 QWENQwq ModelID = "qwen-qwq"
3428
@@ -37,94 +31,51 @@ const (
3731)
3832
3933const (
40− ProviderOpenAI ModelProvider = "openai"
41− ProviderAnthropic ModelProvider = "anthropic"
42− ProviderBedrock ModelProvider = "bedrock"
43− ProviderGemini ModelProvider = "gemini"
44− ProviderGROQ ModelProvider = "groq"
34+ ProviderBedrock ModelProvider = "bedrock"
35+ ProviderGROQ ModelProvider = "groq"
36+
37+ // ForTests
38+ ProviderMock ModelProvider = "__mock"
4539)
4640
4741var SupportedModels = map[ModelID]Model{
48− // Anthropic
49− Claude35Sonnet: {
50− ID: Claude35Sonnet,
51− Name: "Claude 3.5 Sonnet",
52− Provider: ProviderAnthropic,
53− APIModel: "claude-3-5-sonnet-latest",
54− CostPer1MIn: 3.0,
55− CostPer1MInCached: 3.75,
56− CostPer1MOutCached: 0.30,
57− CostPer1MOut: 15.0,
58− },
59− Claude3Haiku: {
60− ID: Claude3Haiku,
61− Name: "Claude 3 Haiku",
62− Provider: ProviderAnthropic,
63− APIModel: "claude-3-haiku-latest",
64− CostPer1MIn: 0.80,
65− CostPer1MInCached: 1,
66− CostPer1MOutCached: 0.08,
67− CostPer1MOut: 4,
68− },
69− Claude37Sonnet: {
70− ID: Claude37Sonnet,
71− Name: "Claude 3.7 Sonnet",
72− Provider: ProviderAnthropic,
73− APIModel: "claude-3-7-sonnet-latest",
74− CostPer1MIn: 3.0,
75− CostPer1MInCached: 3.75,
76− CostPer1MOutCached: 0.30,
77− CostPer1MOut: 15.0,
78− },
79−
80− // OpenAI
81− GPT41: {
82− ID: GPT41,
83− Name: "GPT-4.1",
84− Provider: ProviderOpenAI,
85− APIModel: "gpt-4.1",
86− CostPer1MIn: 2.00,
87− CostPer1MInCached: 0.50,
88− CostPer1MOutCached: 0,
89− CostPer1MOut: 8.00,
90− },
91−
92− // GEMINI
93− GEMINI25: {
94− ID: GEMINI25,
95− Name: "Gemini 2.5 Pro",
96− Provider: ProviderGemini,
97− APIModel: "gemini-2.5-pro-exp-03-25",
98− CostPer1MIn: 0,
99− CostPer1MInCached: 0,
100− CostPer1MOutCached: 0,
101− CostPer1MOut: 0,
102− },
103−
104− GRMINI20Flash: {
105− ID: GRMINI20Flash,
106− Name: "Gemini 2.0 Flash",
107− Provider: ProviderGemini,
108− APIModel: "gemini-2.0-flash",
109− CostPer1MIn: 0.1,
110− CostPer1MInCached: 0,
111− CostPer1MOutCached: 0.025,
112− CostPer1MOut: 0.4,
113− },
114−
115− // GROQ
116− QWENQwq: {
117− ID: QWENQwq,
118− Name: "Qwen Qwq",
119− Provider: ProviderGROQ,
120− APIModel: "qwen-qwq-32b",
121− CostPer1MIn: 0,
122− CostPer1MInCached: 0,
123− CostPer1MOutCached: 0,
124− CostPer1MOut: 0,
125− },
126−
127− // Bedrock
42+ //
43+ // // GEMINI
44+ // GEMINI25: {
45+ // ID: GEMINI25,
46+ // Name: "Gemini 2.5 Pro",
47+ // Provider: ProviderGemini,
48+ // APIModel: "gemini-2.5-pro-exp-03-25",
49+ // CostPer1MIn: 0,
50+ // CostPer1MInCached: 0,
51+ // CostPer1MOutCached: 0,
52+ // CostPer1MOut: 0,
53+ // },
54+ //
55+ // GRMINI20Flash: {
56+ // ID: GRMINI20Flash,
57+ // Name: "Gemini 2.0 Flash",
58+ // Provider: ProviderGemini,
59+ // APIModel: "gemini-2.0-flash",
60+ // CostPer1MIn: 0.1,
61+ // CostPer1MInCached: 0,
62+ // CostPer1MOutCached: 0.025,
63+ // CostPer1MOut: 0.4,
64+ // },
65+ //
66+ // // GROQ
67+ // QWENQwq: {
68+ // ID: QWENQwq,
69+ // Name: "Qwen Qwq",
70+ // Provider: ProviderGROQ,
71+ // APIModel: "qwen-qwq-32b",
72+ // CostPer1MIn: 0,
73+ // CostPer1MInCached: 0,
74+ // CostPer1MOutCached: 0,
75+ // CostPer1MOut: 0,
76+ // },
77+ //
78+ // // Bedrock
12879 BedrockClaude37Sonnet: {
12980 ID: BedrockClaude37Sonnet,
13081 Name: "Bedrock: Claude 3.7 Sonnet",
@@ -136,3 +87,9 @@ var SupportedModels = map[ModelID]Model{
13687 CostPer1MOut: 15.0,
13788 },
13889}
90+
91+func init() {
92+ maps.Copy(SupportedModels, AnthropicModels)
93+ maps.Copy(SupportedModels, OpenAIModels)
94+ maps.Copy(SupportedModels, GeminiModels)
95+}
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+62−66
@@ -8,80 +8,80 @@ import (
88 "runtime"
99 "time"
1010
11− "github.com/kujtimiihoxha/termai/internal/config"
12− "github.com/kujtimiihoxha/termai/internal/llm/tools"
11+ "github.com/kujtimiihoxha/opencode/internal/config"
12+ "github.com/kujtimiihoxha/opencode/internal/llm/models"
13+ "github.com/kujtimiihoxha/opencode/internal/llm/tools"
1314)
1415
15−func CoderOpenAISystemPrompt() string {
16− basePrompt := `You are termAI, an autonomous CLI-based software engineer. Your job is to reduce user effort by proactively reasoning, inferring context, and solving software engineering tasks end-to-end with minimal prompting.
17−
18−# Your mindset
19−Act like a competent, efficient software engineer who is familiar with large codebases. You should:
20−- Think critically about user requests.
21−- Proactively search the codebase for related information.
22−- Infer likely commands, tools, or conventions.
23−- Write and edit code with minimal user input.
24−- Anticipate next steps (tests, lints, etc.), but never commit unless explicitly told.
25−
26−# Context awareness
27−- Before acting, infer the purpose of a file from its name, directory, and neighboring files.
28−- If a file or function appears malicious, refuse to interact with it or discuss it.
29−- If a termai.md file exists, auto-load it as memory. Offer to update it only if new useful info appears (commands, preferences, structure).
30−
31−# CLI communication
32−- Use GitHub-flavored markdown in monospace font.
33−- Be concise. Never add preambles or postambles unless asked. Max 4 lines per response.
34−- Never explain your code unless asked. Do not narrate actions.
35−- Avoid unnecessary questions. Infer, search, act.
36−
37−# Behavior guidelines
38−- Follow project conventions: naming, formatting, libraries, frameworks.
39−- Before using any library or framework, confirm it’s already used.
40−- Always look at the surrounding code to match existing style.
41−- Do not add comments unless the code is complex or the user asks.
42−
43−# Autonomy rules
44−You are allowed and expected to:
45−- Search for commands, tools, or config files before asking the user.
46−- Run multiple search tool calls concurrently to gather relevant context.
47−- Choose test, lint, and typecheck commands based on package files or scripts.
48−- Offer to store these commands in termai.md if not already present.
49−
50−# Example behavior
51−user: write tests for new feature
52−assistant: [searches for existing test patterns, finds appropriate location, generates test code using existing style, optionally asks to add test command to termai.md]
53−
54−user: how do I typecheck this codebase?
55−assistant: [searches for known commands, infers package manager, checks for scripts or config files]
56−tsc --noEmit
57−
58−user: is X function used anywhere else?
59−assistant: [searches repo for references, returns file paths and lines]
60−
61−# Tool usage
62−- Use parallel calls when possible.
63−- Use file search and content tools before asking the user.
64−- Do not ask the user for information unless it cannot be determined via tools.
65−
66−Never commit changes unless the user explicitly asks you to.`
67−
16+func CoderPrompt(provider models.ModelProvider) string {
17+ basePrompt := baseAnthropicCoderPrompt
18+ switch provider {
19+ case models.ProviderOpenAI:
20+ basePrompt = baseOpenAICoderPrompt
21+ }
6822 envInfo := getEnvironmentInfo()
6923
7024 return fmt.Sprintf("%s\n\n%s\n%s", basePrompt, envInfo, lspInformation())
7125}
7226
73−func CoderAnthropicSystemPrompt() string {
74− basePrompt := `You are termAI, 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.
27+const baseOpenAICoderPrompt = `
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.
71+- Remember the user does not see the full output of tools
72+`
73+
74+const baseAnthropicCoderPrompt = `You are OpenCode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
7575
7676IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
7777
7878# Memory
79−If the current working directory contains a file called termai.md, it will be automatically added to your context. This file serves multiple purposes:
79+If the current working directory contains a file called OpenCode.md, it will be automatically added to your context. This file serves multiple purposes:
80801. Storing frequently used bash commands (build, test, lint, etc.) so you can use them without searching each time
81812. Recording the user's code style preferences (naming conventions, preferred libraries, etc.)
82823. Maintaining useful information about the codebase structure and organization
8383
84−When you spend time searching for commands to typecheck, lint, build, or test, you should ask the user if it's okay to add those commands to termai.md. Similarly, when learning about code style preferences or important codebase information, ask if it's okay to add that to termai.md so you can remember it for next time.
84+When you spend time searching for commands to typecheck, lint, build, or test, you should ask the user if it's okay to add those commands to OpenCode.md. Similarly, when learning about code style preferences or important codebase information, ask if it's okay to add that to OpenCode.md so you can remember it for next time.
8585
8686# Tone and style
8787You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
@@ -131,7 +131,7 @@ assistant: src/foo.c
131131
132132<example>
133133user: write tests for new feature
134−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]
134+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]
135135</example>
136136
137137# Proactiveness
@@ -156,21 +156,17 @@ The user will primarily request you perform software engineering tasks. This inc
1561561. Use the available search tools to understand the codebase and the user's query. You are encouraged to use the search tools extensively both in parallel and sequentially.
1571572. Implement the solution using all tools available to you
1581583. Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
159−4. VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to termai.md so that you will know to run it next time.
159+4. VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to opencode.md so that you will know to run it next time.
160160
161161NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
162162
163163# Tool usage policy
164164- When doing file search, prefer to use the Agent tool in order to reduce context usage.
165165- If you intend to call multiple tools and there are no dependencies between the calls, make all of the independent calls in the same function_calls block.
166+- IMPORTANT: The user does not see the full output of the tool responses, so if you need the output of the tool for the response make sure to summarize it for the user.
166167
167168You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.`
168169
169− envInfo := getEnvironmentInfo()
170−
171− return fmt.Sprintf("%s\n\n%s\n%s", basePrompt, envInfo, lspInformation())
172−}
173−
174170func getEnvironmentInfo() string {
175171 cwd := config.WorkingDirectory()
176172 isGit := isGitRepo(cwd)
internal/llm/prompt/prompt.gonew file+63
@@ -0,0 +1,63 @@
1+package prompt
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+
8+ "github.com/kujtimiihoxha/opencode/internal/config"
9+ "github.com/kujtimiihoxha/opencode/internal/llm/models"
10+)
11+
12+// contextFiles is a list of potential context files to check for
13+var contextFiles = []string{
14+ ".github/copilot-instructions.md",
15+ ".cursorrules",
16+ "CLAUDE.md",
17+ "CLAUDE.local.md",
18+ "opencode.md",
19+ "opencode.local.md",
20+ "OpenCode.md",
21+ "OpenCode.local.md",
22+ "OPENCODE.md",
23+ "OPENCODE.local.md",
24+}
25+
26+func GetAgentPrompt(agentName config.AgentName, provider models.ModelProvider) string {
27+ basePrompt := ""
28+ switch agentName {
29+ case config.AgentCoder:
30+ basePrompt = CoderPrompt(provider)
31+ case config.AgentTitle:
32+ basePrompt = TitlePrompt(provider)
33+ case config.AgentTask:
34+ basePrompt = TaskPrompt(provider)
35+ default:
36+ basePrompt = "You are a helpful assistant"
37+ }
38+
39+ if agentName == config.AgentCoder || agentName == config.AgentTask {
40+ // Add context from project-specific instruction files if they exist
41+ contextContent := getContextFromFiles()
42+ if contextContent != "" {
43+ return fmt.Sprintf("%s\n\n# Project-Specific Context\n%s", basePrompt, contextContent)
44+ }
45+ }
46+ return basePrompt
47+}
48+
49+// getContextFromFiles checks for the existence of context files and returns their content
50+func getContextFromFiles() string {
51+ workDir := config.WorkingDirectory()
52+ var contextContent string
53+
54+ for _, file := range contextFiles {
55+ filePath := filepath.Join(workDir, file)
56+ content, err := os.ReadFile(filePath)
57+ if err == nil {
58+ contextContent += fmt.Sprintf("\n%s\n", string(content))
59+ }
60+ }
61+
62+ return contextContent
63+}
internal/llm/prompt/task.go+4−3
@@ -2,11 +2,12 @@ package prompt
22
33import (
44 "fmt"
5−)
65
7−func TaskAgentSystemPrompt() string {
8− agentPrompt := `You are an agent for termAI. Given the user's prompt, you should use the tools available to you to answer the user's question.
6+ "github.com/kujtimiihoxha/opencode/internal/llm/models"
7+)
98
9+func TaskPrompt(_ models.ModelProvider) string {
10+ agentPrompt := `You are an agent for OpenCode. Given the user's prompt, you should use the tools available to you to answer the user's question.
1011Notes:
11121. IMPORTANT: You should be concise, direct, and to the point, since your responses will be displayed on a command line interface. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...".
12132. When relevant, share file names and code snippets relevant to the query
internal/llm/prompt/title.go+4−1
@@ -1,9 +1,12 @@
11package prompt
22
3−func TitlePrompt() string {
3+import "github.com/kujtimiihoxha/opencode/internal/llm/models"
4+
5+func TitlePrompt(_ models.ModelProvider) string {
46 return `you will generate a short title based on the first message a user begins a conversation with
57- ensure it is not more than 50 characters long
68- the title should be a summary of the user's message
9+- it should be one line long
710- do not use quotes or colons
811- the entire text you return will be used as the title`
912}
internal/llm/provider/anthropic.go+316−266
@@ -12,192 +12,275 @@ import (
1212 "github.com/anthropics/anthropic-sdk-go"
1313 "github.com/anthropics/anthropic-sdk-go/bedrock"
1414 "github.com/anthropics/anthropic-sdk-go/option"
15− "github.com/kujtimiihoxha/termai/internal/llm/models"
16− "github.com/kujtimiihoxha/termai/internal/llm/tools"
17− "github.com/kujtimiihoxha/termai/internal/message"
15+ "github.com/kujtimiihoxha/opencode/internal/config"
16+ "github.com/kujtimiihoxha/opencode/internal/llm/tools"
17+ "github.com/kujtimiihoxha/opencode/internal/logging"
18+ "github.com/kujtimiihoxha/opencode/internal/message"
1819)
1920
20−type anthropicProvider struct {
21− client anthropic.Client
22− model models.Model
23− maxTokens int64
24− apiKey string
25− systemMessage string
26− useBedrock bool
27− disableCache bool
21+type anthropicOptions struct {
22+ useBedrock bool
23+ disableCache bool
24+ shouldThink func(userMessage string) bool
2825}
2926
30−type AnthropicOption func(*anthropicProvider)
27+type AnthropicOption func(*anthropicOptions)
3128
32−func WithAnthropicSystemMessage(message string) AnthropicOption {
33− return func(a *anthropicProvider) {
34− a.systemMessage = message
35− }
29+type anthropicClient struct {
30+ providerOptions providerClientOptions
31+ options anthropicOptions
32+ client anthropic.Client
3633}
3734
38−func WithAnthropicMaxTokens(maxTokens int64) AnthropicOption {
39− return func(a *anthropicProvider) {
40− a.maxTokens = maxTokens
41− }
42−}
35+type AnthropicClient ProviderClient
4336
44−func WithAnthropicModel(model models.Model) AnthropicOption {
45− return func(a *anthropicProvider) {
46− a.model = model
37+func newAnthropicClient(opts providerClientOptions) AnthropicClient {
38+ anthropicOpts := anthropicOptions{}
39+ for _, o := range opts.anthropicOptions {
40+ o(&anthropicOpts)
4741 }
48−}
4942
50−func WithAnthropicKey(apiKey string) AnthropicOption {
51− return func(a *anthropicProvider) {
52− a.apiKey = apiKey
43+ anthropicClientOptions := []option.RequestOption{}
44+ if opts.apiKey != "" {
45+ anthropicClientOptions = append(anthropicClientOptions, option.WithAPIKey(opts.apiKey))
5346 }
54−}
55−
56−func WithAnthropicBedrock() AnthropicOption {
57− return func(a *anthropicProvider) {
58− a.useBedrock = true
47+ if anthropicOpts.useBedrock {
48+ anthropicClientOptions = append(anthropicClientOptions, bedrock.WithLoadDefaultConfig(context.Background()))
5949 }
60−}
6150
62−func WithAnthropicDisableCache() AnthropicOption {
63− return func(a *anthropicProvider) {
64− a.disableCache = true
51+ client := anthropic.NewClient(anthropicClientOptions...)
52+ return &anthropicClient{
53+ providerOptions: opts,
54+ options: anthropicOpts,
55+ client: client,
6556 }
6657}
6758
68−func NewAnthropicProvider(opts ...AnthropicOption) (Provider, error) {
69− provider := &anthropicProvider{
70− maxTokens: 1024,
71− }
59+func (a *anthropicClient) convertMessages(messages []message.Message) (anthropicMessages []anthropic.MessageParam) {
60+ for i, msg := range messages {
61+ cache := false
62+ if i > len(messages)-3 {
63+ cache = true
64+ }
65+ switch msg.Role {
66+ case message.User:
67+ content := anthropic.NewTextBlock(msg.Content().String())
68+ if cache && !a.options.disableCache {
69+ content.OfRequestTextBlock.CacheControl = anthropic.CacheControlEphemeralParam{
70+ Type: "ephemeral",
71+ }
72+ }
73+ anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(content))
7274
73− for _, opt := range opts {
74− opt(provider)
75− }
75+ case message.Assistant:
76+ blocks := []anthropic.ContentBlockParamUnion{}
77+ if msg.Content().String() != "" {
78+ content := anthropic.NewTextBlock(msg.Content().String())
79+ if cache && !a.options.disableCache {
80+ content.OfRequestTextBlock.CacheControl = anthropic.CacheControlEphemeralParam{
81+ Type: "ephemeral",
82+ }
83+ }
84+ blocks = append(blocks, content)
85+ }
7686
77− if provider.systemMessage == "" {
78− return nil, errors.New("system message is required")
79− }
87+ for _, toolCall := range msg.ToolCalls() {
88+ var inputMap map[string]any
89+ err := json.Unmarshal([]byte(toolCall.Input), &inputMap)
90+ if err != nil {
91+ continue
92+ }
93+ blocks = append(blocks, anthropic.ContentBlockParamOfRequestToolUseBlock(toolCall.ID, inputMap, toolCall.Name))
94+ }
8095
81− anthropicOptions := []option.RequestOption{}
96+ if len(blocks) == 0 {
97+ logging.Warn("There is a message without content, investigate, this should not happen")
98+ continue
99+ }
100+ anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...))
82101
83− if provider.apiKey != "" {
84− anthropicOptions = append(anthropicOptions, option.WithAPIKey(provider.apiKey))
85− }
86− if provider.useBedrock {
87− anthropicOptions = append(anthropicOptions, bedrock.WithLoadDefaultConfig(context.Background()))
102+ case message.Tool:
103+ results := make([]anthropic.ContentBlockParamUnion, len(msg.ToolResults()))
104+ for i, toolResult := range msg.ToolResults() {
105+ results[i] = anthropic.NewToolResultBlock(toolResult.ToolCallID, toolResult.Content, toolResult.IsError)
106+ }
107+ anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(results...))
108+ }
88109 }
89−
90− provider.client = anthropic.NewClient(anthropicOptions...)
91− return provider, nil
110+ return
92111}
93112
94−func (a *anthropicProvider) SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
95− messages = cleanupMessages(messages)
96− anthropicMessages := a.convertToAnthropicMessages(messages)
97− anthropicTools := a.convertToAnthropicTools(tools)
98−
99− response, err := a.client.Messages.New(
100− ctx,
101− anthropic.MessageNewParams{
102− Model: anthropic.Model(a.model.APIModel),
103− MaxTokens: a.maxTokens,
104− Temperature: anthropic.Float(0),
105− Messages: anthropicMessages,
106− Tools: anthropicTools,
107− System: []anthropic.TextBlockParam{
108− {
109− Text: a.systemMessage,
110− CacheControl: anthropic.CacheControlEphemeralParam{
111− Type: "ephemeral",
112− },
113− },
113+func (a *anthropicClient) convertTools(tools []tools.BaseTool) []anthropic.ToolUnionParam {
114+ anthropicTools := make([]anthropic.ToolUnionParam, len(tools))
115+
116+ for i, tool := range tools {
117+ info := tool.Info()
118+ toolParam := anthropic.ToolParam{
119+ Name: info.Name,
120+ Description: anthropic.String(info.Description),
121+ InputSchema: anthropic.ToolInputSchemaParam{
122+ Properties: info.Parameters,
123+ // TODO: figure out how we can tell claude the required fields?
114124 },
115− },
116− )
117− if err != nil {
118− return nil, err
119− }
125+ }
120126
121− content := ""
122− for _, block := range response.Content {
123− if text, ok := block.AsAny().(anthropic.TextBlock); ok {
124− content += text.Text
127+ if i == len(tools)-1 && !a.options.disableCache {
128+ toolParam.CacheControl = anthropic.CacheControlEphemeralParam{
129+ Type: "ephemeral",
130+ }
125131 }
126− }
127132
128− toolCalls := a.extractToolCalls(response.Content)
129− tokenUsage := a.extractTokenUsage(response.Usage)
133+ anthropicTools[i] = anthropic.ToolUnionParam{OfTool: &toolParam}
134+ }
130135
131− return &ProviderResponse{
132− Content: content,
133− ToolCalls: toolCalls,
134− Usage: tokenUsage,
135− }, nil
136+ return anthropicTools
136137}
137138
138−func (a *anthropicProvider) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (<-chan ProviderEvent, error) {
139− messages = cleanupMessages(messages)
140− anthropicMessages := a.convertToAnthropicMessages(messages)
141− anthropicTools := a.convertToAnthropicTools(tools)
139+func (a *anthropicClient) finishReason(reason string) message.FinishReason {
140+ switch reason {
141+ case "end_turn":
142+ return message.FinishReasonEndTurn
143+ case "max_tokens":
144+ return message.FinishReasonMaxTokens
145+ case "tool_use":
146+ return message.FinishReasonToolUse
147+ case "stop_sequence":
148+ return message.FinishReasonEndTurn
149+ default:
150+ return message.FinishReasonUnknown
151+ }
152+}
142153
154+func (a *anthropicClient) preparedMessages(messages []anthropic.MessageParam, tools []anthropic.ToolUnionParam) anthropic.MessageNewParams {
143155 var thinkingParam anthropic.ThinkingConfigParamUnion
144156 lastMessage := messages[len(messages)-1]
157+ isUser := lastMessage.Role == anthropic.MessageParamRoleUser
158+ messageContent := ""
145159 temperature := anthropic.Float(0)
146− if lastMessage.Role == message.User && strings.Contains(strings.ToLower(lastMessage.Content().String()), "think") {
147− thinkingParam = anthropic.ThinkingConfigParamUnion{
148− OfThinkingConfigEnabled: &anthropic.ThinkingConfigEnabledParam{
149− BudgetTokens: int64(float64(a.maxTokens) * 0.8),
150− Type: "enabled",
151− },
160+ if isUser {
161+ for _, m := range lastMessage.Content {
162+ if m.OfRequestTextBlock != nil && m.OfRequestTextBlock.Text != "" {
163+ messageContent = m.OfRequestTextBlock.Text
164+ }
165+ }
166+ if messageContent != "" && a.options.shouldThink != nil && a.options.shouldThink(messageContent) {
167+ thinkingParam = anthropic.ThinkingConfigParamUnion{
168+ OfThinkingConfigEnabled: &anthropic.ThinkingConfigEnabledParam{
169+ BudgetTokens: int64(float64(a.providerOptions.maxTokens) * 0.8),
170+ Type: "enabled",
171+ },
172+ }
173+ temperature = anthropic.Float(1)
152174 }
153− temperature = anthropic.Float(1)
154175 }
155176
156− eventChan := make(chan ProviderEvent)
177+ return anthropic.MessageNewParams{
178+ Model: anthropic.Model(a.providerOptions.model.APIModel),
179+ MaxTokens: a.providerOptions.maxTokens,
180+ Temperature: temperature,
181+ Messages: messages,
182+ Tools: tools,
183+ Thinking: thinkingParam,
184+ System: []anthropic.TextBlockParam{
185+ {
186+ Text: a.providerOptions.systemMessage,
187+ CacheControl: anthropic.CacheControlEphemeralParam{
188+ Type: "ephemeral",
189+ },
190+ },
191+ },
192+ }
193+}
157194
158− go func() {
159− defer close(eventChan)
195+func (a *anthropicClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (resposne *ProviderResponse, err error) {
196+ preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
197+ cfg := config.Get()
198+ if cfg.Debug {
199+ // jsonData, _ := json.Marshal(preparedMessages)
200+ // logging.Debug("Prepared messages", "messages", string(jsonData))
201+ }
202+ attempts := 0
203+ for {
204+ attempts++
205+ anthropicResponse, err := a.client.Messages.New(
206+ ctx,
207+ preparedMessages,
208+ )
209+ // If there is an error we are going to see if we can retry the call
210+ if err != nil {
211+ retry, after, retryErr := a.shouldRetry(attempts, err)
212+ if retryErr != nil {
213+ return nil, retryErr
214+ }
215+ if retry {
216+ logging.WarnPersist("Retrying due to rate limit... attempt %d of %d", logging.PersistTimeArg, time.Millisecond*time.Duration(after+100))
217+ select {
218+ case <-ctx.Done():
219+ return nil, ctx.Err()
220+ case <-time.After(time.Duration(after) * time.Millisecond):
221+ continue
222+ }
223+ }
224+ return nil, retryErr
225+ }
160226
161− const maxRetries = 8
162− attempts := 0
227+ content := ""
228+ for _, block := range anthropicResponse.Content {
229+ if text, ok := block.AsAny().(anthropic.TextBlock); ok {
230+ content += text.Text
231+ }
232+ }
163233
164− for {
234+ return &ProviderResponse{
235+ Content: content,
236+ ToolCalls: a.toolCalls(*anthropicResponse),
237+ Usage: a.usage(*anthropicResponse),
238+ }, nil
239+ }
240+}
165241
242+func (a *anthropicClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
243+ preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
244+ cfg := config.Get()
245+ if cfg.Debug {
246+ // jsonData, _ := json.Marshal(preparedMessages)
247+ // logging.Debug("Prepared messages", "messages", string(jsonData))
248+ }
249+ attempts := 0
250+ eventChan := make(chan ProviderEvent)
251+ go func() {
252+ for {
166253 attempts++
167−
168− stream := a.client.Messages.NewStreaming(
254+ anthropicStream := a.client.Messages.NewStreaming(
169255 ctx,
170− anthropic.MessageNewParams{
171− Model: anthropic.Model(a.model.APIModel),
172− MaxTokens: a.maxTokens,
173− Temperature: temperature,
174− Messages: anthropicMessages,
175− Tools: anthropicTools,
176− Thinking: thinkingParam,
177− System: []anthropic.TextBlockParam{
178− {
179− Text: a.systemMessage,
180− CacheControl: anthropic.CacheControlEphemeralParam{
181− Type: "ephemeral",
182− },
183− },
184− },
185− },
256+ preparedMessages,
186257 )
187−
188258 accumulatedMessage := anthropic.Message{}
189259
190− for stream.Next() {
191− event := stream.Current()
260+ currentToolCallID := ""
261+ for anthropicStream.Next() {
262+ event := anthropicStream.Current()
192263 err := accumulatedMessage.Accumulate(event)
193264 if err != nil {
194265 eventChan <- ProviderEvent{Type: EventError, Error: err}
195− return // Don't retry on accumulation errors
266+ continue
196267 }
197268
198269 switch event := event.AsAny().(type) {
199270 case anthropic.ContentBlockStartEvent:
200− eventChan <- ProviderEvent{Type: EventContentStart}
271+ if event.ContentBlock.Type == "text" {
272+ eventChan <- ProviderEvent{Type: EventContentStart}
273+ } else if event.ContentBlock.Type == "tool_use" {
274+ currentToolCallID = event.ContentBlock.ID
275+ eventChan <- ProviderEvent{
276+ Type: EventToolUseStart,
277+ ToolCall: &message.ToolCall{
278+ ID: event.ContentBlock.ID,
279+ Name: event.ContentBlock.Name,
280+ Finished: false,
281+ },
282+ }
283+ }
201284
202285 case anthropic.ContentBlockDeltaEvent:
203286 if event.Delta.Type == "thinking_delta" && event.Delta.Thinking != "" {
@@ -210,10 +293,30 @@ func (a *anthropicProvider) StreamResponse(ctx context.Context, messages []messa
210293 Type: EventContentDelta,
211294 Content: event.Delta.Text,
212295 }
296+ } else if event.Delta.Type == "input_json_delta" {
297+ if currentToolCallID != "" {
298+ eventChan <- ProviderEvent{
299+ Type: EventToolUseDelta,
300+ ToolCall: &message.ToolCall{
301+ ID: currentToolCallID,
302+ Finished: false,
303+ Input: event.Delta.JSON.PartialJSON.Raw(),
304+ },
305+ }
306+ }
213307 }
214−
215308 case anthropic.ContentBlockStopEvent:
216− eventChan <- ProviderEvent{Type: EventContentStop}
309+ if currentToolCallID != "" {
310+ eventChan <- ProviderEvent{
311+ Type: EventToolUseStop,
312+ ToolCall: &message.ToolCall{
313+ ID: currentToolCallID,
314+ },
315+ }
316+ currentToolCallID = ""
317+ } else {
318+ eventChan <- ProviderEvent{Type: EventContentStop}
319+ }
217320
218321 case anthropic.MessageStopEvent:
219322 content := ""
@@ -223,91 +326,95 @@ func (a *anthropicProvider) StreamResponse(ctx context.Context, messages []messa
223326 }
224327 }
225328
226− toolCalls := a.extractToolCalls(accumulatedMessage.Content)
227− tokenUsage := a.extractTokenUsage(accumulatedMessage.Usage)
228−
229329 eventChan <- ProviderEvent{
230330 Type: EventComplete,
231331 Response: &ProviderResponse{
232332 Content: content,
233− ToolCalls: toolCalls,
234− Usage: tokenUsage,
235− FinishReason: string(accumulatedMessage.StopReason),
333+ ToolCalls: a.toolCalls(accumulatedMessage),
334+ Usage: a.usage(accumulatedMessage),
335+ FinishReason: a.finishReason(string(accumulatedMessage.StopReason)),
236336 },
237337 }
238338 }
239339 }
240340
241− err := stream.Err()
341+ err := anthropicStream.Err()
242342 if err == nil || errors.Is(err, io.EOF) {
343+ close(eventChan)
243344 return
244345 }
245−
246− var apierr *anthropic.Error
247− if !errors.As(err, &apierr) {
248− eventChan <- ProviderEvent{Type: EventError, Error: err}
346+ // If there is an error we are going to see if we can retry the call
347+ retry, after, retryErr := a.shouldRetry(attempts, err)
348+ if retryErr != nil {
349+ eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
350+ close(eventChan)
249351 return
250352 }
251−
252− if apierr.StatusCode != 429 && apierr.StatusCode != 529 {
253− eventChan <- ProviderEvent{Type: EventError, Error: err}
254− return
255− }
256−
257− if attempts > maxRetries {
258− eventChan <- ProviderEvent{
259− Type: EventError,
260− Error: errors.New("maximum retry attempts reached for rate limit (429)"),
261− }
262− return
263− }
264−
265− retryMs := 0
266− retryAfterValues := apierr.Response.Header.Values("Retry-After")
267− if len(retryAfterValues) > 0 {
268− var retryAfterSec int
269− if _, err := fmt.Sscanf(retryAfterValues[0], "%d", &retryAfterSec); err == nil {
270− retryMs = retryAfterSec * 1000
271− eventChan <- ProviderEvent{
272− Type: EventWarning,
273− Info: fmt.Sprintf("[Rate limited: waiting %d seconds as specified by API]", retryAfterSec),
353+ if retry {
354+ logging.WarnPersist("Retrying due to rate limit... attempt %d of %d", logging.PersistTimeArg, time.Millisecond*time.Duration(after+100))
355+ select {
356+ case <-ctx.Done():
357+ // context cancelled
358+ if ctx.Err() != nil {
359+ eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
274360 }
361+ close(eventChan)
362+ return
363+ case <-time.After(time.Duration(after) * time.Millisecond):
364+ continue
275365 }
276− } else {
277− eventChan <- ProviderEvent{
278− Type: EventWarning,
279− Info: fmt.Sprintf("[Retrying due to rate limit... attempt %d of %d]", attempts, maxRetries),
280− }
281−
282− backoffMs := 2000 * (1 << (attempts - 1))
283− jitterMs := int(float64(backoffMs) * 0.2)
284− retryMs = backoffMs + jitterMs
285366 }
286− select {
287− case <-ctx.Done():
367+ if ctx.Err() != nil {
288368 eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
289− return
290− case <-time.After(time.Duration(retryMs) * time.Millisecond):
291− continue
292369 }
293370
371+ close(eventChan)
372+ return
294373 }
295374 }()
375+ return eventChan
376+}
377+
378+func (a *anthropicClient) shouldRetry(attempts int, err error) (bool, int64, error) {
379+ var apierr *anthropic.Error
380+ if !errors.As(err, &apierr) {
381+ return false, 0, err
382+ }
296383
297− return eventChan, nil
384+ if apierr.StatusCode != 429 && apierr.StatusCode != 529 {
385+ return false, 0, err
386+ }
387+
388+ if attempts > maxRetries {
389+ return false, 0, fmt.Errorf("maximum retry attempts reached for rate limit: %d retries", maxRetries)
390+ }
391+
392+ retryMs := 0
393+ retryAfterValues := apierr.Response.Header.Values("Retry-After")
394+
395+ backoffMs := 2000 * (1 << (attempts - 1))
396+ jitterMs := int(float64(backoffMs) * 0.2)
397+ retryMs = backoffMs + jitterMs
398+ if len(retryAfterValues) > 0 {
399+ if _, err := fmt.Sscanf(retryAfterValues[0], "%d", &retryMs); err == nil {
400+ retryMs = retryMs * 1000
401+ }
402+ }
403+ return true, int64(retryMs), nil
298404}
299405
300−func (a *anthropicProvider) extractToolCalls(content []anthropic.ContentBlockUnion) []message.ToolCall {
406+func (a *anthropicClient) toolCalls(msg anthropic.Message) []message.ToolCall {
301407 var toolCalls []message.ToolCall
302408
303− for _, block := range content {
409+ for _, block := range msg.Content {
304410 switch variant := block.AsAny().(type) {
305411 case anthropic.ToolUseBlock:
306412 toolCall := message.ToolCall{
307− ID: variant.ID,
308− Name: variant.Name,
309− Input: string(variant.Input),
310− Type: string(variant.Type),
413+ ID: variant.ID,
414+ Name: variant.Name,
415+ Input: string(variant.Input),
416+ Type: string(variant.Type),
417+ Finished: true,
311418 }
312419 toolCalls = append(toolCalls, toolCall)
313420 }
@@ -316,90 +423,33 @@ func (a *anthropicProvider) extractToolCalls(content []anthropic.ContentBlockUni
316423 return toolCalls
317424}
318425
319−func (a *anthropicProvider) extractTokenUsage(usage anthropic.Usage) TokenUsage {
426+func (a *anthropicClient) usage(msg anthropic.Message) TokenUsage {
320427 return TokenUsage{
321− InputTokens: usage.InputTokens,
322− OutputTokens: usage.OutputTokens,
323− CacheCreationTokens: usage.CacheCreationInputTokens,
324− CacheReadTokens: usage.CacheReadInputTokens,
428+ InputTokens: msg.Usage.InputTokens,
429+ OutputTokens: msg.Usage.OutputTokens,
430+ CacheCreationTokens: msg.Usage.CacheCreationInputTokens,
431+ CacheReadTokens: msg.Usage.CacheReadInputTokens,
325432 }
326433}
327434
328−func (a *anthropicProvider) convertToAnthropicTools(tools []tools.BaseTool) []anthropic.ToolUnionParam {
329− anthropicTools := make([]anthropic.ToolUnionParam, len(tools))
330−
331− for i, tool := range tools {
332− info := tool.Info()
333− toolParam := anthropic.ToolParam{
334− Name: info.Name,
335− Description: anthropic.String(info.Description),
336− InputSchema: anthropic.ToolInputSchemaParam{
337− Properties: info.Parameters,
338− },
339− }
340−
341− if i == len(tools)-1 && !a.disableCache {
342− toolParam.CacheControl = anthropic.CacheControlEphemeralParam{
343− Type: "ephemeral",
344− }
345− }
346−
347− anthropicTools[i] = anthropic.ToolUnionParam{OfTool: &toolParam}
435+func WithAnthropicBedrock(useBedrock bool) AnthropicOption {
436+ return func(options *anthropicOptions) {
437+ options.useBedrock = useBedrock
348438 }
349−
350− return anthropicTools
351439}
352440
353−func (a *anthropicProvider) convertToAnthropicMessages(messages []message.Message) []anthropic.MessageParam {
354− anthropicMessages := make([]anthropic.MessageParam, 0, len(messages))
355− cachedBlocks := 0
356−
357− for _, msg := range messages {
358− switch msg.Role {
359− case message.User:
360− content := anthropic.NewTextBlock(msg.Content().String())
361− if cachedBlocks < 2 && !a.disableCache {
362− content.OfRequestTextBlock.CacheControl = anthropic.CacheControlEphemeralParam{
363− Type: "ephemeral",
364− }
365− cachedBlocks++
366− }
367− anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(content))
368−
369− case message.Assistant:
370− blocks := []anthropic.ContentBlockParamUnion{}
371− if msg.Content().String() != "" {
372− content := anthropic.NewTextBlock(msg.Content().String())
373− if cachedBlocks < 2 && !a.disableCache {
374− content.OfRequestTextBlock.CacheControl = anthropic.CacheControlEphemeralParam{
375− Type: "ephemeral",
376− }
377− cachedBlocks++
378− }
379− blocks = append(blocks, content)
380− }
381−
382− for _, toolCall := range msg.ToolCalls() {
383− var inputMap map[string]any
384− err := json.Unmarshal([]byte(toolCall.Input), &inputMap)
385− if err != nil {
386− continue
387− }
388− blocks = append(blocks, anthropic.ContentBlockParamOfRequestToolUseBlock(toolCall.ID, inputMap, toolCall.Name))
389− }
441+func WithAnthropicDisableCache() AnthropicOption {
442+ return func(options *anthropicOptions) {
443+ options.disableCache = true
444+ }
445+}
390446
391− if len(blocks) > 0 {
392− anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...))
393− }
447+func DefaultShouldThinkFn(s string) bool {
448+ return strings.Contains(strings.ToLower(s), "think")
449+}
394450
395− case message.Tool:
396− results := make([]anthropic.ContentBlockParamUnion, len(msg.ToolResults()))
397− for i, toolResult := range msg.ToolResults() {
398− results[i] = anthropic.NewToolResultBlock(toolResult.ToolCallID, toolResult.Content, toolResult.IsError)
399− }
400− anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(results...))
401− }
451+func WithAnthropicShouldThinkFn(fn func(string) bool) AnthropicOption {
452+ return func(options *anthropicOptions) {
453+ options.shouldThink = fn
402454 }
403−
404− return anthropicMessages
405455}
internal/llm/provider/bedrock.go+59−46
@@ -7,33 +7,29 @@ import (
77 "os"
88 "strings"
99
10− "github.com/kujtimiihoxha/termai/internal/llm/models"
11− "github.com/kujtimiihoxha/termai/internal/llm/tools"
12− "github.com/kujtimiihoxha/termai/internal/message"
10+ "github.com/kujtimiihoxha/opencode/internal/llm/tools"
11+ "github.com/kujtimiihoxha/opencode/internal/message"
1312)
1413
15−type bedrockProvider struct {
16− childProvider Provider
17− model models.Model
18− maxTokens int64
19− systemMessage string
14+type bedrockOptions struct {
15+ // Bedrock specific options can be added here
2016}
2117
22−func (b *bedrockProvider) SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
23− return b.childProvider.SendMessages(ctx, messages, tools)
24−}
18+type BedrockOption func(*bedrockOptions)
2519
26−func (b *bedrockProvider) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (<-chan ProviderEvent, error) {
27− return b.childProvider.StreamResponse(ctx, messages, tools)
20+type bedrockClient struct {
21+ providerOptions providerClientOptions
22+ options bedrockOptions
23+ childProvider ProviderClient
2824}
2925
30−func NewBedrockProvider(opts ...BedrockOption) (Provider, error) {
31− provider := &bedrockProvider{}
32− for _, opt := range opts {
33− opt(provider)
34− }
26+type BedrockClient ProviderClient
27+
28+func newBedrockClient(opts providerClientOptions) BedrockClient {
29+ bedrockOpts := bedrockOptions{}
30+ // Apply bedrock specific options if they are added in the future
3531
36− // based on the AWS region prefix the model name with, us, eu, ap, sa, etc.
32+ // Get AWS region from environment
3733 region := os.Getenv("AWS_REGION")
3834 if region == "" {
3935 region = os.Getenv("AWS_DEFAULT_REGION")
@@ -43,45 +39,62 @@ func NewBedrockProvider(opts ...BedrockOption) (Provider, error) {
4339 region = "us-east-1" // default region
4440 }
4541 if len(region) < 2 {
46− return nil, errors.New("AWS_REGION or AWS_DEFAULT_REGION environment variable is invalid")
42+ return &bedrockClient{
43+ providerOptions: opts,
44+ options: bedrockOpts,
45+ childProvider: nil, // Will cause an error when used
46+ }
4747 }
48+
49+ // Prefix the model name with region
4850 regionPrefix := region[:2]
49− provider.model.APIModel = fmt.Sprintf("%s.%s", regionPrefix, provider.model.APIModel)
51+ modelName := opts.model.APIModel
52+ opts.model.APIModel = fmt.Sprintf("%s.%s", regionPrefix, modelName)
5053
51− if strings.Contains(string(provider.model.APIModel), "anthropic") {
52− anthropic, err := NewAnthropicProvider(
53− WithAnthropicModel(provider.model),
54− WithAnthropicMaxTokens(provider.maxTokens),
55− WithAnthropicSystemMessage(provider.systemMessage),
56− WithAnthropicBedrock(),
54+ // Determine which provider to use based on the model
55+ if strings.Contains(string(opts.model.APIModel), "anthropic") {
56+ // Create Anthropic client with Bedrock configuration
57+ anthropicOpts := opts
58+ anthropicOpts.anthropicOptions = append(anthropicOpts.anthropicOptions,
59+ WithAnthropicBedrock(true),
5760 WithAnthropicDisableCache(),
5861 )
59− provider.childProvider = anthropic
60− if err != nil {
61− return nil, err
62+ return &bedrockClient{
63+ providerOptions: opts,
64+ options: bedrockOpts,
65+ childProvider: newAnthropicClient(anthropicOpts),
6266 }
63− } else {
64− return nil, errors.New("unsupported model for bedrock provider")
6567 }
66− return provider, nil
67−}
68−
69−type BedrockOption func(*bedrockProvider)
7068
71−func WithBedrockSystemMessage(message string) BedrockOption {
72− return func(a *bedrockProvider) {
73− a.systemMessage = message
69+ // Return client with nil childProvider if model is not supported
70+ // This will cause an error when used
71+ return &bedrockClient{
72+ providerOptions: opts,
73+ options: bedrockOpts,
74+ childProvider: nil,
7475 }
7576}
7677
77−func WithBedrockMaxTokens(maxTokens int64) BedrockOption {
78− return func(a *bedrockProvider) {
79− a.maxTokens = maxTokens
78+func (b *bedrockClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
79+ if b.childProvider == nil {
80+ return nil, errors.New("unsupported model for bedrock provider")
8081 }
82+ return b.childProvider.send(ctx, messages, tools)
8183}
8284
83−func WithBedrockModel(model models.Model) BedrockOption {
84− return func(a *bedrockProvider) {
85− a.model = model
85+func (b *bedrockClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
86+ eventChan := make(chan ProviderEvent)
87+
88+ if b.childProvider == nil {
89+ go func() {
90+ eventChan <- ProviderEvent{
91+ Type: EventError,
92+ Error: errors.New("unsupported model for bedrock provider"),
93+ }
94+ close(eventChan)
95+ }()
96+ return eventChan
8697 }
87−}
98+
99+ return b.childProvider.stream(ctx, messages, tools)
100+}
internal/llm/provider/gemini.go+351−185
@@ -4,80 +4,68 @@ import (
44 "context"
55 "encoding/json"
66 "errors"
7+ "fmt"
8+ "io"
9+ "strings"
10+ "time"
711
812 "github.com/google/generative-ai-go/genai"
913 "github.com/google/uuid"
10− "github.com/kujtimiihoxha/termai/internal/llm/models"
11− "github.com/kujtimiihoxha/termai/internal/llm/tools"
12− "github.com/kujtimiihoxha/termai/internal/message"
14+ "github.com/kujtimiihoxha/opencode/internal/config"
15+ "github.com/kujtimiihoxha/opencode/internal/llm/tools"
16+ "github.com/kujtimiihoxha/opencode/internal/logging"
17+ "github.com/kujtimiihoxha/opencode/internal/message"
1318 "google.golang.org/api/iterator"
1419 "google.golang.org/api/option"
1520)
1621
17−type geminiProvider struct {
18− client *genai.Client
19− model models.Model
20− maxTokens int32
21− apiKey string
22− systemMessage string
22+type geminiOptions struct {
23+ disableCache bool
2324}
2425
25−type GeminiOption func(*geminiProvider)
26+type GeminiOption func(*geminiOptions)
2627
27−func NewGeminiProvider(ctx context.Context, opts ...GeminiOption) (Provider, error) {
28− provider := &geminiProvider{
29− maxTokens: 5000,
30− }
28+type geminiClient struct {
29+ providerOptions providerClientOptions
30+ options geminiOptions
31+ client *genai.Client
32+}
3133
32− for _, opt := range opts {
33− opt(provider)
34− }
34+type GeminiClient ProviderClient
3535
36− if provider.systemMessage == "" {
37− return nil, errors.New("system message is required")
36+func newGeminiClient(opts providerClientOptions) GeminiClient {
37+ geminiOpts := geminiOptions{}
38+ for _, o := range opts.geminiOptions {
39+ o(&geminiOpts)
3840 }
3941
40− client, err := genai.NewClient(ctx, option.WithAPIKey(provider.apiKey))
42+ client, err := genai.NewClient(context.Background(), option.WithAPIKey(opts.apiKey))
4143 if err != nil {
42− return nil, err
43− }
44− provider.client = client
45−
46− return provider, nil
47−}
48−
49−func WithGeminiSystemMessage(message string) GeminiOption {
50− return func(p *geminiProvider) {
51− p.systemMessage = message
44+ logging.Error("Failed to create Gemini client", "error", err)
45+ return nil
5246 }
53−}
5447
55−func WithGeminiMaxTokens(maxTokens int32) GeminiOption {
56− return func(p *geminiProvider) {
57− p.maxTokens = maxTokens
48+ return &geminiClient{
49+ providerOptions: opts,
50+ options: geminiOpts,
51+ client: client,
5852 }
5953}
6054
61−func WithGeminiModel(model models.Model) GeminiOption {
62− return func(p *geminiProvider) {
63− p.model = model
64− }
65−}
66−
67−func WithGeminiKey(apiKey string) GeminiOption {
68− return func(p *geminiProvider) {
69− p.apiKey = apiKey
70− }
71−}
55+func (g *geminiClient) convertMessages(messages []message.Message) []*genai.Content {
56+ var history []*genai.Content
7257
73−func (p *geminiProvider) Close() {
74− if p.client != nil {
75− p.client.Close()
76− }
77−}
58+ // Add system message first
59+ history = append(history, &genai.Content{
60+ Parts: []genai.Part{genai.Text(g.providerOptions.systemMessage)},
61+ Role: "user",
62+ })
7863
79−func (p *geminiProvider) convertToGeminiHistory(messages []message.Message) []*genai.Content {
80− var history []*genai.Content
64+ // Add a system response to acknowledge the system message
65+ history = append(history, &genai.Content{
66+ Parts: []genai.Part{genai.Text("I'll help you with that.")},
67+ Role: "model",
68+ })
8169
8270 for _, msg := range messages {
8371 switch msg.Role {
@@ -86,6 +74,7 @@ func (p *geminiProvider) convertToGeminiHistory(messages []message.Message) []*g
8674 Parts: []genai.Part{genai.Text(msg.Content().String())},
8775 Role: "user",
8876 })
77+
8978 case message.Assistant:
9079 content := &genai.Content{
9180 Role: "model",
@@ -107,6 +96,7 @@ func (p *geminiProvider) convertToGeminiHistory(messages []message.Message) []*g
10796 }
10897
10998 history = append(history, content)
99+
110100 case message.Tool:
111101 for _, result := range msg.ToolResults() {
112102 response := map[string]interface{}{"result": result.Content}
@@ -114,10 +104,11 @@ func (p *geminiProvider) convertToGeminiHistory(messages []message.Message) []*g
114104 if err == nil {
115105 response = parsed
116106 }
107+
117108 var toolCall message.ToolCall
118− for _, msg := range messages {
119− if msg.Role == message.Assistant {
120− for _, call := range msg.ToolCalls() {
109+ for _, m := range messages {
110+ if m.Role == message.Assistant {
111+ for _, call := range m.ToolCalls() {
121112 if call.ID == result.ToolCallID {
122113 toolCall = call
123114 break
@@ -140,186 +131,358 @@ func (p *geminiProvider) convertToGeminiHistory(messages []message.Message) []*g
140131 return history
141132}
142133
143−func (p *geminiProvider) extractTokenUsage(resp *genai.GenerateContentResponse) TokenUsage {
144− if resp == nil || resp.UsageMetadata == nil {
145− return TokenUsage{}
146− }
134+func (g *geminiClient) convertTools(tools []tools.BaseTool) []*genai.Tool {
135+ geminiTools := make([]*genai.Tool, 0, len(tools))
147136
148− return TokenUsage{
149− InputTokens: int64(resp.UsageMetadata.PromptTokenCount),
150− OutputTokens: int64(resp.UsageMetadata.CandidatesTokenCount),
151− CacheCreationTokens: 0, // Not directly provided by Gemini
152− CacheReadTokens: int64(resp.UsageMetadata.CachedContentTokenCount),
137+ for _, tool := range tools {
138+ info := tool.Info()
139+ declaration := &genai.FunctionDeclaration{
140+ Name: info.Name,
141+ Description: info.Description,
142+ Parameters: &genai.Schema{
143+ Type: genai.TypeObject,
144+ Properties: convertSchemaProperties(info.Parameters),
145+ Required: info.Required,
146+ },
147+ }
148+
149+ geminiTools = append(geminiTools, &genai.Tool{
150+ FunctionDeclarations: []*genai.FunctionDeclaration{declaration},
151+ })
153152 }
153+
154+ return geminiTools
154155}
155156
156−func (p *geminiProvider) SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
157− messages = cleanupMessages(messages)
158− model := p.client.GenerativeModel(p.model.APIModel)
159− model.SetMaxOutputTokens(p.maxTokens)
157+func (g *geminiClient) finishReason(reason genai.FinishReason) message.FinishReason {
158+ reasonStr := reason.String()
159+ switch {
160+ case reasonStr == "STOP":
161+ return message.FinishReasonEndTurn
162+ case reasonStr == "MAX_TOKENS":
163+ return message.FinishReasonMaxTokens
164+ case strings.Contains(reasonStr, "FUNCTION") || strings.Contains(reasonStr, "TOOL"):
165+ return message.FinishReasonToolUse
166+ default:
167+ return message.FinishReasonUnknown
168+ }
169+}
160170
161− model.SystemInstruction = genai.NewUserContent(genai.Text(p.systemMessage))
171+func (g *geminiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
172+ model := g.client.GenerativeModel(g.providerOptions.model.APIModel)
173+ model.SetMaxOutputTokens(int32(g.providerOptions.maxTokens))
162174
175+ // Convert tools
163176 if len(tools) > 0 {
164− declarations := p.convertToolsToGeminiFunctionDeclarations(tools)
165− for _, declaration := range declarations {
166− model.Tools = append(model.Tools, &genai.Tool{FunctionDeclarations: []*genai.FunctionDeclaration{declaration}})
167− }
177+ model.Tools = g.convertTools(tools)
168178 }
169179
170− chat := model.StartChat()
171− chat.History = p.convertToGeminiHistory(messages[:len(messages)-1]) // Exclude last message
180+ // Convert messages
181+ geminiMessages := g.convertMessages(messages)
172182
173− lastUserMsg := messages[len(messages)-1]
174− resp, err := chat.SendMessage(ctx, genai.Text(lastUserMsg.Content().String()))
175− if err != nil {
176− return nil, err
183+ cfg := config.Get()
184+ if cfg.Debug {
185+ jsonData, _ := json.Marshal(geminiMessages)
186+ logging.Debug("Prepared messages", "messages", string(jsonData))
177187 }
178188
179− var content string
180− var toolCalls []message.ToolCall
189+ attempts := 0
190+ for {
191+ attempts++
192+ chat := model.StartChat()
193+ chat.History = geminiMessages[:len(geminiMessages)-1] // All but last message
194+
195+ lastMsg := geminiMessages[len(geminiMessages)-1]
196+ var lastText string
197+ for _, part := range lastMsg.Parts {
198+ if text, ok := part.(genai.Text); ok {
199+ lastText = string(text)
200+ break
201+ }
202+ }
181203
182− if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
183− for _, part := range resp.Candidates[0].Content.Parts {
184− switch p := part.(type) {
185− case genai.Text:
186− content = string(p)
187− case genai.FunctionCall:
188− id := "call_" + uuid.New().String()
189− args, _ := json.Marshal(p.Args)
190− toolCalls = append(toolCalls, message.ToolCall{
191− ID: id,
192− Name: p.Name,
193− Input: string(args),
194− Type: "function",
195− })
204+ resp, err := chat.SendMessage(ctx, genai.Text(lastText))
205+ // If there is an error we are going to see if we can retry the call
206+ if err != nil {
207+ retry, after, retryErr := g.shouldRetry(attempts, err)
208+ if retryErr != nil {
209+ return nil, retryErr
196210 }
211+ if retry {
212+ logging.WarnPersist("Retrying due to rate limit... attempt %d of %d", logging.PersistTimeArg, time.Millisecond*time.Duration(after+100))
213+ select {
214+ case <-ctx.Done():
215+ return nil, ctx.Err()
216+ case <-time.After(time.Duration(after) * time.Millisecond):
217+ continue
218+ }
219+ }
220+ return nil, retryErr
197221 }
198− }
199222
200− tokenUsage := p.extractTokenUsage(resp)
223+ content := ""
224+ var toolCalls []message.ToolCall
225+
226+ if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
227+ for _, part := range resp.Candidates[0].Content.Parts {
228+ switch p := part.(type) {
229+ case genai.Text:
230+ content = string(p)
231+ case genai.FunctionCall:
232+ id := "call_" + uuid.New().String()
233+ args, _ := json.Marshal(p.Args)
234+ toolCalls = append(toolCalls, message.ToolCall{
235+ ID: id,
236+ Name: p.Name,
237+ Input: string(args),
238+ Type: "function",
239+ })
240+ }
241+ }
242+ }
201243
202− return &ProviderResponse{
203− Content: content,
204− ToolCalls: toolCalls,
205− Usage: tokenUsage,
206− }, nil
244+ return &ProviderResponse{
245+ Content: content,
246+ ToolCalls: toolCalls,
247+ Usage: g.usage(resp),
248+ FinishReason: g.finishReason(resp.Candidates[0].FinishReason),
249+ }, nil
250+ }
207251}
208252
209−func (p *geminiProvider) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (<-chan ProviderEvent, error) {
210− messages = cleanupMessages(messages)
211− model := p.client.GenerativeModel(p.model.APIModel)
212− model.SetMaxOutputTokens(p.maxTokens)
213−
214− model.SystemInstruction = genai.NewUserContent(genai.Text(p.systemMessage))
253+func (g *geminiClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
254+ model := g.client.GenerativeModel(g.providerOptions.model.APIModel)
255+ model.SetMaxOutputTokens(int32(g.providerOptions.maxTokens))
215256
257+ // Convert tools
216258 if len(tools) > 0 {
217− declarations := p.convertToolsToGeminiFunctionDeclarations(tools)
218− for _, declaration := range declarations {
219− model.Tools = append(model.Tools, &genai.Tool{FunctionDeclarations: []*genai.FunctionDeclaration{declaration}})
220− }
259+ model.Tools = g.convertTools(tools)
221260 }
222261
223− chat := model.StartChat()
224− chat.History = p.convertToGeminiHistory(messages[:len(messages)-1]) // Exclude last message
262+ // Convert messages
263+ geminiMessages := g.convertMessages(messages)
225264
226− lastUserMsg := messages[len(messages)-1]
227−
228− iter := chat.SendMessageStream(ctx, genai.Text(lastUserMsg.Content().String()))
265+ cfg := config.Get()
266+ if cfg.Debug {
267+ jsonData, _ := json.Marshal(geminiMessages)
268+ logging.Debug("Prepared messages", "messages", string(jsonData))
269+ }
229270
271+ attempts := 0
230272 eventChan := make(chan ProviderEvent)
231273
232274 go func() {
233275 defer close(eventChan)
234276
235− var finalResp *genai.GenerateContentResponse
236− currentContent := ""
237− toolCalls := []message.ToolCall{}
238−
239277 for {
240− resp, err := iter.Next()
241− if err == iterator.Done {
242− break
243− }
244− if err != nil {
245− eventChan <- ProviderEvent{
246− Type: EventError,
247− Error: err,
278+ attempts++
279+ chat := model.StartChat()
280+ chat.History = geminiMessages[:len(geminiMessages)-1] // All but last message
281+
282+ lastMsg := geminiMessages[len(geminiMessages)-1]
283+ var lastText string
284+ for _, part := range lastMsg.Parts {
285+ if text, ok := part.(genai.Text); ok {
286+ lastText = string(text)
287+ break
248288 }
249− return
250289 }
251290
252− finalResp = resp
291+ iter := chat.SendMessageStream(ctx, genai.Text(lastText))
253292
254− if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
255− for _, part := range resp.Candidates[0].Content.Parts {
256− switch p := part.(type) {
257− case genai.Text:
258− newText := string(p)
259− eventChan <- ProviderEvent{
260− Type: EventContentDelta,
261− Content: newText,
262− }
263− currentContent += newText
264− case genai.FunctionCall:
265− id := "call_" + uuid.New().String()
266− args, _ := json.Marshal(p.Args)
267− newCall := message.ToolCall{
268− ID: id,
269− Name: p.Name,
270− Input: string(args),
271− Type: "function",
272− }
293+ currentContent := ""
294+ toolCalls := []message.ToolCall{}
295+ var finalResp *genai.GenerateContentResponse
273296
274− isNew := true
275− for _, existing := range toolCalls {
276− if existing.Name == newCall.Name && existing.Input == newCall.Input {
277− isNew = false
278− break
297+ eventChan <- ProviderEvent{Type: EventContentStart}
298+
299+ for {
300+ resp, err := iter.Next()
301+ if err == iterator.Done {
302+ break
303+ }
304+ if err != nil {
305+ retry, after, retryErr := g.shouldRetry(attempts, err)
306+ if retryErr != nil {
307+ eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
308+ return
309+ }
310+ if retry {
311+ logging.WarnPersist("Retrying due to rate limit... attempt %d of %d", logging.PersistTimeArg, time.Millisecond*time.Duration(after+100))
312+ select {
313+ case <-ctx.Done():
314+ if ctx.Err() != nil {
315+ eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
279316 }
317+
318+ return
319+ case <-time.After(time.Duration(after) * time.Millisecond):
320+ break
280321 }
322+ } else {
323+ eventChan <- ProviderEvent{Type: EventError, Error: err}
324+ return
325+ }
326+ }
327+
328+ finalResp = resp
329+
330+ if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
331+ for _, part := range resp.Candidates[0].Content.Parts {
332+ switch p := part.(type) {
333+ case genai.Text:
334+ newText := string(p)
335+ delta := newText[len(currentContent):]
336+ if delta != "" {
337+ eventChan <- ProviderEvent{
338+ Type: EventContentDelta,
339+ Content: delta,
340+ }
341+ currentContent = newText
342+ }
343+ case genai.FunctionCall:
344+ id := "call_" + uuid.New().String()
345+ args, _ := json.Marshal(p.Args)
346+ newCall := message.ToolCall{
347+ ID: id,
348+ Name: p.Name,
349+ Input: string(args),
350+ Type: "function",
351+ }
281352
282− if isNew {
283− toolCalls = append(toolCalls, newCall)
353+ isNew := true
354+ for _, existing := range toolCalls {
355+ if existing.Name == newCall.Name && existing.Input == newCall.Input {
356+ isNew = false
357+ break
358+ }
359+ }
360+
361+ if isNew {
362+ toolCalls = append(toolCalls, newCall)
363+ }
284364 }
285365 }
286366 }
287367 }
288− }
289368
290− tokenUsage := p.extractTokenUsage(finalResp)
369+ eventChan <- ProviderEvent{Type: EventContentStop}
291370
292− eventChan <- ProviderEvent{
293− Type: EventComplete,
294− Response: &ProviderResponse{
295− Content: currentContent,
296− ToolCalls: toolCalls,
297− Usage: tokenUsage,
298− FinishReason: string(finalResp.Candidates[0].FinishReason.String()),
299− },
371+ if finalResp != nil {
372+ eventChan <- ProviderEvent{
373+ Type: EventComplete,
374+ Response: &ProviderResponse{
375+ Content: currentContent,
376+ ToolCalls: toolCalls,
377+ Usage: g.usage(finalResp),
378+ FinishReason: g.finishReason(finalResp.Candidates[0].FinishReason),
379+ },
380+ }
381+ return
382+ }
383+
384+ // If we get here, we need to retry
385+ if attempts > maxRetries {
386+ eventChan <- ProviderEvent{
387+ Type: EventError,
388+ Error: fmt.Errorf("maximum retry attempts reached: %d retries", maxRetries),
389+ }
390+ return
391+ }
392+
393+ // Wait before retrying
394+ select {
395+ case <-ctx.Done():
396+ if ctx.Err() != nil {
397+ eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
398+ }
399+ return
400+ case <-time.After(time.Duration(2000*(1<<(attempts-1))) * time.Millisecond):
401+ continue
402+ }
300403 }
301404 }()
302405
303− return eventChan, nil
406+ return eventChan
304407}
305408
306−func (p *geminiProvider) convertToolsToGeminiFunctionDeclarations(tools []tools.BaseTool) []*genai.FunctionDeclaration {
307− declarations := make([]*genai.FunctionDeclaration, len(tools))
409+func (g *geminiClient) shouldRetry(attempts int, err error) (bool, int64, error) {
410+ // Check if error is a rate limit error
411+ if attempts > maxRetries {
412+ return false, 0, fmt.Errorf("maximum retry attempts reached for rate limit: %d retries", maxRetries)
413+ }
308414
309− for i, tool := range tools {
310− info := tool.Info()
311− declarations[i] = &genai.FunctionDeclaration{
312− Name: info.Name,
313− Description: info.Description,
314− Parameters: &genai.Schema{
315− Type: genai.TypeObject,
316− Properties: convertSchemaProperties(info.Parameters),
317− Required: info.Required,
318− },
415+ // Gemini doesn't have a standard error type we can check against
416+ // So we'll check the error message for rate limit indicators
417+ if errors.Is(err, io.EOF) {
418+ return false, 0, err
419+ }
420+
421+ errMsg := err.Error()
422+ isRateLimit := false
423+
424+ // Check for common rate limit error messages
425+ if contains(errMsg, "rate limit", "quota exceeded", "too many requests") {
426+ isRateLimit = true
427+ }
428+
429+ if !isRateLimit {
430+ return false, 0, err
431+ }
432+
433+ // Calculate backoff with jitter
434+ backoffMs := 2000 * (1 << (attempts - 1))
435+ jitterMs := int(float64(backoffMs) * 0.2)
436+ retryMs := backoffMs + jitterMs
437+
438+ return true, int64(retryMs), nil
439+}
440+
441+func (g *geminiClient) toolCalls(resp *genai.GenerateContentResponse) []message.ToolCall {
442+ var toolCalls []message.ToolCall
443+
444+ if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
445+ for _, part := range resp.Candidates[0].Content.Parts {
446+ if funcCall, ok := part.(genai.FunctionCall); ok {
447+ id := "call_" + uuid.New().String()
448+ args, _ := json.Marshal(funcCall.Args)
449+ toolCalls = append(toolCalls, message.ToolCall{
450+ ID: id,
451+ Name: funcCall.Name,
452+ Input: string(args),
453+ Type: "function",
454+ })
455+ }
319456 }
320457 }
321458
322− return declarations
459+ return toolCalls
460+}
461+
462+func (g *geminiClient) usage(resp *genai.GenerateContentResponse) TokenUsage {
463+ if resp == nil || resp.UsageMetadata == nil {
464+ return TokenUsage{}
465+ }
466+
467+ return TokenUsage{
468+ InputTokens: int64(resp.UsageMetadata.PromptTokenCount),
469+ OutputTokens: int64(resp.UsageMetadata.CandidatesTokenCount),
470+ CacheCreationTokens: 0, // Not directly provided by Gemini
471+ CacheReadTokens: int64(resp.UsageMetadata.CachedContentTokenCount),
472+ }
473+}
474+
475+func WithGeminiDisableCache() GeminiOption {
476+ return func(options *geminiOptions) {
477+ options.disableCache = true
478+ }
479+}
480+
481+// Helper functions
482+func parseJsonToMap(jsonStr string) (map[string]interface{}, error) {
483+ var result map[string]interface{}
484+ err := json.Unmarshal([]byte(jsonStr), &result)
485+ return result, err
323486}
324487
325488func convertSchemaProperties(parameters map[string]interface{}) map[string]*genai.Schema {
@@ -396,8 +559,11 @@ func mapJSONTypeToGenAI(jsonType string) genai.Type {
396559 }
397560}
398561
399−func parseJsonToMap(jsonStr string) (map[string]interface{}, error) {
400− var result map[string]interface{}
401− err := json.Unmarshal([]byte(jsonStr), &result)
402− return result, err
562+func contains(s string, substrs ...string) bool {
563+ for _, substr := range substrs {
564+ if strings.Contains(strings.ToLower(s), strings.ToLower(substr)) {
565+ return true
566+ }
567+ }
568+ return false
403569}
internal/llm/provider/openai.go+275−160
@@ -2,89 +2,69 @@ package provider
22
33import (
44 "context"
5+ "encoding/json"
56 "errors"
6−
7− "github.com/kujtimiihoxha/termai/internal/llm/models"
8− "github.com/kujtimiihoxha/termai/internal/llm/tools"
9− "github.com/kujtimiihoxha/termai/internal/message"
7+ "fmt"
8+ "io"
9+ "time"
10+
11+ "github.com/kujtimiihoxha/opencode/internal/config"
12+ "github.com/kujtimiihoxha/opencode/internal/llm/tools"
13+ "github.com/kujtimiihoxha/opencode/internal/logging"
14+ "github.com/kujtimiihoxha/opencode/internal/message"
1015 "github.com/openai/openai-go"
1116 "github.com/openai/openai-go/option"
17+ "github.com/openai/openai-go/shared"
1218)
1319
14−type openaiProvider struct {
15− client openai.Client
16− model models.Model
17− maxTokens int64
18− baseURL string
19− apiKey string
20− systemMessage string
20+type openaiOptions struct {
21+ baseURL string
22+ disableCache bool
23+ reasoningEffort string
2124}
2225
23−type OpenAIOption func(*openaiProvider)
24−
25−func NewOpenAIProvider(opts ...OpenAIOption) (Provider, error) {
26− provider := &openaiProvider{
27− maxTokens: 5000,
28− }
29−
30− for _, opt := range opts {
31− opt(provider)
32− }
26+type OpenAIOption func(*openaiOptions)
3327
34− clientOpts := []option.RequestOption{
35− option.WithAPIKey(provider.apiKey),
36− }
37− if provider.baseURL != "" {
38− clientOpts = append(clientOpts, option.WithBaseURL(provider.baseURL))
39− }
40−
41− provider.client = openai.NewClient(clientOpts...)
42− if provider.systemMessage == "" {
43− return nil, errors.New("system message is required")
44− }
45−
46− return provider, nil
28+type openaiClient struct {
29+ providerOptions providerClientOptions
30+ options openaiOptions
31+ client openai.Client
4732}
4833
49−func WithOpenAISystemMessage(message string) OpenAIOption {
50− return func(p *openaiProvider) {
51− p.systemMessage = message
52− }
53−}
34+type OpenAIClient ProviderClient
5435
55−func WithOpenAIMaxTokens(maxTokens int64) OpenAIOption {
56− return func(p *openaiProvider) {
57− p.maxTokens = maxTokens
36+func newOpenAIClient(opts providerClientOptions) OpenAIClient {
37+ openaiOpts := openaiOptions{
38+ reasoningEffort: "medium",
5839 }
59−}
60−
61−func WithOpenAIModel(model models.Model) OpenAIOption {
62− return func(p *openaiProvider) {
63− p.model = model
40+ for _, o := range opts.openaiOptions {
41+ o(&openaiOpts)
6442 }
65−}
6643
67−func WithOpenAIBaseURL(baseURL string) OpenAIOption {
68− return func(p *openaiProvider) {
69− p.baseURL = baseURL
44+ openaiClientOptions := []option.RequestOption{}
45+ if opts.apiKey != "" {
46+ openaiClientOptions = append(openaiClientOptions, option.WithAPIKey(opts.apiKey))
47+ }
48+ if openaiOpts.baseURL != "" {
49+ openaiClientOptions = append(openaiClientOptions, option.WithBaseURL(openaiOpts.baseURL))
7050 }
71−}
7251
73−func WithOpenAIKey(apiKey string) OpenAIOption {
74− return func(p *openaiProvider) {
75− p.apiKey = apiKey
52+ client := openai.NewClient(openaiClientOptions...)
53+ return &openaiClient{
54+ providerOptions: opts,
55+ options: openaiOpts,
56+ client: client,
7657 }
7758}
7859
79−func (p *openaiProvider) convertToOpenAIMessages(messages []message.Message) []openai.ChatCompletionMessageParamUnion {
80− var chatMessages []openai.ChatCompletionMessageParamUnion
81−
82− chatMessages = append(chatMessages, openai.SystemMessage(p.systemMessage))
60+func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessages []openai.ChatCompletionMessageParamUnion) {
61+ // Add system message first
62+ openaiMessages = append(openaiMessages, openai.SystemMessage(o.providerOptions.systemMessage))
8363
8464 for _, msg := range messages {
8565 switch msg.Role {
8666 case message.User:
87− chatMessages = append(chatMessages, openai.UserMessage(msg.Content().String()))
67+ openaiMessages = append(openaiMessages, openai.UserMessage(msg.Content().String()))
8868
8969 case message.Assistant:
9070 assistantMsg := openai.ChatCompletionAssistantMessageParam{
@@ -111,23 +91,23 @@ func (p *openaiProvider) convertToOpenAIMessages(messages []message.Message) []o
11191 }
11292 }
11393
114− chatMessages = append(chatMessages, openai.ChatCompletionMessageParamUnion{
94+ openaiMessages = append(openaiMessages, openai.ChatCompletionMessageParamUnion{
11595 OfAssistant: &assistantMsg,
11696 })
11797
11898 case message.Tool:
11999 for _, result := range msg.ToolResults() {
120− chatMessages = append(chatMessages,
100+ openaiMessages = append(openaiMessages,
121101 openai.ToolMessage(result.Content, result.ToolCallID),
122102 )
123103 }
124104 }
125105 }
126106
127− return chatMessages
107+ return
128108}
129109
130−func (p *openaiProvider) convertToOpenAITools(tools []tools.BaseTool) []openai.ChatCompletionToolParam {
110+func (o *openaiClient) convertTools(tools []tools.BaseTool) []openai.ChatCompletionToolParam {
131111 openaiTools := make([]openai.ChatCompletionToolParam, len(tools))
132112
133113 for i, tool := range tools {
@@ -148,133 +128,268 @@ func (p *openaiProvider) convertToOpenAITools(tools []tools.BaseTool) []openai.C
148128 return openaiTools
149129}
150130
151−func (p *openaiProvider) extractTokenUsage(usage openai.CompletionUsage) TokenUsage {
152− cachedTokens := int64(0)
153−
154− cachedTokens = usage.PromptTokensDetails.CachedTokens
155− inputTokens := usage.PromptTokens - cachedTokens
156−
157− return TokenUsage{
158− InputTokens: inputTokens,
159− OutputTokens: usage.CompletionTokens,
160− CacheCreationTokens: 0, // OpenAI doesn't provide this directly
161− CacheReadTokens: cachedTokens,
131+func (o *openaiClient) finishReason(reason string) message.FinishReason {
132+ switch reason {
133+ case "stop":
134+ return message.FinishReasonEndTurn
135+ case "length":
136+ return message.FinishReasonMaxTokens
137+ case "tool_calls":
138+ return message.FinishReasonToolUse
139+ default:
140+ return message.FinishReasonUnknown
162141 }
163142}
164143
165−func (p *openaiProvider) SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
166− messages = cleanupMessages(messages)
167− chatMessages := p.convertToOpenAIMessages(messages)
168− openaiTools := p.convertToOpenAITools(tools)
169−
144+func (o *openaiClient) preparedParams(messages []openai.ChatCompletionMessageParamUnion, tools []openai.ChatCompletionToolParam) openai.ChatCompletionNewParams {
170145 params := openai.ChatCompletionNewParams{
171− Model: openai.ChatModel(p.model.APIModel),
172− Messages: chatMessages,
173− MaxTokens: openai.Int(p.maxTokens),
174− Tools: openaiTools,
146+ Model: openai.ChatModel(o.providerOptions.model.APIModel),
147+ Messages: messages,
148+ Tools: tools,
175149 }
176150
177− response, err := p.client.Chat.Completions.New(ctx, params)
178− if err != nil {
179− return nil, err
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)
180165 }
181166
182− content := ""
183− if response.Choices[0].Message.Content != "" {
184− content = response.Choices[0].Message.Content
185− }
167+ return params
168+}
186169
187− var toolCalls []message.ToolCall
188− if len(response.Choices[0].Message.ToolCalls) > 0 {
189− toolCalls = make([]message.ToolCall, len(response.Choices[0].Message.ToolCalls))
190− for i, call := range response.Choices[0].Message.ToolCalls {
191− toolCalls[i] = message.ToolCall{
192− ID: call.ID,
193− Name: call.Function.Name,
194− Input: call.Function.Arguments,
195− Type: "function",
170+func (o *openaiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) {
171+ params := o.preparedParams(o.convertMessages(messages), o.convertTools(tools))
172+ cfg := config.Get()
173+ if cfg.Debug {
174+ jsonData, _ := json.Marshal(params)
175+ logging.Debug("Prepared messages", "messages", string(jsonData))
176+ }
177+ attempts := 0
178+ for {
179+ attempts++
180+ openaiResponse, err := o.client.Chat.Completions.New(
181+ ctx,
182+ params,
183+ )
184+ // If there is an error we are going to see if we can retry the call
185+ if err != nil {
186+ retry, after, retryErr := o.shouldRetry(attempts, err)
187+ if retryErr != nil {
188+ return nil, retryErr
189+ }
190+ if retry {
191+ logging.WarnPersist("Retrying due to rate limit... attempt %d of %d", logging.PersistTimeArg, time.Millisecond*time.Duration(after+100))
192+ select {
193+ case <-ctx.Done():
194+ return nil, ctx.Err()
195+ case <-time.After(time.Duration(after) * time.Millisecond):
196+ continue
197+ }
196198 }
199+ return nil, retryErr
197200 }
198− }
199201
200− tokenUsage := p.extractTokenUsage(response.Usage)
202+ content := ""
203+ if openaiResponse.Choices[0].Message.Content != "" {
204+ content = openaiResponse.Choices[0].Message.Content
205+ }
201206
202− return &ProviderResponse{
203− Content: content,
204− ToolCalls: toolCalls,
205− Usage: tokenUsage,
206− }, nil
207+ return &ProviderResponse{
208+ Content: content,
209+ ToolCalls: o.toolCalls(*openaiResponse),
210+ Usage: o.usage(*openaiResponse),
211+ FinishReason: o.finishReason(string(openaiResponse.Choices[0].FinishReason)),
212+ }, nil
213+ }
207214}
208215
209−func (p *openaiProvider) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (<-chan ProviderEvent, error) {
210− messages = cleanupMessages(messages)
211− chatMessages := p.convertToOpenAIMessages(messages)
212− openaiTools := p.convertToOpenAITools(tools)
213−
214− params := openai.ChatCompletionNewParams{
215− Model: openai.ChatModel(p.model.APIModel),
216− Messages: chatMessages,
217− MaxTokens: openai.Int(p.maxTokens),
218− Tools: openaiTools,
219− StreamOptions: openai.ChatCompletionStreamOptionsParam{
220− IncludeUsage: openai.Bool(true),
221− },
216+func (o *openaiClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
217+ params := o.preparedParams(o.convertMessages(messages), o.convertTools(tools))
218+ params.StreamOptions = openai.ChatCompletionStreamOptionsParam{
219+ IncludeUsage: openai.Bool(true),
222220 }
223221
224− stream := p.client.Chat.Completions.NewStreaming(ctx, params)
222+ cfg := config.Get()
223+ if cfg.Debug {
224+ jsonData, _ := json.Marshal(params)
225+ logging.Debug("Prepared messages", "messages", string(jsonData))
226+ }
225227
228+ attempts := 0
226229 eventChan := make(chan ProviderEvent)
227230
228− toolCalls := make([]message.ToolCall, 0)
229231 go func() {
230− defer close(eventChan)
231−
232− acc := openai.ChatCompletionAccumulator{}
233− currentContent := ""
234−
235− for stream.Next() {
236− chunk := stream.Current()
237− acc.AddChunk(chunk)
238−
239− if tool, ok := acc.JustFinishedToolCall(); ok {
240− toolCalls = append(toolCalls, message.ToolCall{
241− ID: tool.Id,
242− Name: tool.Name,
243− Input: tool.Arguments,
244− Type: "function",
245− })
246− }
232+ for {
233+ attempts++
234+ openaiStream := o.client.Chat.Completions.NewStreaming(
235+ ctx,
236+ params,
237+ )
238+
239+ acc := openai.ChatCompletionAccumulator{}
240+ currentContent := ""
241+ toolCalls := make([]message.ToolCall, 0)
242+
243+ for openaiStream.Next() {
244+ chunk := openaiStream.Current()
245+ acc.AddChunk(chunk)
246+
247+ if tool, ok := acc.JustFinishedToolCall(); ok {
248+ toolCalls = append(toolCalls, message.ToolCall{
249+ ID: tool.Id,
250+ Name: tool.Name,
251+ Input: tool.Arguments,
252+ Type: "function",
253+ })
254+ }
247255
248− for _, choice := range chunk.Choices {
249− if choice.Delta.Content != "" {
250− eventChan <- ProviderEvent{
251− Type: EventContentDelta,
252− Content: choice.Delta.Content,
256+ for _, choice := range chunk.Choices {
257+ if choice.Delta.Content != "" {
258+ eventChan <- ProviderEvent{
259+ Type: EventContentDelta,
260+ Content: choice.Delta.Content,
261+ }
262+ currentContent += choice.Delta.Content
253263 }
254− currentContent += choice.Delta.Content
255264 }
256265 }
257− }
258266
259− if err := stream.Err(); err != nil {
260− eventChan <- ProviderEvent{
261− Type: EventError,
262− Error: err,
267+ err := openaiStream.Err()
268+ if err == nil || errors.Is(err, io.EOF) {
269+ // Stream completed successfully
270+ eventChan <- ProviderEvent{
271+ Type: EventComplete,
272+ Response: &ProviderResponse{
273+ Content: currentContent,
274+ ToolCalls: toolCalls,
275+ Usage: o.usage(acc.ChatCompletion),
276+ FinishReason: o.finishReason(string(acc.ChatCompletion.Choices[0].FinishReason)),
277+ },
278+ }
279+ close(eventChan)
280+ return
281+ }
282+
283+ // If there is an error we are going to see if we can retry the call
284+ retry, after, retryErr := o.shouldRetry(attempts, err)
285+ if retryErr != nil {
286+ eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
287+ close(eventChan)
288+ return
289+ }
290+ if retry {
291+ logging.WarnPersist("Retrying due to rate limit... attempt %d of %d", logging.PersistTimeArg, time.Millisecond*time.Duration(after+100))
292+ select {
293+ case <-ctx.Done():
294+ // context cancelled
295+ if ctx.Err() == nil {
296+ eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
297+ }
298+ close(eventChan)
299+ return
300+ case <-time.After(time.Duration(after) * time.Millisecond):
301+ continue
302+ }
263303 }
304+ eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
305+ close(eventChan)
264306 return
265307 }
308+ }()
266309
267− tokenUsage := p.extractTokenUsage(acc.Usage)
310+ return eventChan
311+}
268312
269− eventChan <- ProviderEvent{
270− Type: EventComplete,
271− Response: &ProviderResponse{
272− Content: currentContent,
273− ToolCalls: toolCalls,
274− Usage: tokenUsage,
275− },
313+func (o *openaiClient) shouldRetry(attempts int, err error) (bool, int64, error) {
314+ var apierr *openai.Error
315+ if !errors.As(err, &apierr) {
316+ return false, 0, err
317+ }
318+
319+ if apierr.StatusCode != 429 && apierr.StatusCode != 500 {
320+ return false, 0, err
321+ }
322+
323+ if attempts > maxRetries {
324+ return false, 0, fmt.Errorf("maximum retry attempts reached for rate limit: %d retries", maxRetries)
325+ }
326+
327+ retryMs := 0
328+ retryAfterValues := apierr.Response.Header.Values("Retry-After")
329+
330+ backoffMs := 2000 * (1 << (attempts - 1))
331+ jitterMs := int(float64(backoffMs) * 0.2)
332+ retryMs = backoffMs + jitterMs
333+ if len(retryAfterValues) > 0 {
334+ if _, err := fmt.Sscanf(retryAfterValues[0], "%d", &retryMs); err == nil {
335+ retryMs = retryMs * 1000
276336 }
277− }()
337+ }
338+ return true, int64(retryMs), nil
339+}
340+
341+func (o *openaiClient) toolCalls(completion openai.ChatCompletion) []message.ToolCall {
342+ var toolCalls []message.ToolCall
278343
279− return eventChan, nil
344+ if len(completion.Choices) > 0 && len(completion.Choices[0].Message.ToolCalls) > 0 {
345+ for _, call := range completion.Choices[0].Message.ToolCalls {
346+ toolCall := message.ToolCall{
347+ ID: call.ID,
348+ Name: call.Function.Name,
349+ Input: call.Function.Arguments,
350+ Type: "function",
351+ Finished: true,
352+ }
353+ toolCalls = append(toolCalls, toolCall)
354+ }
355+ }
356+
357+ return toolCalls
358+}
359+
360+func (o *openaiClient) usage(completion openai.ChatCompletion) TokenUsage {
361+ cachedTokens := completion.Usage.PromptTokensDetails.CachedTokens
362+ inputTokens := completion.Usage.PromptTokens - cachedTokens
363+
364+ return TokenUsage{
365+ InputTokens: inputTokens,
366+ OutputTokens: completion.Usage.CompletionTokens,
367+ CacheCreationTokens: 0, // OpenAI doesn't provide this directly
368+ CacheReadTokens: cachedTokens,
369+ }
370+}
371+
372+func WithOpenAIBaseURL(baseURL string) OpenAIOption {
373+ return func(options *openaiOptions) {
374+ options.baseURL = baseURL
375+ }
376+}
377+
378+func WithOpenAIDisableCache() OpenAIOption {
379+ return func(options *openaiOptions) {
380+ options.disableCache = true
381+ }
382+}
383+
384+func WithReasoningEffort(effort string) OpenAIOption {
385+ return func(options *openaiOptions) {
386+ defaultReasoningEffort := "medium"
387+ switch effort {
388+ case "low", "medium", "high":
389+ defaultReasoningEffort = effort
390+ default:
391+ logging.Warn("Invalid reasoning effort, using default: medium")
392+ }
393+ options.reasoningEffort = defaultReasoningEffort
394+ }
280395}
internal/llm/provider/provider.go+136−38
@@ -2,23 +2,28 @@ package provider
22
33import (
44 "context"
5+ "fmt"
56
6− "github.com/kujtimiihoxha/termai/internal/llm/tools"
7− "github.com/kujtimiihoxha/termai/internal/message"
7+ "github.com/kujtimiihoxha/opencode/internal/llm/models"
8+ "github.com/kujtimiihoxha/opencode/internal/llm/tools"
9+ "github.com/kujtimiihoxha/opencode/internal/message"
810)
911
10−// EventType represents the type of streaming event
1112type EventType string
1213
14+const maxRetries = 8
15+
1316const (
1417 EventContentStart EventType = "content_start"
18+ EventToolUseStart EventType = "tool_use_start"
19+ EventToolUseDelta EventType = "tool_use_delta"
20+ EventToolUseStop EventType = "tool_use_stop"
1521 EventContentDelta EventType = "content_delta"
1622 EventThinkingDelta EventType = "thinking_delta"
1723 EventContentStop EventType = "content_stop"
1824 EventComplete EventType = "complete"
1925 EventError EventType = "error"
2026 EventWarning EventType = "warning"
21− EventInfo EventType = "info"
2227)
2328
2429type TokenUsage struct {
@@ -32,59 +37,152 @@ type ProviderResponse struct {
3237 Content string
3338 ToolCalls []message.ToolCall
3439 Usage TokenUsage
35− FinishReason string
40+ FinishReason message.FinishReason
3641}
3742
3843type ProviderEvent struct {
39− Type EventType
44+ Type EventType
45+
4046 Content string
4147 Thinking string
48+ Response *ProviderResponse
4249 ToolCall *message.ToolCall
4350 Error error
44− Response *ProviderResponse
45−
46− // Used for giving users info on e.x retry
47− Info string
4851}
49−
5052type Provider interface {
5153 SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error)
5254
53− StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (<-chan ProviderEvent, error)
55+ StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent
56+
57+ Model() models.Model
58+}
59+
60+type providerClientOptions struct {
61+ apiKey string
62+ model models.Model
63+ maxTokens int64
64+ systemMessage string
65+
66+ anthropicOptions []AnthropicOption
67+ openaiOptions []OpenAIOption
68+ geminiOptions []GeminiOption
69+ bedrockOptions []BedrockOption
5470}
5571
56−func cleanupMessages(messages []message.Message) []message.Message {
57− // First pass: filter out canceled messages
58− var cleanedMessages []message.Message
72+type ProviderClientOption func(*providerClientOptions)
73+
74+type ProviderClient interface {
75+ send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error)
76+ stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent
77+}
78+
79+type baseProvider[C ProviderClient] struct {
80+ options providerClientOptions
81+ client C
82+}
83+
84+func NewProvider(providerName models.ModelProvider, opts ...ProviderClientOption) (Provider, error) {
85+ clientOptions := providerClientOptions{}
86+ for _, o := range opts {
87+ o(&clientOptions)
88+ }
89+ switch providerName {
90+ case models.ProviderAnthropic:
91+ return &baseProvider[AnthropicClient]{
92+ options: clientOptions,
93+ client: newAnthropicClient(clientOptions),
94+ }, nil
95+ case models.ProviderOpenAI:
96+ return &baseProvider[OpenAIClient]{
97+ options: clientOptions,
98+ client: newOpenAIClient(clientOptions),
99+ }, nil
100+ case models.ProviderGemini:
101+ return &baseProvider[GeminiClient]{
102+ options: clientOptions,
103+ client: newGeminiClient(clientOptions),
104+ }, nil
105+ case models.ProviderBedrock:
106+ return &baseProvider[BedrockClient]{
107+ options: clientOptions,
108+ client: newBedrockClient(clientOptions),
109+ }, nil
110+ case models.ProviderMock:
111+ // TODO: implement mock client for test
112+ panic("not implemented")
113+ }
114+ return nil, fmt.Errorf("provider not supported: %s", providerName)
115+}
116+
117+func (p *baseProvider[C]) cleanMessages(messages []message.Message) (cleaned []message.Message) {
59118 for _, msg := range messages {
60− if msg.FinishReason() != "canceled" {
61− cleanedMessages = append(cleanedMessages, msg)
119+ // The message has no content
120+ if len(msg.Parts) == 0 {
121+ continue
62122 }
123+ cleaned = append(cleaned, msg)
63124 }
125+ return
126+}
64127
65− // Second pass: filter out tool messages without a corresponding tool call
66− var result []message.Message
67− toolMessageIDs := make(map[string]bool)
128+func (p *baseProvider[C]) SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
129+ messages = p.cleanMessages(messages)
130+ return p.client.send(ctx, messages, tools)
131+}
68132
69− for _, msg := range cleanedMessages {
70− if msg.Role == message.Assistant {
71− for _, toolCall := range msg.ToolCalls() {
72− toolMessageIDs[toolCall.ID] = true // Mark as referenced
73− }
74− }
133+func (p *baseProvider[C]) Model() models.Model {
134+ return p.options.model
135+}
136+
137+func (p *baseProvider[C]) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
138+ messages = p.cleanMessages(messages)
139+ return p.client.stream(ctx, messages, tools)
140+}
141+
142+func WithAPIKey(apiKey string) ProviderClientOption {
143+ return func(options *providerClientOptions) {
144+ options.apiKey = apiKey
75145 }
146+}
76147
77− // Keep only messages that aren't unreferenced tool messages
78− for _, msg := range cleanedMessages {
79− if msg.Role == message.Tool {
80− for _, toolCall := range msg.ToolResults() {
81− if referenced, exists := toolMessageIDs[toolCall.ToolCallID]; exists && referenced {
82− result = append(result, msg)
83− }
84− }
85− } else {
86− result = append(result, msg)
87− }
148+func WithModel(model models.Model) ProviderClientOption {
149+ return func(options *providerClientOptions) {
150+ options.model = model
151+ }
152+}
153+
154+func WithMaxTokens(maxTokens int64) ProviderClientOption {
155+ return func(options *providerClientOptions) {
156+ options.maxTokens = maxTokens
157+ }
158+}
159+
160+func WithSystemMessage(systemMessage string) ProviderClientOption {
161+ return func(options *providerClientOptions) {
162+ options.systemMessage = systemMessage
163+ }
164+}
165+
166+func WithAnthropicOptions(anthropicOptions ...AnthropicOption) ProviderClientOption {
167+ return func(options *providerClientOptions) {
168+ options.anthropicOptions = anthropicOptions
169+ }
170+}
171+
172+func WithOpenAIOptions(openaiOptions ...OpenAIOption) ProviderClientOption {
173+ return func(options *providerClientOptions) {
174+ options.openaiOptions = openaiOptions
175+ }
176+}
177+
178+func WithGeminiOptions(geminiOptions ...GeminiOption) ProviderClientOption {
179+ return func(options *providerClientOptions) {
180+ options.geminiOptions = geminiOptions
181+ }
182+}
183+
184+func WithBedrockOptions(bedrockOptions ...BedrockOption) ProviderClientOption {
185+ return func(options *providerClientOptions) {
186+ options.bedrockOptions = bedrockOptions
88187 }
89− return result
90188}
internal/llm/tools/bash.go+29−13
@@ -5,10 +5,11 @@ import (
55 "encoding/json"
66 "fmt"
77 "strings"
8+ "time"
89
9− "github.com/kujtimiihoxha/termai/internal/config"
10− "github.com/kujtimiihoxha/termai/internal/llm/tools/shell"
11− "github.com/kujtimiihoxha/termai/internal/permission"
10+ "github.com/kujtimiihoxha/opencode/internal/config"
11+ "github.com/kujtimiihoxha/opencode/internal/llm/tools/shell"
12+ "github.com/kujtimiihoxha/opencode/internal/permission"
1213)
1314
1415type BashParams struct {
@@ -21,6 +22,10 @@ type BashPermissionsParams struct {
2122 Timeout int `json:"timeout"`
2223}
2324
25+type BashResponseMetadata struct {
26+ StartTime int64 `json:"start_time"`
27+ EndTime int64 `json:"end_time"`
28+}
2429type bashTool struct {
2530 permissions permission.Service
2631}
@@ -46,7 +51,7 @@ var safeReadOnlyCommands = []string{
4651 "git status", "git log", "git diff", "git show", "git branch", "git tag", "git remote", "git ls-files", "git ls-remote",
4752 "git rev-parse", "git config --get", "git config --list", "git describe", "git blame", "git grep", "git shortlog",
4853
49− "go version", "go list", "go env", "go doc", "go vet", "go fmt", "go mod", "go test", "go build", "go run", "go install", "go clean",
54+ "go version", "go help", "go list", "go env", "go doc", "go vet", "go fmt", "go mod", "go test", "go build", "go run", "go install", "go clean",
5055}
5156
5257func bashDescription() string {
@@ -117,16 +122,16 @@ When the user asks you to create a new git commit, follow these steps carefully:
117122</commit_analysis>
118123
1191244. Create the commit with a message ending with:
120−🤖 Generated with termai
121−Co-Authored-By: termai <noreply@termai.io>
125+🤖 Generated with opencode
126+Co-Authored-By: opencode <noreply@opencode.ai>
122127
123128- In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example:
124129<example>
125130git commit -m "$(cat <<'EOF'
126131 Commit message here.
127132
128− 🤖 Generated with termai
129− Co-Authored-By: termai <noreply@termai.io>
133+ 🤖 Generated with opencode
134+ Co-Authored-By: opencode <noreply@opencode.ai>
130135 EOF
131136 )"
132137</example>
@@ -188,7 +193,7 @@ gh pr create --title "the pr title" --body "$(cat <<'EOF'
188193## Test plan
189194[Checklist of TODOs for testing the pull request...]
190195
191−🤖 Generated with termai
196+🤖 Generated with opencode
192197EOF
193198)"
194199</example>
@@ -256,9 +261,15 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
256261 }
257262 }
258263 }
264+
265+ sessionID, messageID := GetContextValues(ctx)
266+ if sessionID == "" || messageID == "" {
267+ return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
268+ }
259269 if !isSafeReadOnly {
260270 p := b.permissions.Request(
261271 permission.CreatePermissionRequest{
272+ SessionID: sessionID,
262273 Path: config.WorkingDirectory(),
263274 ToolName: BashToolName,
264275 Action: "execute",
@@ -269,13 +280,14 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
269280 },
270281 )
271282 if !p {
272− return NewTextErrorResponse("permission denied"), nil
283+ return ToolResponse{}, permission.ErrorPermissionDenied
273284 }
274285 }
286+ startTime := time.Now()
275287 shell := shell.GetPersistentShell(config.WorkingDirectory())
276288 stdout, stderr, exitCode, interrupted, err := shell.Exec(ctx, params.Command, params.Timeout)
277289 if err != nil {
278− return NewTextErrorResponse(fmt.Sprintf("error executing command: %s", err)), nil
290+ return ToolResponse{}, fmt.Errorf("error executing command: %w", err)
279291 }
280292
281293 stdout = truncateOutput(stdout)
@@ -304,10 +316,14 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
304316 stdout += "\n" + errorMessage
305317 }
306318
319+ metadata := BashResponseMetadata{
320+ StartTime: startTime.UnixMilli(),
321+ EndTime: time.Now().UnixMilli(),
322+ }
307323 if stdout == "" {
308− return NewTextResponse("no output"), nil
324+ return WithResponseMetadata(NewTextResponse("no output"), metadata), nil
309325 }
310− return NewTextResponse(stdout), nil
326+ return WithResponseMetadata(NewTextResponse(stdout), metadata), nil
311327}
312328
313329func truncateOutput(content string) string {
internal/llm/tools/bash_test.godeleted−371
@@ -1,371 +0,0 @@
1−package tools
2−
3−import (
4− "context"
5− "encoding/json"
6− "os"
7− "strings"
8− "testing"
9− "time"
10−
11− "github.com/kujtimiihoxha/termai/internal/permission"
12− "github.com/kujtimiihoxha/termai/internal/pubsub"
13− "github.com/stretchr/testify/assert"
14− "github.com/stretchr/testify/require"
15−)
16−
17−func TestBashTool_Info(t *testing.T) {
18− tool := NewBashTool(newMockPermissionService(true))
19− info := tool.Info()
20−
21− assert.Equal(t, BashToolName, info.Name)
22− assert.NotEmpty(t, info.Description)
23− assert.Contains(t, info.Parameters, "command")
24− assert.Contains(t, info.Parameters, "timeout")
25− assert.Contains(t, info.Required, "command")
26−}
27−
28−func TestBashTool_Run(t *testing.T) {
29− // Save original working directory
30− origWd, err := os.Getwd()
31− require.NoError(t, err)
32− defer func() {
33− os.Chdir(origWd)
34− }()
35−
36− t.Run("executes command successfully", func(t *testing.T) {
37− tool := NewBashTool(newMockPermissionService(true))
38− params := BashParams{
39− Command: "echo 'Hello World'",
40− }
41−
42− paramsJSON, err := json.Marshal(params)
43− require.NoError(t, err)
44−
45− call := ToolCall{
46− Name: BashToolName,
47− Input: string(paramsJSON),
48− }
49−
50− response, err := tool.Run(context.Background(), call)
51− require.NoError(t, err)
52− assert.Equal(t, "Hello World\n", response.Content)
53− })
54−
55− t.Run("handles invalid parameters", func(t *testing.T) {
56− tool := NewBashTool(newMockPermissionService(true))
57− call := ToolCall{
58− Name: BashToolName,
59− Input: "invalid json",
60− }
61−
62− response, err := tool.Run(context.Background(), call)
63− require.NoError(t, err)
64− assert.Contains(t, response.Content, "invalid parameters")
65− })
66−
67− t.Run("handles missing command", func(t *testing.T) {
68− tool := NewBashTool(newMockPermissionService(true))
69− params := BashParams{
70− Command: "",
71− }
72−
73− paramsJSON, err := json.Marshal(params)
74− require.NoError(t, err)
75−
76− call := ToolCall{
77− Name: BashToolName,
78− Input: string(paramsJSON),
79− }
80−
81− response, err := tool.Run(context.Background(), call)
82− require.NoError(t, err)
83− assert.Contains(t, response.Content, "missing command")
84− })
85−
86− t.Run("handles banned commands", func(t *testing.T) {
87− tool := NewBashTool(newMockPermissionService(true))
88−
89− for _, bannedCmd := range bannedCommands {
90− params := BashParams{
91− Command: bannedCmd + " arg1 arg2",
92− }
93−
94− paramsJSON, err := json.Marshal(params)
95− require.NoError(t, err)
96−
97− call := ToolCall{
98− Name: BashToolName,
99− Input: string(paramsJSON),
100− }
101−
102− response, err := tool.Run(context.Background(), call)
103− require.NoError(t, err)
104− assert.Contains(t, response.Content, "not allowed", "Command %s should be blocked", bannedCmd)
105− }
106− })
107−
108− t.Run("handles multi-word safe commands without permission check", func(t *testing.T) {
109− tool := NewBashTool(newMockPermissionService(false))
110−
111− // Test with multi-word safe commands
112− multiWordCommands := []string{
113− "go env",
114− }
115−
116− for _, cmd := range multiWordCommands {
117− params := BashParams{
118− Command: cmd,
119− }
120−
121− paramsJSON, err := json.Marshal(params)
122− require.NoError(t, err)
123−
124− call := ToolCall{
125− Name: BashToolName,
126− Input: string(paramsJSON),
127− }
128−
129− response, err := tool.Run(context.Background(), call)
130− require.NoError(t, err)
131− assert.NotContains(t, response.Content, "permission denied",
132− "Command %s should be allowed without permission", cmd)
133− }
134− })
135−
136− t.Run("handles permission denied", func(t *testing.T) {
137− tool := NewBashTool(newMockPermissionService(false))
138−
139− // Test with a command that requires permission
140− params := BashParams{
141− Command: "mkdir test_dir",
142− }
143−
144− paramsJSON, err := json.Marshal(params)
145− require.NoError(t, err)
146−
147− call := ToolCall{
148− Name: BashToolName,
149− Input: string(paramsJSON),
150− }
151−
152− response, err := tool.Run(context.Background(), call)
153− require.NoError(t, err)
154− assert.Contains(t, response.Content, "permission denied")
155− })
156−
157− t.Run("handles command timeout", func(t *testing.T) {
158− tool := NewBashTool(newMockPermissionService(true))
159− params := BashParams{
160− Command: "sleep 2",
161− Timeout: 100, // 100ms timeout
162− }
163−
164− paramsJSON, err := json.Marshal(params)
165− require.NoError(t, err)
166−
167− call := ToolCall{
168− Name: BashToolName,
169− Input: string(paramsJSON),
170− }
171−
172− response, err := tool.Run(context.Background(), call)
173− require.NoError(t, err)
174− assert.Contains(t, response.Content, "aborted")
175− })
176−
177− t.Run("handles command with stderr output", func(t *testing.T) {
178− tool := NewBashTool(newMockPermissionService(true))
179− params := BashParams{
180− Command: "echo 'error message' >&2",
181− }
182−
183− paramsJSON, err := json.Marshal(params)
184− require.NoError(t, err)
185−
186− call := ToolCall{
187− Name: BashToolName,
188− Input: string(paramsJSON),
189− }
190−
191− response, err := tool.Run(context.Background(), call)
192− require.NoError(t, err)
193− assert.Contains(t, response.Content, "error message")
194− })
195−
196− t.Run("handles command with both stdout and stderr", func(t *testing.T) {
197− tool := NewBashTool(newMockPermissionService(true))
198− params := BashParams{
199− Command: "echo 'stdout message' && echo 'stderr message' >&2",
200− }
201−
202− paramsJSON, err := json.Marshal(params)
203− require.NoError(t, err)
204−
205− call := ToolCall{
206− Name: BashToolName,
207− Input: string(paramsJSON),
208− }
209−
210− response, err := tool.Run(context.Background(), call)
211− require.NoError(t, err)
212− assert.Contains(t, response.Content, "stdout message")
213− assert.Contains(t, response.Content, "stderr message")
214− })
215−
216− t.Run("handles context cancellation", func(t *testing.T) {
217− tool := NewBashTool(newMockPermissionService(true))
218− params := BashParams{
219− Command: "sleep 5",
220− }
221−
222− paramsJSON, err := json.Marshal(params)
223− require.NoError(t, err)
224−
225− call := ToolCall{
226− Name: BashToolName,
227− Input: string(paramsJSON),
228− }
229−
230− ctx, cancel := context.WithCancel(context.Background())
231−
232− // Cancel the context after a short delay
233− go func() {
234− time.Sleep(100 * time.Millisecond)
235− cancel()
236− }()
237−
238− response, err := tool.Run(ctx, call)
239− require.NoError(t, err)
240− assert.Contains(t, response.Content, "aborted")
241− })
242−
243− t.Run("respects max timeout", func(t *testing.T) {
244− tool := NewBashTool(newMockPermissionService(true))
245− params := BashParams{
246− Command: "echo 'test'",
247− Timeout: MaxTimeout + 1000, // Exceeds max timeout
248− }
249−
250− paramsJSON, err := json.Marshal(params)
251− require.NoError(t, err)
252−
253− call := ToolCall{
254− Name: BashToolName,
255− Input: string(paramsJSON),
256− }
257−
258− response, err := tool.Run(context.Background(), call)
259− require.NoError(t, err)
260− assert.Equal(t, "test\n", response.Content)
261− })
262−
263− t.Run("uses default timeout for zero or negative timeout", func(t *testing.T) {
264− tool := NewBashTool(newMockPermissionService(true))
265− params := BashParams{
266− Command: "echo 'test'",
267− Timeout: -100, // Negative timeout
268− }
269−
270− paramsJSON, err := json.Marshal(params)
271− require.NoError(t, err)
272−
273− call := ToolCall{
274− Name: BashToolName,
275− Input: string(paramsJSON),
276− }
277−
278− response, err := tool.Run(context.Background(), call)
279− require.NoError(t, err)
280− assert.Equal(t, "test\n", response.Content)
281− })
282−}
283−
284−func TestTruncateOutput(t *testing.T) {
285− t.Run("does not truncate short output", func(t *testing.T) {
286− output := "short output"
287− result := truncateOutput(output)
288− assert.Equal(t, output, result)
289− })
290−
291− t.Run("truncates long output", func(t *testing.T) {
292− // Create a string longer than MaxOutputLength
293− longOutput := strings.Repeat("a\n", MaxOutputLength)
294− result := truncateOutput(longOutput)
295−
296− // Check that the result is shorter than the original
297− assert.Less(t, len(result), len(longOutput))
298−
299− // Check that the truncation message is included
300− assert.Contains(t, result, "lines truncated")
301−
302− // Check that we have the beginning and end of the original string
303− assert.True(t, strings.HasPrefix(result, "a\n"))
304− assert.True(t, strings.HasSuffix(result, "a\n"))
305− })
306−}
307−
308−func TestCountLines(t *testing.T) {
309− testCases := []struct {
310− name string
311− input string
312− expected int
313− }{
314− {
315− name: "empty string",
316− input: "",
317− expected: 0,
318− },
319− {
320− name: "single line",
321− input: "line1",
322− expected: 1,
323− },
324− {
325− name: "multiple lines",
326− input: "line1\nline2\nline3",
327− expected: 3,
328− },
329− {
330− name: "trailing newline",
331− input: "line1\nline2\n",
332− expected: 3, // Empty string after last newline counts as a line
333− },
334− }
335−
336− for _, tc := range testCases {
337− t.Run(tc.name, func(t *testing.T) {
338− result := countLines(tc.input)
339− assert.Equal(t, tc.expected, result)
340− })
341− }
342−}
343−
344−// Mock permission service for testing
345−type mockPermissionService struct {
346− *pubsub.Broker[permission.PermissionRequest]
347− allow bool
348−}
349−
350−func (m *mockPermissionService) GrantPersistant(permission permission.PermissionRequest) {
351− // Not needed for tests
352−}
353−
354−func (m *mockPermissionService) Grant(permission permission.PermissionRequest) {
355− // Not needed for tests
356−}
357−
358−func (m *mockPermissionService) Deny(permission permission.PermissionRequest) {
359− // Not needed for tests
360−}
361−
362−func (m *mockPermissionService) Request(opts permission.CreatePermissionRequest) bool {
363− return m.allow
364−}
365−
366−func newMockPermissionService(allow bool) permission.Service {
367− return &mockPermissionService{
368− Broker: pubsub.NewBroker[permission.PermissionRequest](),
369− allow: allow,
370− }
371−}
internal/llm/tools/diagnostics.go+4−4
@@ -9,8 +9,8 @@ import (
99 "strings"
1010 "time"
1111
12− "github.com/kujtimiihoxha/termai/internal/lsp"
13− "github.com/kujtimiihoxha/termai/internal/lsp/protocol"
12+ "github.com/kujtimiihoxha/opencode/internal/lsp"
13+ "github.com/kujtimiihoxha/opencode/internal/lsp/protocol"
1414)
1515
1616type DiagnosticsParams struct {
@@ -82,7 +82,7 @@ func (b *diagnosticsTool) Run(ctx context.Context, call ToolCall) (ToolResponse,
8282 waitForLspDiagnostics(ctx, params.FilePath, lsps)
8383 }
8484
85− output := appendDiagnostics(params.FilePath, lsps)
85+ output := getDiagnostics(params.FilePath, lsps)
8686
8787 return NewTextResponse(output), nil
8888}
@@ -154,7 +154,7 @@ func hasDiagnosticsChanged(current, original map[protocol.DocumentUri][]protocol
154154 return false
155155}
156156
157−func appendDiagnostics(filePath string, lsps map[string]*lsp.Client) string {
157+func getDiagnostics(filePath string, lsps map[string]*lsp.Client) string {
158158 fileDiagnostics := []string{}
159159 projectDiagnostics := []string{}
160160
internal/llm/tools/edit.go+214−127
@@ -9,10 +9,12 @@ import (
99 "strings"
1010 "time"
1111
12− "github.com/kujtimiihoxha/termai/internal/config"
13− "github.com/kujtimiihoxha/termai/internal/lsp"
14− "github.com/kujtimiihoxha/termai/internal/permission"
15− "github.com/sergi/go-diff/diffmatchpatch"
12+ "github.com/kujtimiihoxha/opencode/internal/config"
13+ "github.com/kujtimiihoxha/opencode/internal/diff"
14+ "github.com/kujtimiihoxha/opencode/internal/history"
15+ "github.com/kujtimiihoxha/opencode/internal/logging"
16+ "github.com/kujtimiihoxha/opencode/internal/lsp"
17+ "github.com/kujtimiihoxha/opencode/internal/permission"
1618)
1719
1820type EditParams struct {
@@ -22,15 +24,20 @@ type EditParams struct {
2224}
2325
2426type EditPermissionsParams struct {
25− FilePath string `json:"file_path"`
26− OldString string `json:"old_string"`
27− NewString string `json:"new_string"`
27+ FilePath string `json:"file_path"`
28+ Diff string `json:"diff"`
29+}
30+
31+type EditResponseMetadata struct {
2832 Diff string `json:"diff"`
33+ Additions int `json:"additions"`
34+ Removals int `json:"removals"`
2935}
3036
3137type editTool struct {
3238 lspClients map[string]*lsp.Client
3339 permissions permission.Service
40+ files history.Service
3441}
3542
3643const (
@@ -84,10 +91,11 @@ When making edits:
8491Remember: when making multiple file edits in a row to the same file, you should prefer to send all edits in a single message with multiple calls to this tool, rather than multiple messages with a single call each.`
8592)
8693
87−func NewEditTool(lspClients map[string]*lsp.Client, permissions permission.Service) BaseTool {
94+func NewEditTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service) BaseTool {
8895 return &editTool{
8996 lspClients: lspClients,
9097 permissions: permissions,
98+ files: files,
9199 }
92100}
93101
@@ -128,275 +136,354 @@ func (e *editTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
128136 params.FilePath = filepath.Join(wd, params.FilePath)
129137 }
130138
139+ var response ToolResponse
140+ var err error
141+
131142 if params.OldString == "" {
132− result, err := e.createNewFile(params.FilePath, params.NewString)
143+ response, err = e.createNewFile(ctx, params.FilePath, params.NewString)
133144 if err != nil {
134− return NewTextErrorResponse(fmt.Sprintf("error creating file: %s", err)), nil
145+ return response, err
135146 }
136− return NewTextResponse(result), nil
137147 }
138148
139149 if params.NewString == "" {
140− result, err := e.deleteContent(params.FilePath, params.OldString)
150+ response, err = e.deleteContent(ctx, params.FilePath, params.OldString)
141151 if err != nil {
142− return NewTextErrorResponse(fmt.Sprintf("error deleting content: %s", err)), nil
152+ return response, err
143153 }
144− return NewTextResponse(result), nil
145154 }
146155
147− result, err := e.replaceContent(params.FilePath, params.OldString, params.NewString)
156+ response, err = e.replaceContent(ctx, params.FilePath, params.OldString, params.NewString)
148157 if err != nil {
149− return NewTextErrorResponse(fmt.Sprintf("error replacing content: %s", err)), nil
158+ return response, err
159+ }
160+ if response.IsError {
161+ // Return early if there was an error during content replacement
162+ // This prevents unnecessary LSP diagnostics processing
163+ return response, nil
150164 }
151165
152166 waitForLspDiagnostics(ctx, params.FilePath, e.lspClients)
153− result = fmt.Sprintf("<result>\n%s\n</result>\n", result)
154− result += appendDiagnostics(params.FilePath, e.lspClients)
155− return NewTextResponse(result), nil
167+ text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
168+ text += getDiagnostics(params.FilePath, e.lspClients)
169+ response.Content = text
170+ return response, nil
156171}
157172
158−func (e *editTool) createNewFile(filePath, content string) (string, error) {
173+func (e *editTool) createNewFile(ctx context.Context, filePath, content string) (ToolResponse, error) {
159174 fileInfo, err := os.Stat(filePath)
160175 if err == nil {
161176 if fileInfo.IsDir() {
162− return "", fmt.Errorf("path is a directory, not a file: %s", filePath)
177+ return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
163178 }
164− return "", fmt.Errorf("file already exists: %s. Use the Replace tool to overwrite an existing file", filePath)
179+ return NewTextErrorResponse(fmt.Sprintf("file already exists: %s", filePath)), nil
165180 } else if !os.IsNotExist(err) {
166− return "", fmt.Errorf("failed to access file: %w", err)
181+ return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
167182 }
168183
169184 dir := filepath.Dir(filePath)
170185 if err = os.MkdirAll(dir, 0o755); err != nil {
171− return "", fmt.Errorf("failed to create parent directories: %w", err)
186+ return ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
172187 }
173188
189+ sessionID, messageID := GetContextValues(ctx)
190+ if sessionID == "" || messageID == "" {
191+ return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
192+ }
193+
194+ diff, additions, removals := diff.GenerateDiff(
195+ "",
196+ content,
197+ filePath,
198+ )
199+ rootDir := config.WorkingDirectory()
200+ permissionPath := filepath.Dir(filePath)
201+ if strings.HasPrefix(filePath, rootDir) {
202+ permissionPath = rootDir
203+ }
174204 p := e.permissions.Request(
175205 permission.CreatePermissionRequest{
176− Path: filepath.Dir(filePath),
206+ SessionID: sessionID,
207+ Path: permissionPath,
177208 ToolName: EditToolName,
178− Action: "create",
209+ Action: "write",
179210 Description: fmt.Sprintf("Create file %s", filePath),
180211 Params: EditPermissionsParams{
181− FilePath: filePath,
182− OldString: "",
183− NewString: content,
184− Diff: GenerateDiff("", content),
212+ FilePath: filePath,
213+ Diff: diff,
185214 },
186215 },
187216 )
188217 if !p {
189− return "", fmt.Errorf("permission denied")
218+ return ToolResponse{}, permission.ErrorPermissionDenied
190219 }
191220
192221 err = os.WriteFile(filePath, []byte(content), 0o644)
193222 if err != nil {
194− return "", fmt.Errorf("failed to write file: %w", err)
223+ return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
224+ }
225+
226+ // File can't be in the history so we create a new file history
227+ _, err = e.files.Create(ctx, sessionID, filePath, "")
228+ if err != nil {
229+ // Log error but don't fail the operation
230+ return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
231+ }
232+
233+ // Add the new content to the file history
234+ _, err = e.files.CreateVersion(ctx, sessionID, filePath, content)
235+ if err != nil {
236+ // Log error but don't fail the operation
237+ logging.Debug("Error creating file history version", "error", err)
195238 }
196239
197240 recordFileWrite(filePath)
198241 recordFileRead(filePath)
199242
200− return "File created: " + filePath, nil
243+ return WithResponseMetadata(
244+ NewTextResponse("File created: "+filePath),
245+ EditResponseMetadata{
246+ Diff: diff,
247+ Additions: additions,
248+ Removals: removals,
249+ },
250+ ), nil
201251}
202252
203−func (e *editTool) deleteContent(filePath, oldString string) (string, error) {
253+func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string) (ToolResponse, error) {
204254 fileInfo, err := os.Stat(filePath)
205255 if err != nil {
206256 if os.IsNotExist(err) {
207− return "", fmt.Errorf("file not found: %s", filePath)
257+ return NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
208258 }
209− return "", fmt.Errorf("failed to access file: %w", err)
259+ return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
210260 }
211261
212262 if fileInfo.IsDir() {
213− return "", fmt.Errorf("path is a directory, not a file: %s", filePath)
263+ return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
214264 }
215265
216266 if getLastReadTime(filePath).IsZero() {
217− return "", fmt.Errorf("you must read the file before editing it. Use the View tool first")
267+ return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
218268 }
219269
220270 modTime := fileInfo.ModTime()
221271 lastRead := getLastReadTime(filePath)
222272 if modTime.After(lastRead) {
223− return "", fmt.Errorf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
224− filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))
273+ return NewTextErrorResponse(
274+ fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
275+ filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
276+ )), nil
225277 }
226278
227279 content, err := os.ReadFile(filePath)
228280 if err != nil {
229− return "", fmt.Errorf("failed to read file: %w", err)
281+ return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
230282 }
231283
232284 oldContent := string(content)
233285
234286 index := strings.Index(oldContent, oldString)
235287 if index == -1 {
236− return "", fmt.Errorf("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks")
288+ return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
237289 }
238290
239291 lastIndex := strings.LastIndex(oldContent, oldString)
240292 if index != lastIndex {
241− return "", fmt.Errorf("old_string appears multiple times in the file. Please provide more context to ensure a unique match")
293+ return NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match"), nil
242294 }
243295
244296 newContent := oldContent[:index] + oldContent[index+len(oldString):]
245297
298+ sessionID, messageID := GetContextValues(ctx)
299+
300+ if sessionID == "" || messageID == "" {
301+ return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
302+ }
303+
304+ diff, additions, removals := diff.GenerateDiff(
305+ oldContent,
306+ newContent,
307+ filePath,
308+ )
309+
310+ rootDir := config.WorkingDirectory()
311+ permissionPath := filepath.Dir(filePath)
312+ if strings.HasPrefix(filePath, rootDir) {
313+ permissionPath = rootDir
314+ }
246315 p := e.permissions.Request(
247316 permission.CreatePermissionRequest{
248− Path: filepath.Dir(filePath),
317+ SessionID: sessionID,
318+ Path: permissionPath,
249319 ToolName: EditToolName,
250− Action: "delete",
320+ Action: "write",
251321 Description: fmt.Sprintf("Delete content from file %s", filePath),
252322 Params: EditPermissionsParams{
253− FilePath: filePath,
254− OldString: oldString,
255− NewString: "",
256− Diff: GenerateDiff(oldContent, newContent),
323+ FilePath: filePath,
324+ Diff: diff,
257325 },
258326 },
259327 )
260328 if !p {
261− return "", fmt.Errorf("permission denied")
329+ return ToolResponse{}, permission.ErrorPermissionDenied
262330 }
263331
264332 err = os.WriteFile(filePath, []byte(newContent), 0o644)
265333 if err != nil {
266− return "", fmt.Errorf("failed to write file: %w", err)
334+ return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
335+ }
336+
337+ // Check if file exists in history
338+ file, err := e.files.GetByPathAndSession(ctx, filePath, sessionID)
339+ if err != nil {
340+ _, err = e.files.Create(ctx, sessionID, filePath, oldContent)
341+ if err != nil {
342+ // Log error but don't fail the operation
343+ return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
344+ }
345+ }
346+ if file.Content != oldContent {
347+ // User Manually changed the content store an intermediate version
348+ _, err = e.files.CreateVersion(ctx, sessionID, filePath, oldContent)
349+ if err != nil {
350+ logging.Debug("Error creating file history version", "error", err)
351+ }
352+ }
353+ // Store the new version
354+ _, err = e.files.CreateVersion(ctx, sessionID, filePath, "")
355+ if err != nil {
356+ logging.Debug("Error creating file history version", "error", err)
267357 }
268358
269359 recordFileWrite(filePath)
270360 recordFileRead(filePath)
271361
272− return "Content deleted from file: " + filePath, nil
362+ return WithResponseMetadata(
363+ NewTextResponse("Content deleted from file: "+filePath),
364+ EditResponseMetadata{
365+ Diff: diff,
366+ Additions: additions,
367+ Removals: removals,
368+ },
369+ ), nil
273370}
274371
275−func (e *editTool) replaceContent(filePath, oldString, newString string) (string, error) {
372+func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newString string) (ToolResponse, error) {
276373 fileInfo, err := os.Stat(filePath)
277374 if err != nil {
278375 if os.IsNotExist(err) {
279− return "", fmt.Errorf("file not found: %s", filePath)
376+ return NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
280377 }
281− return "", fmt.Errorf("failed to access file: %w", err)
378+ return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
282379 }
283380
284381 if fileInfo.IsDir() {
285− return "", fmt.Errorf("path is a directory, not a file: %s", filePath)
382+ return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
286383 }
287384
288385 if getLastReadTime(filePath).IsZero() {
289− return "", fmt.Errorf("you must read the file before editing it. Use the View tool first")
386+ return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
290387 }
291388
292389 modTime := fileInfo.ModTime()
293390 lastRead := getLastReadTime(filePath)
294391 if modTime.After(lastRead) {
295− return "", fmt.Errorf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
296− filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))
392+ return NewTextErrorResponse(
393+ fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
394+ filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
395+ )), nil
297396 }
298397
299398 content, err := os.ReadFile(filePath)
300399 if err != nil {
301− return "", fmt.Errorf("failed to read file: %w", err)
400+ return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
302401 }
303402
304403 oldContent := string(content)
305404
306405 index := strings.Index(oldContent, oldString)
307406 if index == -1 {
308− return "", fmt.Errorf("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks")
407+ return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
309408 }
310409
311410 lastIndex := strings.LastIndex(oldContent, oldString)
312411 if index != lastIndex {
313− return "", fmt.Errorf("old_string appears multiple times in the file. Please provide more context to ensure a unique match")
412+ return NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match"), nil
314413 }
315414
316415 newContent := oldContent[:index] + newString + oldContent[index+len(oldString):]
317416
318− startIndex := max(0, index-3)
319− oldEndIndex := min(len(oldContent), index+len(oldString)+3)
320− newEndIndex := min(len(newContent), index+len(newString)+3)
321−
322− diff := GenerateDiff(oldContent[startIndex:oldEndIndex], newContent[startIndex:newEndIndex])
417+ if oldContent == newContent {
418+ return NewTextErrorResponse("new content is the same as old content. No changes made."), nil
419+ }
420+ sessionID, messageID := GetContextValues(ctx)
323421
422+ if sessionID == "" || messageID == "" {
423+ return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
424+ }
425+ diff, additions, removals := diff.GenerateDiff(
426+ oldContent,
427+ newContent,
428+ filePath,
429+ )
430+ rootDir := config.WorkingDirectory()
431+ permissionPath := filepath.Dir(filePath)
432+ if strings.HasPrefix(filePath, rootDir) {
433+ permissionPath = rootDir
434+ }
324435 p := e.permissions.Request(
325436 permission.CreatePermissionRequest{
326− Path: filepath.Dir(filePath),
437+ SessionID: sessionID,
438+ Path: permissionPath,
327439 ToolName: EditToolName,
328− Action: "replace",
440+ Action: "write",
329441 Description: fmt.Sprintf("Replace content in file %s", filePath),
330442 Params: EditPermissionsParams{
331− FilePath: filePath,
332− OldString: oldString,
333− NewString: newString,
334− Diff: diff,
443+ FilePath: filePath,
444+ Diff: diff,
335445 },
336446 },
337447 )
338448 if !p {
339− return "", fmt.Errorf("permission denied")
449+ return ToolResponse{}, permission.ErrorPermissionDenied
340450 }
341451
342452 err = os.WriteFile(filePath, []byte(newContent), 0o644)
343453 if err != nil {
344− return "", fmt.Errorf("failed to write file: %w", err)
454+ return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
455+ }
456+
457+ // Check if file exists in history
458+ file, err := e.files.GetByPathAndSession(ctx, filePath, sessionID)
459+ if err != nil {
460+ _, err = e.files.Create(ctx, sessionID, filePath, oldContent)
461+ if err != nil {
462+ // Log error but don't fail the operation
463+ return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
464+ }
465+ }
466+ if file.Content != oldContent {
467+ // User Manually changed the content store an intermediate version
468+ _, err = e.files.CreateVersion(ctx, sessionID, filePath, oldContent)
469+ if err != nil {
470+ logging.Debug("Error creating file history version", "error", err)
471+ }
472+ }
473+ // Store the new version
474+ _, err = e.files.CreateVersion(ctx, sessionID, filePath, newContent)
475+ if err != nil {
476+ logging.Debug("Error creating file history version", "error", err)
345477 }
346478
347479 recordFileWrite(filePath)
348480 recordFileRead(filePath)
349481
350− return "Content replaced in file: " + filePath, nil
351−}
352−
353−func GenerateDiff(oldContent, newContent string) string {
354− dmp := diffmatchpatch.New()
355− fileAdmp, fileBdmp, dmpStrings := dmp.DiffLinesToChars(oldContent, newContent)
356− diffs := dmp.DiffMain(fileAdmp, fileBdmp, false)
357− diffs = dmp.DiffCharsToLines(diffs, dmpStrings)
358− diffs = dmp.DiffCleanupSemantic(diffs)
359− buff := strings.Builder{}
360−
361− buff.WriteString("Changes:\n")
362−
363− for _, diff := range diffs {
364− text := diff.Text
365−
366− switch diff.Type {
367− case diffmatchpatch.DiffInsert:
368− for line := range strings.SplitSeq(text, "\n") {
369− if line == "" {
370− continue
371− }
372− _, _ = buff.WriteString("+ " + line + "\n")
373− }
374− case diffmatchpatch.DiffDelete:
375− for line := range strings.SplitSeq(text, "\n") {
376− if line == "" {
377− continue
378− }
379− _, _ = buff.WriteString("- " + line + "\n")
380− }
381− case diffmatchpatch.DiffEqual:
382− lines := strings.Split(text, "\n")
383− if len(lines) > 3 {
384− if lines[0] != "" {
385− _, _ = buff.WriteString(" " + lines[0] + "\n")
386− }
387− _, _ = buff.WriteString(" ...\n")
388− if lines[len(lines)-1] != "" {
389− _, _ = buff.WriteString(" " + lines[len(lines)-1] + "\n")
390− }
391− } else {
392− for _, line := range lines {
393− if line == "" {
394− continue
395− }
396− _, _ = buff.WriteString(" " + line + "\n")
397− }
398− }
399− }
400− }
401− return buff.String()
482+ return WithResponseMetadata(
483+ NewTextResponse("Content replaced in file: "+filePath),
484+ EditResponseMetadata{
485+ Diff: diff,
486+ Additions: additions,
487+ Removals: removals,
488+ }), nil
402489}
internal/llm/tools/edit_test.godeleted−509
@@ -1,509 +0,0 @@
1−package tools
2−
3−import (
4− "context"
5− "encoding/json"
6− "os"
7− "path/filepath"
8− "testing"
9− "time"
10−
11− "github.com/kujtimiihoxha/termai/internal/lsp"
12− "github.com/stretchr/testify/assert"
13− "github.com/stretchr/testify/require"
14−)
15−
16−func TestEditTool_Info(t *testing.T) {
17− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
18− info := tool.Info()
19−
20− assert.Equal(t, EditToolName, info.Name)
21− assert.NotEmpty(t, info.Description)
22− assert.Contains(t, info.Parameters, "file_path")
23− assert.Contains(t, info.Parameters, "old_string")
24− assert.Contains(t, info.Parameters, "new_string")
25− assert.Contains(t, info.Required, "file_path")
26− assert.Contains(t, info.Required, "old_string")
27− assert.Contains(t, info.Required, "new_string")
28−}
29−
30−func TestEditTool_Run(t *testing.T) {
31− // Create a temporary directory for testing
32− tempDir, err := os.MkdirTemp("", "edit_tool_test")
33− require.NoError(t, err)
34− defer os.RemoveAll(tempDir)
35−
36− t.Run("creates a new file successfully", func(t *testing.T) {
37− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
38−
39− filePath := filepath.Join(tempDir, "new_file.txt")
40− content := "This is a test content"
41−
42− params := EditParams{
43− FilePath: filePath,
44− OldString: "",
45− NewString: content,
46− }
47−
48− paramsJSON, err := json.Marshal(params)
49− require.NoError(t, err)
50−
51− call := ToolCall{
52− Name: EditToolName,
53− Input: string(paramsJSON),
54− }
55−
56− response, err := tool.Run(context.Background(), call)
57− require.NoError(t, err)
58− assert.Contains(t, response.Content, "File created")
59−
60− // Verify file was created with correct content
61− fileContent, err := os.ReadFile(filePath)
62− require.NoError(t, err)
63− assert.Equal(t, content, string(fileContent))
64− })
65−
66− t.Run("creates file with nested directories", func(t *testing.T) {
67− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
68−
69− filePath := filepath.Join(tempDir, "nested/dirs/new_file.txt")
70− content := "Content in nested directory"
71−
72− params := EditParams{
73− FilePath: filePath,
74− OldString: "",
75− NewString: content,
76− }
77−
78− paramsJSON, err := json.Marshal(params)
79− require.NoError(t, err)
80−
81− call := ToolCall{
82− Name: EditToolName,
83− Input: string(paramsJSON),
84− }
85−
86− response, err := tool.Run(context.Background(), call)
87− require.NoError(t, err)
88− assert.Contains(t, response.Content, "File created")
89−
90− // Verify file was created with correct content
91− fileContent, err := os.ReadFile(filePath)
92− require.NoError(t, err)
93− assert.Equal(t, content, string(fileContent))
94− })
95−
96− t.Run("fails to create file that already exists", func(t *testing.T) {
97− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
98−
99− // Create a file first
100− filePath := filepath.Join(tempDir, "existing_file.txt")
101− initialContent := "Initial content"
102− err := os.WriteFile(filePath, []byte(initialContent), 0o644)
103− require.NoError(t, err)
104−
105− // Try to create the same file
106− params := EditParams{
107− FilePath: filePath,
108− OldString: "",
109− NewString: "New content",
110− }
111−
112− paramsJSON, err := json.Marshal(params)
113− require.NoError(t, err)
114−
115− call := ToolCall{
116− Name: EditToolName,
117− Input: string(paramsJSON),
118− }
119−
120− response, err := tool.Run(context.Background(), call)
121− require.NoError(t, err)
122− assert.Contains(t, response.Content, "file already exists")
123− })
124−
125− t.Run("fails to create file when path is a directory", func(t *testing.T) {
126− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
127−
128− // Create a directory
129− dirPath := filepath.Join(tempDir, "test_dir")
130− err := os.Mkdir(dirPath, 0o755)
131− require.NoError(t, err)
132−
133− // Try to create a file with the same path as the directory
134− params := EditParams{
135− FilePath: dirPath,
136− OldString: "",
137− NewString: "Some content",
138− }
139−
140− paramsJSON, err := json.Marshal(params)
141− require.NoError(t, err)
142−
143− call := ToolCall{
144− Name: EditToolName,
145− Input: string(paramsJSON),
146− }
147−
148− response, err := tool.Run(context.Background(), call)
149− require.NoError(t, err)
150− assert.Contains(t, response.Content, "path is a directory")
151− })
152−
153− t.Run("replaces content successfully", func(t *testing.T) {
154− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
155−
156− // Create a file first
157− filePath := filepath.Join(tempDir, "replace_content.txt")
158− initialContent := "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
159− err := os.WriteFile(filePath, []byte(initialContent), 0o644)
160− require.NoError(t, err)
161−
162− // Record the file read to avoid modification time check failure
163− recordFileRead(filePath)
164−
165− // Replace content
166− oldString := "Line 2\nLine 3"
167− newString := "Line 2 modified\nLine 3 modified"
168− params := EditParams{
169− FilePath: filePath,
170− OldString: oldString,
171− NewString: newString,
172− }
173−
174− paramsJSON, err := json.Marshal(params)
175− require.NoError(t, err)
176−
177− call := ToolCall{
178− Name: EditToolName,
179− Input: string(paramsJSON),
180− }
181−
182− response, err := tool.Run(context.Background(), call)
183− require.NoError(t, err)
184− assert.Contains(t, response.Content, "Content replaced")
185−
186− // Verify file was updated with correct content
187− expectedContent := "Line 1\nLine 2 modified\nLine 3 modified\nLine 4\nLine 5"
188− fileContent, err := os.ReadFile(filePath)
189− require.NoError(t, err)
190− assert.Equal(t, expectedContent, string(fileContent))
191− })
192−
193− t.Run("deletes content successfully", func(t *testing.T) {
194− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
195−
196− // Create a file first
197− filePath := filepath.Join(tempDir, "delete_content.txt")
198− initialContent := "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
199− err := os.WriteFile(filePath, []byte(initialContent), 0o644)
200− require.NoError(t, err)
201−
202− // Record the file read to avoid modification time check failure
203− recordFileRead(filePath)
204−
205− // Delete content
206− oldString := "Line 2\nLine 3\n"
207− params := EditParams{
208− FilePath: filePath,
209− OldString: oldString,
210− NewString: "",
211− }
212−
213− paramsJSON, err := json.Marshal(params)
214− require.NoError(t, err)
215−
216− call := ToolCall{
217− Name: EditToolName,
218− Input: string(paramsJSON),
219− }
220−
221− response, err := tool.Run(context.Background(), call)
222− require.NoError(t, err)
223− assert.Contains(t, response.Content, "Content deleted")
224−
225− // Verify file was updated with correct content
226− expectedContent := "Line 1\nLine 4\nLine 5"
227− fileContent, err := os.ReadFile(filePath)
228− require.NoError(t, err)
229− assert.Equal(t, expectedContent, string(fileContent))
230− })
231−
232− t.Run("handles invalid parameters", func(t *testing.T) {
233− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
234−
235− call := ToolCall{
236− Name: EditToolName,
237− Input: "invalid json",
238− }
239−
240− response, err := tool.Run(context.Background(), call)
241− require.NoError(t, err)
242− assert.Contains(t, response.Content, "invalid parameters")
243− })
244−
245− t.Run("handles missing file_path", func(t *testing.T) {
246− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
247−
248− params := EditParams{
249− FilePath: "",
250− OldString: "old",
251− NewString: "new",
252− }
253−
254− paramsJSON, err := json.Marshal(params)
255− require.NoError(t, err)
256−
257− call := ToolCall{
258− Name: EditToolName,
259− Input: string(paramsJSON),
260− }
261−
262− response, err := tool.Run(context.Background(), call)
263− require.NoError(t, err)
264− assert.Contains(t, response.Content, "file_path is required")
265− })
266−
267− t.Run("handles file not found", func(t *testing.T) {
268− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
269−
270− filePath := filepath.Join(tempDir, "non_existent_file.txt")
271− params := EditParams{
272− FilePath: filePath,
273− OldString: "old content",
274− NewString: "new content",
275− }
276−
277− paramsJSON, err := json.Marshal(params)
278− require.NoError(t, err)
279−
280− call := ToolCall{
281− Name: EditToolName,
282− Input: string(paramsJSON),
283− }
284−
285− response, err := tool.Run(context.Background(), call)
286− require.NoError(t, err)
287− assert.Contains(t, response.Content, "file not found")
288− })
289−
290− t.Run("handles old_string not found in file", func(t *testing.T) {
291− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
292−
293− // Create a file first
294− filePath := filepath.Join(tempDir, "content_not_found.txt")
295− initialContent := "Line 1\nLine 2\nLine 3"
296− err := os.WriteFile(filePath, []byte(initialContent), 0o644)
297− require.NoError(t, err)
298−
299− // Record the file read to avoid modification time check failure
300− recordFileRead(filePath)
301−
302− // Try to replace content that doesn't exist
303− params := EditParams{
304− FilePath: filePath,
305− OldString: "This content does not exist",
306− NewString: "new content",
307− }
308−
309− paramsJSON, err := json.Marshal(params)
310− require.NoError(t, err)
311−
312− call := ToolCall{
313− Name: EditToolName,
314− Input: string(paramsJSON),
315− }
316−
317− response, err := tool.Run(context.Background(), call)
318− require.NoError(t, err)
319− assert.Contains(t, response.Content, "old_string not found in file")
320− })
321−
322− t.Run("handles multiple occurrences of old_string", func(t *testing.T) {
323− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
324−
325− // Create a file with duplicate content
326− filePath := filepath.Join(tempDir, "duplicate_content.txt")
327− initialContent := "Line 1\nDuplicate\nLine 3\nDuplicate\nLine 5"
328− err := os.WriteFile(filePath, []byte(initialContent), 0o644)
329− require.NoError(t, err)
330−
331− // Record the file read to avoid modification time check failure
332− recordFileRead(filePath)
333−
334− // Try to replace content that appears multiple times
335− params := EditParams{
336− FilePath: filePath,
337− OldString: "Duplicate",
338− NewString: "Replaced",
339− }
340−
341− paramsJSON, err := json.Marshal(params)
342− require.NoError(t, err)
343−
344− call := ToolCall{
345− Name: EditToolName,
346− Input: string(paramsJSON),
347− }
348−
349− response, err := tool.Run(context.Background(), call)
350− require.NoError(t, err)
351− assert.Contains(t, response.Content, "appears multiple times")
352− })
353−
354− t.Run("handles file modified since last read", func(t *testing.T) {
355− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
356−
357− // Create a file
358− filePath := filepath.Join(tempDir, "modified_file.txt")
359− initialContent := "Initial content"
360− err := os.WriteFile(filePath, []byte(initialContent), 0o644)
361− require.NoError(t, err)
362−
363− // Record an old read time
364− fileRecordMutex.Lock()
365− fileRecords[filePath] = fileRecord{
366− path: filePath,
367− readTime: time.Now().Add(-1 * time.Hour),
368− }
369− fileRecordMutex.Unlock()
370−
371− // Try to update the file
372− params := EditParams{
373− FilePath: filePath,
374− OldString: "Initial",
375− NewString: "Updated",
376− }
377−
378− paramsJSON, err := json.Marshal(params)
379− require.NoError(t, err)
380−
381− call := ToolCall{
382− Name: EditToolName,
383− Input: string(paramsJSON),
384− }
385−
386− response, err := tool.Run(context.Background(), call)
387− require.NoError(t, err)
388− assert.Contains(t, response.Content, "has been modified since it was last read")
389−
390− // Verify file was not modified
391− fileContent, err := os.ReadFile(filePath)
392− require.NoError(t, err)
393− assert.Equal(t, initialContent, string(fileContent))
394− })
395−
396− t.Run("handles file not read before editing", func(t *testing.T) {
397− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
398−
399− // Create a file
400− filePath := filepath.Join(tempDir, "not_read_file.txt")
401− initialContent := "Initial content"
402− err := os.WriteFile(filePath, []byte(initialContent), 0o644)
403− require.NoError(t, err)
404−
405− // Try to update the file without reading it first
406− params := EditParams{
407− FilePath: filePath,
408− OldString: "Initial",
409− NewString: "Updated",
410− }
411−
412− paramsJSON, err := json.Marshal(params)
413− require.NoError(t, err)
414−
415− call := ToolCall{
416− Name: EditToolName,
417− Input: string(paramsJSON),
418− }
419−
420− response, err := tool.Run(context.Background(), call)
421− require.NoError(t, err)
422− assert.Contains(t, response.Content, "you must read the file before editing it")
423− })
424−
425− t.Run("handles permission denied", func(t *testing.T) {
426− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(false))
427−
428− // Create a file
429− filePath := filepath.Join(tempDir, "permission_denied.txt")
430− initialContent := "Initial content"
431− err := os.WriteFile(filePath, []byte(initialContent), 0o644)
432− require.NoError(t, err)
433−
434− // Record the file read to avoid modification time check failure
435− recordFileRead(filePath)
436−
437− // Try to update the file
438− params := EditParams{
439− FilePath: filePath,
440− OldString: "Initial",
441− NewString: "Updated",
442− }
443−
444− paramsJSON, err := json.Marshal(params)
445− require.NoError(t, err)
446−
447− call := ToolCall{
448− Name: EditToolName,
449− Input: string(paramsJSON),
450− }
451−
452− response, err := tool.Run(context.Background(), call)
453− require.NoError(t, err)
454− assert.Contains(t, response.Content, "permission denied")
455−
456− // Verify file was not modified
457− fileContent, err := os.ReadFile(filePath)
458− require.NoError(t, err)
459− assert.Equal(t, initialContent, string(fileContent))
460− })
461−}
462−
463−func TestGenerateDiff(t *testing.T) {
464− testCases := []struct {
465− name string
466− oldContent string
467− newContent string
468− expectedDiff string
469− }{
470− {
471− name: "add content",
472− oldContent: "Line 1\nLine 2\n",
473− newContent: "Line 1\nLine 2\nLine 3\n",
474− expectedDiff: "Changes:\n Line 1\n Line 2\n+ Line 3\n",
475− },
476− {
477− name: "remove content",
478− oldContent: "Line 1\nLine 2\nLine 3\n",
479− newContent: "Line 1\nLine 3\n",
480− expectedDiff: "Changes:\n Line 1\n- Line 2\n Line 3\n",
481− },
482− {
483− name: "replace content",
484− oldContent: "Line 1\nLine 2\nLine 3\n",
485− newContent: "Line 1\nModified Line\nLine 3\n",
486− expectedDiff: "Changes:\n Line 1\n- Line 2\n+ Modified Line\n Line 3\n",
487− },
488− {
489− name: "empty to content",
490− oldContent: "",
491− newContent: "Line 1\nLine 2\n",
492− expectedDiff: "Changes:\n+ Line 1\n+ Line 2\n",
493− },
494− {
495− name: "content to empty",
496− oldContent: "Line 1\nLine 2\n",
497− newContent: "",
498− expectedDiff: "Changes:\n- Line 1\n- Line 2\n",
499− },
500− }
501−
502− for _, tc := range testCases {
503− t.Run(tc.name, func(t *testing.T) {
504− diff := GenerateDiff(tc.oldContent, tc.newContent)
505− assert.Contains(t, diff, tc.expectedDiff)
506− })
507− }
508−}
509−
internal/llm/tools/fetch.go+13−6
@@ -11,8 +11,8 @@ import (
1111
1212 md "github.com/JohannesKaufmann/html-to-markdown"
1313 "github.com/PuerkitoBio/goquery"
14− "github.com/kujtimiihoxha/termai/internal/config"
15− "github.com/kujtimiihoxha/termai/internal/permission"
14+ "github.com/kujtimiihoxha/opencode/internal/config"
15+ "github.com/kujtimiihoxha/opencode/internal/permission"
1616)
1717
1818type FetchParams struct {
@@ -86,6 +86,7 @@ func (t *fetchTool) Info() ToolInfo {
8686 "format": map[string]any{
8787 "type": "string",
8888 "description": "The format to return the content in (text, markdown, or html)",
89+ "enum": []string{"text", "markdown", "html"},
8990 },
9091 "timeout": map[string]any{
9192 "type": "number",
@@ -115,8 +116,14 @@ func (t *fetchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
115116 return NewTextErrorResponse("URL must start with http:// or https://"), nil
116117 }
117118
119+ sessionID, messageID := GetContextValues(ctx)
120+ if sessionID == "" || messageID == "" {
121+ return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
122+ }
123+
118124 p := t.permissions.Request(
119125 permission.CreatePermissionRequest{
126+ SessionID: sessionID,
120127 Path: config.WorkingDirectory(),
121128 ToolName: FetchToolName,
122129 Action: "fetch",
@@ -126,7 +133,7 @@ func (t *fetchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
126133 )
127134
128135 if !p {
129− return NewTextErrorResponse("Permission denied to fetch from URL: " + params.URL), nil
136+ return ToolResponse{}, permission.ErrorPermissionDenied
130137 }
131138
132139 client := t.client
@@ -142,14 +149,14 @@ func (t *fetchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
142149
143150 req, err := http.NewRequestWithContext(ctx, "GET", params.URL, nil)
144151 if err != nil {
145− return NewTextErrorResponse("Failed to create request: " + err.Error()), nil
152+ return ToolResponse{}, fmt.Errorf("failed to create request: %w", err)
146153 }
147154
148− req.Header.Set("User-Agent", "termai/1.0")
155+ req.Header.Set("User-Agent", "opencode/1.0")
149156
150157 resp, err := client.Do(req)
151158 if err != nil {
152− return NewTextErrorResponse("Failed to execute request: " + err.Error()), nil
159+ return ToolResponse{}, fmt.Errorf("failed to fetch URL: %w", err)
153160 }
154161 defer resp.Body.Close()
155162
internal/llm/tools/glob.go+51−4
@@ -12,7 +12,7 @@ import (
1212 "time"
1313
1414 "github.com/bmatcuk/doublestar/v4"
15− "github.com/kujtimiihoxha/termai/internal/config"
15+ "github.com/kujtimiihoxha/opencode/internal/config"
1616)
1717
1818const (
@@ -63,6 +63,11 @@ type GlobParams struct {
6363 Path string `json:"path"`
6464}
6565
66+type GlobResponseMetadata struct {
67+ NumberOfFiles int `json:"number_of_files"`
68+ Truncated bool `json:"truncated"`
69+}
70+
6671type globTool struct{}
6772
6873func NewGlobTool() BaseTool {
@@ -104,7 +109,7 @@ func (g *globTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
104109
105110 files, truncated, err := globFiles(params.Pattern, searchPath, 100)
106111 if err != nil {
107− return NewTextErrorResponse(fmt.Sprintf("error performing glob search: %s", err)), nil
112+ return ToolResponse{}, fmt.Errorf("error finding files: %w", err)
108113 }
109114
110115 var output string
@@ -117,7 +122,13 @@ func (g *globTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
117122 }
118123 }
119124
120− return NewTextResponse(output), nil
125+ return WithResponseMetadata(
126+ NewTextResponse(output),
127+ GlobResponseMetadata{
128+ NumberOfFiles: len(files),
129+ Truncated: truncated,
130+ },
131+ ), nil
121132}
122133
123134func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) {
@@ -181,6 +192,42 @@ func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) {
181192}
182193
183194func skipHidden(path string) bool {
195+ // Check for hidden files (starting with a dot)
184196 base := filepath.Base(path)
185− 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
186233}
internal/llm/tools/grep.go+103−42
@@ -10,21 +10,30 @@ import (
1010 "path/filepath"
1111 "regexp"
1212 "sort"
13+ "strconv"
1314 "strings"
1415 "time"
1516
16− "github.com/kujtimiihoxha/termai/internal/config"
17+ "github.com/kujtimiihoxha/opencode/internal/config"
1718)
1819
1920type GrepParams struct {
20− Pattern string `json:"pattern"`
21− Path string `json:"path"`
22− Include string `json:"include"`
21+ Pattern string `json:"pattern"`
22+ Path string `json:"path"`
23+ Include string `json:"include"`
24+ LiteralText bool `json:"literal_text"`
2325}
2426
2527type grepMatch struct {
26− path string
27− modTime time.Time
28+ path string
29+ modTime time.Time
30+ lineNum int
31+ lineText string
32+}
33+
34+type GrepResponseMetadata struct {
35+ NumberOfMatches int `json:"number_of_matches"`
36+ Truncated bool `json:"truncated"`
2837}
2938
3039type grepTool struct{}
@@ -40,11 +49,12 @@ WHEN TO USE THIS TOOL:
4049
4150HOW TO USE:
4251- Provide a regex pattern to search for within file contents
52+- Set literal_text=true if you want to search for the exact text with special characters (recommended for non-regex users)
4353- Optionally specify a starting directory (defaults to current working directory)
4454- Optionally provide an include pattern to filter which files to search
4555- Results are sorted with most recently modified files first
4656
47−REGEX PATTERN SYNTAX:
57+REGEX PATTERN SYNTAX (when literal_text=false):
4858- Supports standard regular expression syntax
4959- 'function' searches for the literal text "function"
5060- 'log\..*Error' finds text starting with "log." and ending with "Error"
@@ -64,7 +74,8 @@ LIMITATIONS:
6474TIPS:
6575- For faster, more targeted searches, first use Glob to find relevant files, then use Grep
6676- When doing iterative exploration that may require multiple rounds of searching, consider using the Agent tool instead
67−- Always check if results are truncated and refine your search pattern if needed`
77+- Always check if results are truncated and refine your search pattern if needed
78+- Use literal_text=true when searching for exact text containing special characters like dots, parentheses, etc.`
6879)
6980
7081func NewGrepTool() BaseTool {
@@ -88,11 +99,27 @@ func (g *grepTool) Info() ToolInfo {
8899 "type": "string",
89100 "description": "File pattern to include in the search (e.g. \"*.js\", \"*.{ts,tsx}\")",
90101 },
102+ "literal_text": map[string]any{
103+ "type": "boolean",
104+ "description": "If true, the pattern will be treated as literal text with special regex characters escaped. Default is false.",
105+ },
91106 },
92107 Required: []string{"pattern"},
93108 }
94109}
95110
111+// escapeRegexPattern escapes special regex characters so they're treated as literal characters
112+func escapeRegexPattern(pattern string) string {
113+ specialChars := []string{"\\", ".", "+", "*", "?", "(", ")", "[", "]", "{", "}", "^", "$", "|"}
114+ escaped := pattern
115+
116+ for _, char := range specialChars {
117+ escaped = strings.ReplaceAll(escaped, char, "\\"+char)
118+ }
119+
120+ return escaped
121+}
122+
96123func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
97124 var params GrepParams
98125 if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
@@ -103,41 +130,59 @@ func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
103130 return NewTextErrorResponse("pattern is required"), nil
104131 }
105132
133+ // If literal_text is true, escape the pattern
134+ searchPattern := params.Pattern
135+ if params.LiteralText {
136+ searchPattern = escapeRegexPattern(params.Pattern)
137+ }
138+
106139 searchPath := params.Path
107140 if searchPath == "" {
108141 searchPath = config.WorkingDirectory()
109142 }
110143
111− matches, truncated, err := searchFiles(params.Pattern, searchPath, params.Include, 100)
144+ matches, truncated, err := searchFiles(searchPattern, searchPath, params.Include, 100)
112145 if err != nil {
113− return NewTextErrorResponse(fmt.Sprintf("error searching files: %s", err)), nil
146+ return ToolResponse{}, fmt.Errorf("error searching files: %w", err)
114147 }
115148
116149 var output string
117150 if len(matches) == 0 {
118151 output = "No files found"
119152 } else {
120− output = fmt.Sprintf("Found %d file%s\n%s",
121− len(matches),
122− pluralize(len(matches)),
123− strings.Join(matches, "\n"))
153+ output = fmt.Sprintf("Found %d matches\n", len(matches))
154+
155+ currentFile := ""
156+ for _, match := range matches {
157+ if currentFile != match.path {
158+ if currentFile != "" {
159+ output += "\n"
160+ }
161+ currentFile = match.path
162+ output += fmt.Sprintf("%s:\n", match.path)
163+ }
164+ if match.lineNum > 0 {
165+ output += fmt.Sprintf(" Line %d: %s\n", match.lineNum, match.lineText)
166+ } else {
167+ output += fmt.Sprintf(" %s\n", match.path)
168+ }
169+ }
124170
125171 if truncated {
126− output += "\n\n(Results are truncated. Consider using a more specific path or pattern.)"
172+ output += "\n(Results are truncated. Consider using a more specific path or pattern.)"
127173 }
128174 }
129175
130− return NewTextResponse(output), nil
131−}
132−
133−func pluralize(count int) string {
134− if count == 1 {
135− return ""
136− }
137− return "s"
176+ return WithResponseMetadata(
177+ NewTextResponse(output),
178+ GrepResponseMetadata{
179+ NumberOfMatches: len(matches),
180+ Truncated: truncated,
181+ },
182+ ), nil
138183}
139184
140−func searchFiles(pattern, rootPath, include string, limit int) ([]string, bool, error) {
185+func searchFiles(pattern, rootPath, include string, limit int) ([]grepMatch, bool, error) {
141186 matches, err := searchWithRipgrep(pattern, rootPath, include)
142187 if err != nil {
143188 matches, err = searchFilesWithRegex(pattern, rootPath, include)
@@ -155,12 +200,7 @@ func searchFiles(pattern, rootPath, include string, limit int) ([]string, bool,
155200 matches = matches[:limit]
156201 }
157202
158− results := make([]string, len(matches))
159− for i, m := range matches {
160− results[i] = m.path
161− }
162−
163− return results, truncated, nil
203+ return matches, truncated, nil
164204}
165205
166206func searchWithRipgrep(pattern, path, include string) ([]grepMatch, error) {
@@ -169,7 +209,8 @@ func searchWithRipgrep(pattern, path, include string) ([]grepMatch, error) {
169209 return nil, fmt.Errorf("ripgrep not found: %w", err)
170210 }
171211
172− args := []string{"-l", pattern}
212+ // Use -n to show line numbers and include the matched line
213+ args := []string{"-n", pattern}
173214 if include != "" {
174215 args = append(args, "--glob", include)
175216 }
@@ -192,14 +233,29 @@ func searchWithRipgrep(pattern, path, include string) ([]grepMatch, error) {
192233 continue
193234 }
194235
195− fileInfo, err := os.Stat(line)
236+ // Parse ripgrep output format: file:line:content
237+ parts := strings.SplitN(line, ":", 3)
238+ if len(parts) < 3 {
239+ continue
240+ }
241+
242+ filePath := parts[0]
243+ lineNum, err := strconv.Atoi(parts[1])
244+ if err != nil {
245+ continue
246+ }
247+ lineText := parts[2]
248+
249+ fileInfo, err := os.Stat(filePath)
196250 if err != nil {
197251 continue // Skip files we can't access
198252 }
199253
200254 matches = append(matches, grepMatch{
201− path: line,
202− modTime: fileInfo.ModTime(),
255+ path: filePath,
256+ modTime: fileInfo.ModTime(),
257+ lineNum: lineNum,
258+ lineText: lineText,
203259 })
204260 }
205261
@@ -240,15 +296,17 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
240296 return nil
241297 }
242298
243− match, err := fileContainsPattern(path, regex)
299+ match, lineNum, lineText, err := fileContainsPattern(path, regex)
244300 if err != nil {
245301 return nil // Skip files we can't read
246302 }
247303
248304 if match {
249305 matches = append(matches, grepMatch{
250− path: path,
251− modTime: info.ModTime(),
306+ path: path,
307+ modTime: info.ModTime(),
308+ lineNum: lineNum,
309+ lineText: lineText,
252310 })
253311
254312 if len(matches) >= 200 {
@@ -265,21 +323,24 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
265323 return matches, nil
266324}
267325
268−func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, error) {
326+func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, string, error) {
269327 file, err := os.Open(filePath)
270328 if err != nil {
271− return false, err
329+ return false, 0, "", err
272330 }
273331 defer file.Close()
274332
275333 scanner := bufio.NewScanner(file)
334+ lineNum := 0
276335 for scanner.Scan() {
277− if pattern.MatchString(scanner.Text()) {
278− return true, nil
336+ lineNum++
337+ line := scanner.Text()
338+ if pattern.MatchString(line) {
339+ return true, lineNum, line, nil
279340 }
280341 }
281342
282− return false, scanner.Err()
343+ return false, 0, "", scanner.Err()
283344}
284345
285346func globToRegex(glob string) string {
internal/llm/tools/ls.go+14−3
@@ -8,7 +8,7 @@ import (
88 "path/filepath"
99 "strings"
1010
11− "github.com/kujtimiihoxha/termai/internal/config"
11+ "github.com/kujtimiihoxha/opencode/internal/config"
1212)
1313
1414type LSParams struct {
@@ -23,6 +23,11 @@ type TreeNode struct {
2323 Children []*TreeNode `json:"children,omitempty"`
2424}
2525
26+type LSResponseMetadata struct {
27+ NumberOfFiles int `json:"number_of_files"`
28+ Truncated bool `json:"truncated"`
29+}
30+
2631type lsTool struct{}
2732
2833const (
@@ -104,7 +109,7 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
104109
105110 files, truncated, err := listDirectory(searchPath, params.Ignore, MaxLSFiles)
106111 if err != nil {
107− return NewTextErrorResponse(fmt.Sprintf("error listing directory: %s", err)), nil
112+ return ToolResponse{}, fmt.Errorf("error listing directory: %w", err)
108113 }
109114
110115 tree := createFileTree(files)
@@ -114,7 +119,13 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
114119 output = fmt.Sprintf("There are more than %d files in the directory. Use a more specific path or use the Glob tool to find specific files. The first %d files and directories are included below:\n\n%s", MaxLSFiles, MaxLSFiles, output)
115120 }
116121
117− return NewTextResponse(output), nil
122+ return WithResponseMetadata(
123+ NewTextResponse(output),
124+ LSResponseMetadata{
125+ NumberOfFiles: len(files),
126+ Truncated: truncated,
127+ },
128+ ), nil
118129}
119130
120131func listDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) {
internal/llm/tools/patch.gonew file+372
@@ -0,0 +1,372 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "time"
10+
11+ "github.com/kujtimiihoxha/opencode/internal/config"
12+ "github.com/kujtimiihoxha/opencode/internal/diff"
13+ "github.com/kujtimiihoxha/opencode/internal/history"
14+ "github.com/kujtimiihoxha/opencode/internal/logging"
15+ "github.com/kujtimiihoxha/opencode/internal/lsp"
16+ "github.com/kujtimiihoxha/opencode/internal/permission"
17+)
18+
19+type PatchParams struct {
20+ PatchText string `json:"patch_text"`
21+}
22+
23+type PatchResponseMetadata struct {
24+ FilesChanged []string `json:"files_changed"`
25+ Additions int `json:"additions"`
26+ Removals int `json:"removals"`
27+}
28+
29+type patchTool struct {
30+ lspClients map[string]*lsp.Client
31+ permissions permission.Service
32+ files history.Service
33+}
34+
35+const (
36+ PatchToolName = "patch"
37+ patchDescription = `Applies a patch to multiple files in one operation. This tool is useful for making coordinated changes across multiple files.
38+
39+The patch text must follow this format:
40+*** Begin Patch
41+*** Update File: /path/to/file
42+@@ Context line (unique within the file)
43+ Line to keep
44+-Line to remove
45++Line to add
46+ Line to keep
47+*** Add File: /path/to/new/file
48++Content of the new file
49++More content
50+*** Delete File: /path/to/file/to/delete
51+*** End Patch
52+
53+Before using this tool:
54+1. Use the FileRead tool to understand the files' contents and context
55+2. Verify all file paths are correct (use the LS tool)
56+
57+CRITICAL REQUIREMENTS FOR USING THIS TOOL:
58+
59+1. UNIQUENESS: Context lines MUST uniquely identify the specific sections you want to change
60+2. PRECISION: All whitespace, indentation, and surrounding code must match exactly
61+3. VALIDATION: Ensure edits result in idiomatic, correct code
62+4. PATHS: Always use absolute file paths (starting with /)
63+
64+The tool will apply all changes in a single atomic operation.`
65+)
66+
67+func NewPatchTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service) BaseTool {
68+ return &patchTool{
69+ lspClients: lspClients,
70+ permissions: permissions,
71+ files: files,
72+ }
73+}
74+
75+func (p *patchTool) Info() ToolInfo {
76+ return ToolInfo{
77+ Name: PatchToolName,
78+ Description: patchDescription,
79+ Parameters: map[string]any{
80+ "patch_text": map[string]any{
81+ "type": "string",
82+ "description": "The full patch text that describes all changes to be made",
83+ },
84+ },
85+ Required: []string{"patch_text"},
86+ }
87+}
88+
89+func (p *patchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
90+ var params PatchParams
91+ if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
92+ return NewTextErrorResponse("invalid parameters"), nil
93+ }
94+
95+ if params.PatchText == "" {
96+ return NewTextErrorResponse("patch_text is required"), nil
97+ }
98+
99+ // Identify all files needed for the patch and verify they've been read
100+ filesToRead := diff.IdentifyFilesNeeded(params.PatchText)
101+ for _, filePath := range filesToRead {
102+ absPath := filePath
103+ if !filepath.IsAbs(absPath) {
104+ wd := config.WorkingDirectory()
105+ absPath = filepath.Join(wd, absPath)
106+ }
107+
108+ if getLastReadTime(absPath).IsZero() {
109+ return NewTextErrorResponse(fmt.Sprintf("you must read the file %s before patching it. Use the FileRead tool first", filePath)), nil
110+ }
111+
112+ fileInfo, err := os.Stat(absPath)
113+ if err != nil {
114+ if os.IsNotExist(err) {
115+ return NewTextErrorResponse(fmt.Sprintf("file not found: %s", absPath)), nil
116+ }
117+ return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
118+ }
119+
120+ if fileInfo.IsDir() {
121+ return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", absPath)), nil
122+ }
123+
124+ modTime := fileInfo.ModTime()
125+ lastRead := getLastReadTime(absPath)
126+ if modTime.After(lastRead) {
127+ return NewTextErrorResponse(
128+ fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
129+ absPath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
130+ )), nil
131+ }
132+ }
133+
134+ // Check for new files to ensure they don't already exist
135+ filesToAdd := diff.IdentifyFilesAdded(params.PatchText)
136+ for _, filePath := range filesToAdd {
137+ absPath := filePath
138+ if !filepath.IsAbs(absPath) {
139+ wd := config.WorkingDirectory()
140+ absPath = filepath.Join(wd, absPath)
141+ }
142+
143+ _, err := os.Stat(absPath)
144+ if err == nil {
145+ return NewTextErrorResponse(fmt.Sprintf("file already exists and cannot be added: %s", absPath)), nil
146+ } else if !os.IsNotExist(err) {
147+ return ToolResponse{}, fmt.Errorf("failed to check file: %w", err)
148+ }
149+ }
150+
151+ // Load all required files
152+ currentFiles := make(map[string]string)
153+ for _, filePath := range filesToRead {
154+ absPath := filePath
155+ if !filepath.IsAbs(absPath) {
156+ wd := config.WorkingDirectory()
157+ absPath = filepath.Join(wd, absPath)
158+ }
159+
160+ content, err := os.ReadFile(absPath)
161+ if err != nil {
162+ return ToolResponse{}, fmt.Errorf("failed to read file %s: %w", absPath, err)
163+ }
164+ currentFiles[filePath] = string(content)
165+ }
166+
167+ // Process the patch
168+ patch, fuzz, err := diff.TextToPatch(params.PatchText, currentFiles)
169+ if err != nil {
170+ return NewTextErrorResponse(fmt.Sprintf("failed to parse patch: %s", err)), nil
171+ }
172+
173+ if fuzz > 3 {
174+ return NewTextErrorResponse(fmt.Sprintf("patch contains fuzzy matches (fuzz level: %d). Please make your context lines more precise", fuzz)), nil
175+ }
176+
177+ // Convert patch to commit
178+ commit, err := diff.PatchToCommit(patch, currentFiles)
179+ if err != nil {
180+ return NewTextErrorResponse(fmt.Sprintf("failed to create commit from patch: %s", err)), nil
181+ }
182+
183+ // Get session ID and message ID
184+ sessionID, messageID := GetContextValues(ctx)
185+ if sessionID == "" || messageID == "" {
186+ return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a patch")
187+ }
188+
189+ // Request permission for all changes
190+ for path, change := range commit.Changes {
191+ switch change.Type {
192+ case diff.ActionAdd:
193+ dir := filepath.Dir(path)
194+ patchDiff, _, _ := diff.GenerateDiff("", *change.NewContent, path)
195+ p := p.permissions.Request(
196+ permission.CreatePermissionRequest{
197+ SessionID: sessionID,
198+ Path: dir,
199+ ToolName: PatchToolName,
200+ Action: "create",
201+ Description: fmt.Sprintf("Create file %s", path),
202+ Params: EditPermissionsParams{
203+ FilePath: path,
204+ Diff: patchDiff,
205+ },
206+ },
207+ )
208+ if !p {
209+ return ToolResponse{}, permission.ErrorPermissionDenied
210+ }
211+ case diff.ActionUpdate:
212+ currentContent := ""
213+ if change.OldContent != nil {
214+ currentContent = *change.OldContent
215+ }
216+ newContent := ""
217+ if change.NewContent != nil {
218+ newContent = *change.NewContent
219+ }
220+ patchDiff, _, _ := diff.GenerateDiff(currentContent, newContent, path)
221+ dir := filepath.Dir(path)
222+ p := p.permissions.Request(
223+ permission.CreatePermissionRequest{
224+ SessionID: sessionID,
225+ Path: dir,
226+ ToolName: PatchToolName,
227+ Action: "update",
228+ Description: fmt.Sprintf("Update file %s", path),
229+ Params: EditPermissionsParams{
230+ FilePath: path,
231+ Diff: patchDiff,
232+ },
233+ },
234+ )
235+ if !p {
236+ return ToolResponse{}, permission.ErrorPermissionDenied
237+ }
238+ case diff.ActionDelete:
239+ dir := filepath.Dir(path)
240+ patchDiff, _, _ := diff.GenerateDiff(*change.OldContent, "", path)
241+ p := p.permissions.Request(
242+ permission.CreatePermissionRequest{
243+ SessionID: sessionID,
244+ Path: dir,
245+ ToolName: PatchToolName,
246+ Action: "delete",
247+ Description: fmt.Sprintf("Delete file %s", path),
248+ Params: EditPermissionsParams{
249+ FilePath: path,
250+ Diff: patchDiff,
251+ },
252+ },
253+ )
254+ if !p {
255+ return ToolResponse{}, permission.ErrorPermissionDenied
256+ }
257+ }
258+ }
259+
260+ // Apply the changes to the filesystem
261+ err = diff.ApplyCommit(commit, func(path string, content string) error {
262+ absPath := path
263+ if !filepath.IsAbs(absPath) {
264+ wd := config.WorkingDirectory()
265+ absPath = filepath.Join(wd, absPath)
266+ }
267+
268+ // Create parent directories if needed
269+ dir := filepath.Dir(absPath)
270+ if err := os.MkdirAll(dir, 0o755); err != nil {
271+ return fmt.Errorf("failed to create parent directories for %s: %w", absPath, err)
272+ }
273+
274+ return os.WriteFile(absPath, []byte(content), 0o644)
275+ }, func(path string) error {
276+ absPath := path
277+ if !filepath.IsAbs(absPath) {
278+ wd := config.WorkingDirectory()
279+ absPath = filepath.Join(wd, absPath)
280+ }
281+ return os.Remove(absPath)
282+ })
283+ if err != nil {
284+ return NewTextErrorResponse(fmt.Sprintf("failed to apply patch: %s", err)), nil
285+ }
286+
287+ // Update file history for all modified files
288+ changedFiles := []string{}
289+ totalAdditions := 0
290+ totalRemovals := 0
291+
292+ for path, change := range commit.Changes {
293+ absPath := path
294+ if !filepath.IsAbs(absPath) {
295+ wd := config.WorkingDirectory()
296+ absPath = filepath.Join(wd, absPath)
297+ }
298+ changedFiles = append(changedFiles, absPath)
299+
300+ oldContent := ""
301+ if change.OldContent != nil {
302+ oldContent = *change.OldContent
303+ }
304+
305+ newContent := ""
306+ if change.NewContent != nil {
307+ newContent = *change.NewContent
308+ }
309+
310+ // Calculate diff statistics
311+ _, additions, removals := diff.GenerateDiff(oldContent, newContent, path)
312+ totalAdditions += additions
313+ totalRemovals += removals
314+
315+ // Update history
316+ file, err := p.files.GetByPathAndSession(ctx, absPath, sessionID)
317+ if err != nil && change.Type != diff.ActionAdd {
318+ // If not adding a file, create history entry for existing file
319+ _, err = p.files.Create(ctx, sessionID, absPath, oldContent)
320+ if err != nil {
321+ logging.Debug("Error creating file history", "error", err)
322+ }
323+ }
324+
325+ if err == nil && change.Type != diff.ActionAdd && file.Content != oldContent {
326+ // User manually changed content, store intermediate version
327+ _, err = p.files.CreateVersion(ctx, sessionID, absPath, oldContent)
328+ if err != nil {
329+ logging.Debug("Error creating file history version", "error", err)
330+ }
331+ }
332+
333+ // Store new version
334+ if change.Type == diff.ActionDelete {
335+ _, err = p.files.CreateVersion(ctx, sessionID, absPath, "")
336+ } else {
337+ _, err = p.files.CreateVersion(ctx, sessionID, absPath, newContent)
338+ }
339+ if err != nil {
340+ logging.Debug("Error creating file history version", "error", err)
341+ }
342+
343+ // Record file operations
344+ recordFileWrite(absPath)
345+ recordFileRead(absPath)
346+ }
347+
348+ // Run LSP diagnostics on all changed files
349+ for _, filePath := range changedFiles {
350+ waitForLspDiagnostics(ctx, filePath, p.lspClients)
351+ }
352+
353+ result := fmt.Sprintf("Patch applied successfully. %d files changed, %d additions, %d removals",
354+ len(changedFiles), totalAdditions, totalRemovals)
355+
356+ diagnosticsText := ""
357+ for _, filePath := range changedFiles {
358+ diagnosticsText += getDiagnostics(filePath, p.lspClients)
359+ }
360+
361+ if diagnosticsText != "" {
362+ result += "\n\nDiagnostics:\n" + diagnosticsText
363+ }
364+
365+ return WithResponseMetadata(
366+ NewTextResponse(result),
367+ PatchResponseMetadata{
368+ FilesChanged: changedFiles,
369+ Additions: totalAdditions,
370+ Removals: totalRemovals,
371+ }), nil
372+}
internal/llm/tools/shell/shell.go+15−5
@@ -83,11 +83,21 @@ func newPersistentShell(cwd string) *PersistentShell {
8383 commandQueue: make(chan *commandExecution, 10),
8484 }
8585
86− go shell.processCommands()
86+ go func() {
87+ defer func() {
88+ if r := recover(); r != nil {
89+ fmt.Fprintf(os.Stderr, "Panic in shell command processor: %v\n", r)
90+ shell.isAlive = false
91+ close(shell.commandQueue)
92+ }
93+ }()
94+ shell.processCommands()
95+ }()
8796
8897 go func() {
8998 err := cmd.Wait()
9099 if err != nil {
100+ // Log the error if needed
91101 }
92102 shell.isAlive = false
93103 close(shell.commandQueue)
@@ -116,10 +126,10 @@ func (s *PersistentShell) execCommand(command string, timeout time.Duration, ctx
116126 }
117127
118128 tempDir := os.TempDir()
119− stdoutFile := filepath.Join(tempDir, fmt.Sprintf("termai-stdout-%d", time.Now().UnixNano()))
120− stderrFile := filepath.Join(tempDir, fmt.Sprintf("termai-stderr-%d", time.Now().UnixNano()))
121− statusFile := filepath.Join(tempDir, fmt.Sprintf("termai-status-%d", time.Now().UnixNano()))
122− cwdFile := filepath.Join(tempDir, fmt.Sprintf("termai-cwd-%d", time.Now().UnixNano()))
129+ stdoutFile := filepath.Join(tempDir, fmt.Sprintf("opencode-stdout-%d", time.Now().UnixNano()))
130+ stderrFile := filepath.Join(tempDir, fmt.Sprintf("opencode-stderr-%d", time.Now().UnixNano()))
131+ statusFile := filepath.Join(tempDir, fmt.Sprintf("opencode-status-%d", time.Now().UnixNano()))
132+ cwdFile := filepath.Join(tempDir, fmt.Sprintf("opencode-cwd-%d", time.Now().UnixNano()))
123133
124134 defer func() {
125135 os.Remove(stdoutFile)
internal/llm/tools/sourcegraph.go+11−6
@@ -18,6 +18,11 @@ type SourcegraphParams struct {
1818 Timeout int `json:"timeout,omitempty"`
1919}
2020
21+type SourcegraphResponseMetadata struct {
22+ NumberOfMatches int `json:"number_of_matches"`
23+ Truncated bool `json:"truncated"`
24+}
25+
2126type sourcegraphTool struct {
2227 client *http.Client
2328}
@@ -198,7 +203,7 @@ func (t *sourcegraphTool) Run(ctx context.Context, call ToolCall) (ToolResponse,
198203
199204 graphqlQueryBytes, err := json.Marshal(request)
200205 if err != nil {
201− return NewTextErrorResponse("Failed to create GraphQL request: " + err.Error()), nil
206+ return ToolResponse{}, fmt.Errorf("failed to marshal GraphQL request: %w", err)
202207 }
203208 graphqlQuery := string(graphqlQueryBytes)
204209
@@ -209,15 +214,15 @@ func (t *sourcegraphTool) Run(ctx context.Context, call ToolCall) (ToolResponse,
209214 bytes.NewBuffer([]byte(graphqlQuery)),
210215 )
211216 if err != nil {
212− return NewTextErrorResponse("Failed to create request: " + err.Error()), nil
217+ return ToolResponse{}, fmt.Errorf("failed to create request: %w", err)
213218 }
214219
215220 req.Header.Set("Content-Type", "application/json")
216− req.Header.Set("User-Agent", "termai/1.0")
221+ req.Header.Set("User-Agent", "opencode/1.0")
217222
218223 resp, err := client.Do(req)
219224 if err != nil {
220− return NewTextErrorResponse("Failed to execute request: " + err.Error()), nil
225+ return ToolResponse{}, fmt.Errorf("failed to fetch URL: %w", err)
221226 }
222227 defer resp.Body.Close()
223228
@@ -231,12 +236,12 @@ func (t *sourcegraphTool) Run(ctx context.Context, call ToolCall) (ToolResponse,
231236 }
232237 body, err := io.ReadAll(resp.Body)
233238 if err != nil {
234− return NewTextErrorResponse("Failed to read response body: " + err.Error()), nil
239+ return ToolResponse{}, fmt.Errorf("failed to read response body: %w", err)
235240 }
236241
237242 var result map[string]any
238243 if err = json.Unmarshal(body, &result); err != nil {
239− return NewTextErrorResponse("Failed to parse response: " + err.Error()), nil
244+ return ToolResponse{}, fmt.Errorf("failed to unmarshal response: %w", err)
240245 }
241246
242247 formattedResults, err := formatSourcegraphResults(result, params.ContextWindow)
internal/llm/tools/sourcegraph_test.godeleted−86
@@ -1,86 +0,0 @@
1−package tools
2−
3−import (
4− "context"
5− "encoding/json"
6− "testing"
7−
8− "github.com/stretchr/testify/assert"
9− "github.com/stretchr/testify/require"
10−)
11−
12−func TestSourcegraphTool_Info(t *testing.T) {
13− tool := NewSourcegraphTool()
14− info := tool.Info()
15−
16− assert.Equal(t, SourcegraphToolName, info.Name)
17− assert.NotEmpty(t, info.Description)
18− assert.Contains(t, info.Parameters, "query")
19− assert.Contains(t, info.Parameters, "count")
20− assert.Contains(t, info.Parameters, "timeout")
21− assert.Contains(t, info.Required, "query")
22−}
23−
24−func TestSourcegraphTool_Run(t *testing.T) {
25− t.Run("handles missing query parameter", func(t *testing.T) {
26− tool := NewSourcegraphTool()
27− params := SourcegraphParams{
28− Query: "",
29− }
30−
31− paramsJSON, err := json.Marshal(params)
32− require.NoError(t, err)
33−
34− call := ToolCall{
35− Name: SourcegraphToolName,
36− Input: string(paramsJSON),
37− }
38−
39− response, err := tool.Run(context.Background(), call)
40− require.NoError(t, err)
41− assert.Contains(t, response.Content, "Query parameter is required")
42− })
43−
44− t.Run("handles invalid parameters", func(t *testing.T) {
45− tool := NewSourcegraphTool()
46− call := ToolCall{
47− Name: SourcegraphToolName,
48− Input: "invalid json",
49− }
50−
51− response, err := tool.Run(context.Background(), call)
52− require.NoError(t, err)
53− assert.Contains(t, response.Content, "Failed to parse sourcegraph parameters")
54− })
55−
56− t.Run("normalizes count parameter", func(t *testing.T) {
57− // Test cases for count normalization
58− testCases := []struct {
59− name string
60− inputCount int
61− expectedCount int
62− }{
63− {"negative count", -5, 10}, // Should use default (10)
64− {"zero count", 0, 10}, // Should use default (10)
65− {"valid count", 50, 50}, // Should keep as is
66− {"excessive count", 150, 100}, // Should cap at 100
67− }
68−
69− for _, tc := range testCases {
70− t.Run(tc.name, func(t *testing.T) {
71− // Verify count normalization logic directly
72− assert.NotPanics(t, func() {
73− // Apply the same normalization logic as in the tool
74− normalizedCount := tc.inputCount
75− if normalizedCount <= 0 {
76− normalizedCount = 10
77− } else if normalizedCount > 100 {
78− normalizedCount = 100
79− }
80−
81− assert.Equal(t, tc.expectedCount, normalizedCount)
82− })
83− })
84− }
85− })
86−}
internal/llm/tools/tools.go+39−4
@@ -1,6 +1,9 @@
11package tools
22
3−import "context"
3+import (
4+ "context"
5+ "encoding/json"
6+)
47
58type ToolInfo struct {
69 Name string
@@ -11,15 +14,24 @@ type ToolInfo struct {
1114
1215type toolResponseType string
1316
17+type (
18+ sessionIDContextKey string
19+ messageIDContextKey string
20+)
21+
1422const (
1523 ToolResponseTypeText toolResponseType = "text"
1624 ToolResponseTypeImage toolResponseType = "image"
25+
26+ SessionIDContextKey sessionIDContextKey = "session_id"
27+ MessageIDContextKey messageIDContextKey = "message_id"
1728)
1829
1930type ToolResponse struct {
20− Type toolResponseType `json:"type"`
21− Content string `json:"content"`
22− IsError bool `json:"is_error"`
31+ Type toolResponseType `json:"type"`
32+ Content string `json:"content"`
33+ Metadata string `json:"metadata,omitempty"`
34+ IsError bool `json:"is_error"`
2335}
2436
2537func NewTextResponse(content string) ToolResponse {
@@ -29,6 +41,17 @@ func NewTextResponse(content string) ToolResponse {
2941 }
3042}
3143
44+func WithResponseMetadata(response ToolResponse, metadata any) ToolResponse {
45+ if metadata != nil {
46+ metadataBytes, err := json.Marshal(metadata)
47+ if err != nil {
48+ return response
49+ }
50+ response.Metadata = string(metadataBytes)
51+ }
52+ return response
53+}
54+
3255func NewTextErrorResponse(content string) ToolResponse {
3356 return ToolResponse{
3457 Type: ToolResponseTypeText,
@@ -47,3 +70,15 @@ type BaseTool interface {
4770 Info() ToolInfo
4871 Run(ctx context.Context, params ToolCall) (ToolResponse, error)
4972}
73+
74+func GetContextValues(ctx context.Context) (string, string) {
75+ sessionID := ctx.Value(SessionIDContextKey)
76+ messageID := ctx.Value(MessageIDContextKey)
77+ if sessionID == nil {
78+ return "", ""
79+ }
80+ if messageID == nil {
81+ return sessionID.(string), ""
82+ }
83+ return sessionID.(string), messageID.(string)
84+}
internal/llm/tools/view.go+18−6
@@ -10,8 +10,8 @@ import (
1010 "path/filepath"
1111 "strings"
1212
13− "github.com/kujtimiihoxha/termai/internal/config"
14− "github.com/kujtimiihoxha/termai/internal/lsp"
13+ "github.com/kujtimiihoxha/opencode/internal/config"
14+ "github.com/kujtimiihoxha/opencode/internal/lsp"
1515)
1616
1717type ViewParams struct {
@@ -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
@@ -135,7 +140,7 @@ func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
135140
136141 return NewTextErrorResponse(fmt.Sprintf("File not found: %s", filePath)), nil
137142 }
138− return NewTextErrorResponse(fmt.Sprintf("Failed to access file: %s", err)), nil
143+ return ToolResponse{}, fmt.Errorf("error accessing file: %w", err)
139144 }
140145
141146 // Check if it's a directory
@@ -156,6 +161,7 @@ func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
156161
157162 // Check if it's an image file
158163 isImage, imageType := isImageFile(filePath)
164+ // TODO: handle images
159165 if isImage {
160166 return NewTextErrorResponse(fmt.Sprintf("This is an image file of type: %s\nUse a different tool to process images", imageType)), nil
161167 }
@@ -163,7 +169,7 @@ func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
163169 // Read the file content
164170 content, lineCount, err := readTextFile(filePath, params.Offset, params.Limit)
165171 if err != nil {
166− return NewTextErrorResponse(fmt.Sprintf("Failed to read file: %s", err)), nil
172+ return ToolResponse{}, fmt.Errorf("error reading file: %w", err)
167173 }
168174
169175 notifyLspOpenFile(ctx, filePath, v.lspClients)
@@ -177,9 +183,15 @@ func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
177183 params.Offset+len(strings.Split(content, "\n")))
178184 }
179185 output += "\n</file>\n"
180− output += appendDiagnostics(filePath, v.lspClients)
186+ output += getDiagnostics(filePath, v.lspClients)
181187 recordFileRead(filePath)
182− return NewTextResponse(output), nil
188+ return WithResponseMetadata(
189+ NewTextResponse(output),
190+ ViewResponseMetadata{
191+ FilePath: filePath,
192+ Content: content,
193+ },
194+ ), nil
183195}
184196
185197func addLineNumbers(content string, startLine int) string {
internal/llm/tools/write.go+71−14
@@ -6,11 +6,15 @@ import (
66 "fmt"
77 "os"
88 "path/filepath"
9+ "strings"
910 "time"
1011
11− "github.com/kujtimiihoxha/termai/internal/config"
12− "github.com/kujtimiihoxha/termai/internal/lsp"
13− "github.com/kujtimiihoxha/termai/internal/permission"
12+ "github.com/kujtimiihoxha/opencode/internal/config"
13+ "github.com/kujtimiihoxha/opencode/internal/diff"
14+ "github.com/kujtimiihoxha/opencode/internal/history"
15+ "github.com/kujtimiihoxha/opencode/internal/logging"
16+ "github.com/kujtimiihoxha/opencode/internal/lsp"
17+ "github.com/kujtimiihoxha/opencode/internal/permission"
1418)
1519
1620type WriteParams struct {
@@ -20,12 +24,19 @@ type WriteParams struct {
2024
2125type WritePermissionsParams struct {
2226 FilePath string `json:"file_path"`
23− Content string `json:"content"`
27+ Diff string `json:"diff"`
2428}
2529
2630type writeTool struct {
2731 lspClients map[string]*lsp.Client
2832 permissions permission.Service
33+ files history.Service
34+}
35+
36+type WriteResponseMetadata struct {
37+ Diff string `json:"diff"`
38+ Additions int `json:"additions"`
39+ Removals int `json:"removals"`
2940}
3041
3142const (
@@ -60,10 +71,11 @@ TIPS:
6071- Always include descriptive comments when making changes to existing code`
6172)
6273
63−func NewWriteTool(lspClients map[string]*lsp.Client, permissions permission.Service) BaseTool {
74+func NewWriteTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service) BaseTool {
6475 return &writeTool{
6576 lspClients: lspClients,
6677 permissions: permissions,
78+ files: files,
6779 }
6880}
6981
@@ -122,12 +134,12 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
122134 return NewTextErrorResponse(fmt.Sprintf("File %s already contains the exact content. No changes made.", filePath)), nil
123135 }
124136 } else if !os.IsNotExist(err) {
125− return NewTextErrorResponse(fmt.Sprintf("Failed to access file: %s", err)), nil
137+ return ToolResponse{}, fmt.Errorf("error checking file: %w", err)
126138 }
127139
128140 dir := filepath.Dir(filePath)
129141 if err = os.MkdirAll(dir, 0o755); err != nil {
130− return NewTextErrorResponse(fmt.Sprintf("Failed to create parent directories: %s", err)), nil
142+ return ToolResponse{}, fmt.Errorf("error creating directory: %w", err)
131143 }
132144
133145 oldContent := ""
@@ -138,25 +150,64 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
138150 }
139151 }
140152
153+ sessionID, messageID := GetContextValues(ctx)
154+ if sessionID == "" || messageID == "" {
155+ return ToolResponse{}, fmt.Errorf("session_id and message_id are required")
156+ }
157+
158+ diff, additions, removals := diff.GenerateDiff(
159+ oldContent,
160+ params.Content,
161+ filePath,
162+ )
163+
164+ rootDir := config.WorkingDirectory()
165+ permissionPath := filepath.Dir(filePath)
166+ if strings.HasPrefix(filePath, rootDir) {
167+ permissionPath = rootDir
168+ }
141169 p := w.permissions.Request(
142170 permission.CreatePermissionRequest{
143− Path: filePath,
171+ SessionID: sessionID,
172+ Path: permissionPath,
144173 ToolName: WriteToolName,
145− Action: "create",
174+ Action: "write",
146175 Description: fmt.Sprintf("Create file %s", filePath),
147176 Params: WritePermissionsParams{
148177 FilePath: filePath,
149− Content: GenerateDiff(oldContent, params.Content),
178+ Diff: diff,
150179 },
151180 },
152181 )
153182 if !p {
154− return NewTextErrorResponse(fmt.Sprintf("Permission denied to create file: %s", filePath)), nil
183+ return ToolResponse{}, permission.ErrorPermissionDenied
155184 }
156185
157186 err = os.WriteFile(filePath, []byte(params.Content), 0o644)
158187 if err != nil {
159− return NewTextErrorResponse(fmt.Sprintf("Failed to write file: %s", err)), nil
188+ return ToolResponse{}, fmt.Errorf("error writing file: %w", err)
189+ }
190+
191+ // Check if file exists in history
192+ file, err := w.files.GetByPathAndSession(ctx, filePath, sessionID)
193+ if err != nil {
194+ _, err = w.files.Create(ctx, sessionID, filePath, oldContent)
195+ if err != nil {
196+ // Log error but don't fail the operation
197+ return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
198+ }
199+ }
200+ if file.Content != oldContent {
201+ // User Manually changed the content store an intermediate version
202+ _, err = w.files.CreateVersion(ctx, sessionID, filePath, oldContent)
203+ if err != nil {
204+ logging.Debug("Error creating file history version", "error", err)
205+ }
206+ }
207+ // Store the new version
208+ _, err = w.files.CreateVersion(ctx, sessionID, filePath, params.Content)
209+ if err != nil {
210+ logging.Debug("Error creating file history version", "error", err)
160211 }
161212
162213 recordFileWrite(filePath)
@@ -165,6 +216,12 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
165216
166217 result := fmt.Sprintf("File successfully written: %s", filePath)
167218 result = fmt.Sprintf("<result>\n%s\n</result>", result)
168− result += appendDiagnostics(filePath, w.lspClients)
169− return NewTextResponse(result), nil
219+ result += getDiagnostics(filePath, w.lspClients)
220+ return WithResponseMetadata(NewTextResponse(result),
221+ WriteResponseMetadata{
222+ Diff: diff,
223+ Additions: additions,
224+ Removals: removals,
225+ },
226+ ), nil
170227}
internal/llm/tools/write_test.godeleted−307
@@ -1,307 +0,0 @@
1−package tools
2−
3−import (
4− "context"
5− "encoding/json"
6− "os"
7− "path/filepath"
8− "testing"
9− "time"
10−
11− "github.com/kujtimiihoxha/termai/internal/lsp"
12− "github.com/stretchr/testify/assert"
13− "github.com/stretchr/testify/require"
14−)
15−
16−func TestWriteTool_Info(t *testing.T) {
17− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
18− info := tool.Info()
19−
20− assert.Equal(t, WriteToolName, info.Name)
21− assert.NotEmpty(t, info.Description)
22− assert.Contains(t, info.Parameters, "file_path")
23− assert.Contains(t, info.Parameters, "content")
24− assert.Contains(t, info.Required, "file_path")
25− assert.Contains(t, info.Required, "content")
26−}
27−
28−func TestWriteTool_Run(t *testing.T) {
29− // Create a temporary directory for testing
30− tempDir, err := os.MkdirTemp("", "write_tool_test")
31− require.NoError(t, err)
32− defer os.RemoveAll(tempDir)
33−
34− t.Run("creates a new file successfully", func(t *testing.T) {
35− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
36−
37− filePath := filepath.Join(tempDir, "new_file.txt")
38− content := "This is a test content"
39−
40− params := WriteParams{
41− FilePath: filePath,
42− Content: content,
43− }
44−
45− paramsJSON, err := json.Marshal(params)
46− require.NoError(t, err)
47−
48− call := ToolCall{
49− Name: WriteToolName,
50− Input: string(paramsJSON),
51− }
52−
53− response, err := tool.Run(context.Background(), call)
54− require.NoError(t, err)
55− assert.Contains(t, response.Content, "successfully written")
56−
57− // Verify file was created with correct content
58− fileContent, err := os.ReadFile(filePath)
59− require.NoError(t, err)
60− assert.Equal(t, content, string(fileContent))
61− })
62−
63− t.Run("creates file with nested directories", func(t *testing.T) {
64− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
65−
66− filePath := filepath.Join(tempDir, "nested/dirs/new_file.txt")
67− content := "Content in nested directory"
68−
69− params := WriteParams{
70− FilePath: filePath,
71− Content: content,
72− }
73−
74− paramsJSON, err := json.Marshal(params)
75− require.NoError(t, err)
76−
77− call := ToolCall{
78− Name: WriteToolName,
79− Input: string(paramsJSON),
80− }
81−
82− response, err := tool.Run(context.Background(), call)
83− require.NoError(t, err)
84− assert.Contains(t, response.Content, "successfully written")
85−
86− // Verify file was created with correct content
87− fileContent, err := os.ReadFile(filePath)
88− require.NoError(t, err)
89− assert.Equal(t, content, string(fileContent))
90− })
91−
92− t.Run("updates existing file", func(t *testing.T) {
93− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
94−
95− // Create a file first
96− filePath := filepath.Join(tempDir, "existing_file.txt")
97− initialContent := "Initial content"
98− err := os.WriteFile(filePath, []byte(initialContent), 0o644)
99− require.NoError(t, err)
100−
101− // Record the file read to avoid modification time check failure
102− recordFileRead(filePath)
103−
104− // Update the file
105− updatedContent := "Updated content"
106− params := WriteParams{
107− FilePath: filePath,
108− Content: updatedContent,
109− }
110−
111− paramsJSON, err := json.Marshal(params)
112− require.NoError(t, err)
113−
114− call := ToolCall{
115− Name: WriteToolName,
116− Input: string(paramsJSON),
117− }
118−
119− response, err := tool.Run(context.Background(), call)
120− require.NoError(t, err)
121− assert.Contains(t, response.Content, "successfully written")
122−
123− // Verify file was updated with correct content
124− fileContent, err := os.ReadFile(filePath)
125− require.NoError(t, err)
126− assert.Equal(t, updatedContent, string(fileContent))
127− })
128−
129− t.Run("handles invalid parameters", func(t *testing.T) {
130− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
131−
132− call := ToolCall{
133− Name: WriteToolName,
134− Input: "invalid json",
135− }
136−
137− response, err := tool.Run(context.Background(), call)
138− require.NoError(t, err)
139− assert.Contains(t, response.Content, "error parsing parameters")
140− })
141−
142− t.Run("handles missing file_path", func(t *testing.T) {
143− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
144−
145− params := WriteParams{
146− FilePath: "",
147− Content: "Some content",
148− }
149−
150− paramsJSON, err := json.Marshal(params)
151− require.NoError(t, err)
152−
153− call := ToolCall{
154− Name: WriteToolName,
155− Input: string(paramsJSON),
156− }
157−
158− response, err := tool.Run(context.Background(), call)
159− require.NoError(t, err)
160− assert.Contains(t, response.Content, "file_path is required")
161− })
162−
163− t.Run("handles missing content", func(t *testing.T) {
164− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
165−
166− params := WriteParams{
167− FilePath: filepath.Join(tempDir, "file.txt"),
168− Content: "",
169− }
170−
171− paramsJSON, err := json.Marshal(params)
172− require.NoError(t, err)
173−
174− call := ToolCall{
175− Name: WriteToolName,
176− Input: string(paramsJSON),
177− }
178−
179− response, err := tool.Run(context.Background(), call)
180− require.NoError(t, err)
181− assert.Contains(t, response.Content, "content is required")
182− })
183−
184− t.Run("handles writing to a directory path", func(t *testing.T) {
185− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
186−
187− // Create a directory
188− dirPath := filepath.Join(tempDir, "test_dir")
189− err := os.Mkdir(dirPath, 0o755)
190− require.NoError(t, err)
191−
192− params := WriteParams{
193− FilePath: dirPath,
194− Content: "Some content",
195− }
196−
197− paramsJSON, err := json.Marshal(params)
198− require.NoError(t, err)
199−
200− call := ToolCall{
201− Name: WriteToolName,
202− Input: string(paramsJSON),
203− }
204−
205− response, err := tool.Run(context.Background(), call)
206− require.NoError(t, err)
207− assert.Contains(t, response.Content, "Path is a directory")
208− })
209−
210− t.Run("handles permission denied", func(t *testing.T) {
211− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(false))
212−
213− filePath := filepath.Join(tempDir, "permission_denied.txt")
214− params := WriteParams{
215− FilePath: filePath,
216− Content: "Content that should not be written",
217− }
218−
219− paramsJSON, err := json.Marshal(params)
220− require.NoError(t, err)
221−
222− call := ToolCall{
223− Name: WriteToolName,
224− Input: string(paramsJSON),
225− }
226−
227− response, err := tool.Run(context.Background(), call)
228− require.NoError(t, err)
229− assert.Contains(t, response.Content, "Permission denied")
230−
231− // Verify file was not created
232− _, err = os.Stat(filePath)
233− assert.True(t, os.IsNotExist(err))
234− })
235−
236− t.Run("detects file modified since last read", func(t *testing.T) {
237− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
238−
239− // Create a file
240− filePath := filepath.Join(tempDir, "modified_file.txt")
241− initialContent := "Initial content"
242− err := os.WriteFile(filePath, []byte(initialContent), 0o644)
243− require.NoError(t, err)
244−
245− // Record an old read time
246− fileRecordMutex.Lock()
247− fileRecords[filePath] = fileRecord{
248− path: filePath,
249− readTime: time.Now().Add(-1 * time.Hour),
250− }
251− fileRecordMutex.Unlock()
252−
253− // Try to update the file
254− params := WriteParams{
255− FilePath: filePath,
256− Content: "Updated content",
257− }
258−
259− paramsJSON, err := json.Marshal(params)
260− require.NoError(t, err)
261−
262− call := ToolCall{
263− Name: WriteToolName,
264− Input: string(paramsJSON),
265− }
266−
267− response, err := tool.Run(context.Background(), call)
268− require.NoError(t, err)
269− assert.Contains(t, response.Content, "has been modified since it was last read")
270−
271− // Verify file was not modified
272− fileContent, err := os.ReadFile(filePath)
273− require.NoError(t, err)
274− assert.Equal(t, initialContent, string(fileContent))
275− })
276−
277− t.Run("skips writing when content is identical", func(t *testing.T) {
278− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
279−
280− // Create a file
281− filePath := filepath.Join(tempDir, "identical_content.txt")
282− content := "Content that won't change"
283− err := os.WriteFile(filePath, []byte(content), 0o644)
284− require.NoError(t, err)
285−
286− // Record a read time
287− recordFileRead(filePath)
288−
289− // Try to write the same content
290− params := WriteParams{
291− FilePath: filePath,
292− Content: content,
293− }
294−
295− paramsJSON, err := json.Marshal(params)
296− require.NoError(t, err)
297−
298− call := ToolCall{
299− Name: WriteToolName,
300− Input: string(paramsJSON),
301− }
302−
303− response, err := tool.Run(context.Background(), call)
304− require.NoError(t, err)
305− assert.Contains(t, response.Content, "already contains the exact content")
306− })
307−}
internal/logging/logger.go+40−1
@@ -1,6 +1,12 @@
11package logging
22
3−import "log/slog"
3+import (
4+ "fmt"
5+ "log/slog"
6+ "os"
7+ "runtime/debug"
8+ "time"
9+)
410
511func Info(msg string, args ...any) {
612 slog.Info(msg, args...)
@@ -37,3 +43,36 @@ func ErrorPersist(msg string, args ...any) {
3743 args = append(args, persistKeyArg, true)
3844 slog.Error(msg, args...)
3945}
46+
47+// RecoverPanic is a common function to handle panics gracefully.
48+// It logs the error, creates a panic log file with stack trace,
49+// and executes an optional cleanup function before returning.
50+func RecoverPanic(name string, cleanup func()) {
51+ if r := recover(); r != nil {
52+ // Log the panic
53+ ErrorPersist(fmt.Sprintf("Panic in %s: %v", name, r))
54+
55+ // Create a timestamped panic log file
56+ timestamp := time.Now().Format("20060102-150405")
57+ filename := fmt.Sprintf("opencode-panic-%s-%s.log", name, timestamp)
58+
59+ file, err := os.Create(filename)
60+ if err != nil {
61+ ErrorPersist(fmt.Sprintf("Failed to create panic log: %v", err))
62+ } else {
63+ defer file.Close()
64+
65+ // Write panic information and stack trace
66+ fmt.Fprintf(file, "Panic in %s: %v\n\n", name, r)
67+ fmt.Fprintf(file, "Time: %s\n\n", time.Now().Format(time.RFC3339))
68+ fmt.Fprintf(file, "Stack Trace:\n%s\n", debug.Stack())
69+
70+ InfoPersist(fmt.Sprintf("Panic details written to %s", filename))
71+ }
72+
73+ // Execute cleanup function if provided
74+ if cleanup != nil {
75+ cleanup()
76+ }
77+ }
78+}
internal/logging/writer.go+1−1
@@ -9,7 +9,7 @@ import (
99 "time"
1010
1111 "github.com/go-logfmt/logfmt"
12− "github.com/kujtimiihoxha/termai/internal/pubsub"
12+ "github.com/kujtimiihoxha/opencode/internal/pubsub"
1313)
1414
1515const (
internal/lsp/client.go+363−20
@@ -8,14 +8,15 @@ import (
88 "io"
99 "os"
1010 "os/exec"
11+ "path/filepath"
1112 "strings"
1213 "sync"
1314 "sync/atomic"
1415 "time"
1516
16− "github.com/kujtimiihoxha/termai/internal/config"
17− "github.com/kujtimiihoxha/termai/internal/logging"
18− "github.com/kujtimiihoxha/termai/internal/lsp/protocol"
17+ "github.com/kujtimiihoxha/opencode/internal/config"
18+ "github.com/kujtimiihoxha/opencode/internal/logging"
19+ "github.com/kujtimiihoxha/opencode/internal/lsp/protocol"
1920)
2021
2122type Client struct {
@@ -46,6 +47,9 @@ type Client struct {
4647 // Files are currently opened by the LSP
4748 openFiles map[string]*OpenFileInfo
4849 openFilesMu sync.RWMutex
50+
51+ // Server state
52+ serverState atomic.Value
4953}
5054
5155func NewClient(ctx context.Context, command string, args ...string) (*Client, error) {
@@ -80,6 +84,9 @@ func NewClient(ctx context.Context, command string, args ...string) (*Client, er
8084 openFiles: make(map[string]*OpenFileInfo),
8185 }
8286
87+ // Initialize server state
88+ client.serverState.Store(StateStarting)
89+
8390 // Start the LSP server process
8491 if err := cmd.Start(); err != nil {
8592 return nil, fmt.Errorf("failed to start LSP server: %w", err)
@@ -97,7 +104,12 @@ func NewClient(ctx context.Context, command string, args ...string) (*Client, er
97104 }()
98105
99106 // Start message handling loop
100− go client.handleMessages()
107+ go func() {
108+ defer logging.RecoverPanic("LSP-message-handler", func() {
109+ logging.ErrorPersist("LSP message handler crashed, LSP functionality may be impaired")
110+ })
111+ client.handleMessages()
112+ }()
101113
102114 return client, nil
103115}
@@ -215,16 +227,6 @@ func (c *Client) InitializeLSPClient(ctx context.Context, workspaceDir string) (
215227 return nil, fmt.Errorf("initialization failed: %w", err)
216228 }
217229
218− // LSP sepecific Initialization
219− path := strings.ToLower(c.Cmd.Path)
220− switch {
221− case strings.Contains(path, "typescript-language-server"):
222− // err := initializeTypescriptLanguageServer(ctx, c, workspaceDir)
223− // if err != nil {
224− // return nil, err
225− // }
226− }
227−
228230 return &result, nil
229231}
230232
@@ -268,10 +270,314 @@ const (
268270 StateError
269271)
270272
273+// GetServerState returns the current state of the LSP server
274+func (c *Client) GetServerState() ServerState {
275+ if val := c.serverState.Load(); val != nil {
276+ return val.(ServerState)
277+ }
278+ return StateStarting
279+}
280+
281+// SetServerState sets the current state of the LSP server
282+func (c *Client) SetServerState(state ServerState) {
283+ c.serverState.Store(state)
284+}
285+
286+// WaitForServerReady waits for the server to be ready by polling the server
287+// with a simple request until it responds successfully or times out
271288func (c *Client) WaitForServerReady(ctx context.Context) error {
272− // TODO: wait for specific messages or poll workspace/symbol
273− time.Sleep(time.Second * 1)
274− return nil
289+ cnf := config.Get()
290+
291+ // Set initial state
292+ c.SetServerState(StateStarting)
293+
294+ // Create a context with timeout
295+ ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
296+ defer cancel()
297+
298+ // Try to ping the server with a simple request
299+ ticker := time.NewTicker(500 * time.Millisecond)
300+ defer ticker.Stop()
301+
302+ if cnf.DebugLSP {
303+ logging.Debug("Waiting for LSP server to be ready...")
304+ }
305+
306+ // Determine server type for specialized initialization
307+ serverType := c.detectServerType()
308+
309+ // For TypeScript-like servers, we need to open some key files first
310+ if serverType == ServerTypeTypeScript {
311+ if cnf.DebugLSP {
312+ logging.Debug("TypeScript-like server detected, opening key configuration files")
313+ }
314+ c.openKeyConfigFiles(ctx)
315+ }
316+
317+ for {
318+ select {
319+ case <-ctx.Done():
320+ c.SetServerState(StateError)
321+ return fmt.Errorf("timeout waiting for LSP server to be ready")
322+ case <-ticker.C:
323+ // Try a ping method appropriate for this server type
324+ err := c.pingServerByType(ctx, serverType)
325+ if err == nil {
326+ // Server responded successfully
327+ c.SetServerState(StateReady)
328+ if cnf.DebugLSP {
329+ logging.Debug("LSP server is ready")
330+ }
331+ return nil
332+ } else {
333+ logging.Debug("LSP server not ready yet", "error", err, "serverType", serverType)
334+ }
335+
336+ if cnf.DebugLSP {
337+ logging.Debug("LSP server not ready yet", "error", err, "serverType", serverType)
338+ }
339+ }
340+ }
341+}
342+
343+// ServerType represents the type of LSP server
344+type ServerType int
345+
346+const (
347+ ServerTypeUnknown ServerType = iota
348+ ServerTypeGo
349+ ServerTypeTypeScript
350+ ServerTypeRust
351+ ServerTypePython
352+ ServerTypeGeneric
353+)
354+
355+// detectServerType tries to determine what type of LSP server we're dealing with
356+func (c *Client) detectServerType() ServerType {
357+ if c.Cmd == nil {
358+ return ServerTypeUnknown
359+ }
360+
361+ cmdPath := strings.ToLower(c.Cmd.Path)
362+
363+ switch {
364+ case strings.Contains(cmdPath, "gopls"):
365+ return ServerTypeGo
366+ case strings.Contains(cmdPath, "typescript") || strings.Contains(cmdPath, "vtsls") || strings.Contains(cmdPath, "tsserver"):
367+ return ServerTypeTypeScript
368+ case strings.Contains(cmdPath, "rust-analyzer"):
369+ return ServerTypeRust
370+ case strings.Contains(cmdPath, "pyright") || strings.Contains(cmdPath, "pylsp") || strings.Contains(cmdPath, "python"):
371+ return ServerTypePython
372+ default:
373+ return ServerTypeGeneric
374+ }
375+}
376+
377+// openKeyConfigFiles opens important configuration files that help initialize the server
378+func (c *Client) openKeyConfigFiles(ctx context.Context) {
379+ workDir := config.WorkingDirectory()
380+ serverType := c.detectServerType()
381+
382+ var filesToOpen []string
383+
384+ switch serverType {
385+ case ServerTypeTypeScript:
386+ // TypeScript servers need these config files to properly initialize
387+ filesToOpen = []string{
388+ filepath.Join(workDir, "tsconfig.json"),
389+ filepath.Join(workDir, "package.json"),
390+ filepath.Join(workDir, "jsconfig.json"),
391+ }
392+
393+ // Also find and open a few TypeScript files to help the server initialize
394+ c.openTypeScriptFiles(ctx, workDir)
395+ case ServerTypeGo:
396+ filesToOpen = []string{
397+ filepath.Join(workDir, "go.mod"),
398+ filepath.Join(workDir, "go.sum"),
399+ }
400+ case ServerTypeRust:
401+ filesToOpen = []string{
402+ filepath.Join(workDir, "Cargo.toml"),
403+ filepath.Join(workDir, "Cargo.lock"),
404+ }
405+ }
406+
407+ // Try to open each file, ignoring errors if they don't exist
408+ for _, file := range filesToOpen {
409+ if _, err := os.Stat(file); err == nil {
410+ // File exists, try to open it
411+ if err := c.OpenFile(ctx, file); err != nil {
412+ logging.Debug("Failed to open key config file", "file", file, "error", err)
413+ } else {
414+ logging.Debug("Opened key config file for initialization", "file", file)
415+ }
416+ }
417+ }
418+}
419+
420+// pingServerByType sends a ping request appropriate for the server type
421+func (c *Client) pingServerByType(ctx context.Context, serverType ServerType) error {
422+ switch serverType {
423+ case ServerTypeTypeScript:
424+ // For TypeScript, try a document symbol request on an open file
425+ return c.pingTypeScriptServer(ctx)
426+ case ServerTypeGo:
427+ // For Go, workspace/symbol works well
428+ return c.pingWithWorkspaceSymbol(ctx)
429+ case ServerTypeRust:
430+ // For Rust, workspace/symbol works well
431+ return c.pingWithWorkspaceSymbol(ctx)
432+ default:
433+ // Default ping method
434+ return c.pingWithWorkspaceSymbol(ctx)
435+ }
436+}
437+
438+// pingTypeScriptServer tries to ping a TypeScript server with appropriate methods
439+func (c *Client) pingTypeScriptServer(ctx context.Context) error {
440+ // First try workspace/symbol which works for many servers
441+ if err := c.pingWithWorkspaceSymbol(ctx); err == nil {
442+ return nil
443+ }
444+
445+ // If that fails, try to find an open file and request document symbols
446+ c.openFilesMu.RLock()
447+ defer c.openFilesMu.RUnlock()
448+
449+ // If we have any open files, try to get document symbols for one
450+ for uri := range c.openFiles {
451+ filePath := strings.TrimPrefix(uri, "file://")
452+ if strings.HasSuffix(filePath, ".ts") || strings.HasSuffix(filePath, ".js") ||
453+ strings.HasSuffix(filePath, ".tsx") || strings.HasSuffix(filePath, ".jsx") {
454+ var symbols []protocol.DocumentSymbol
455+ err := c.Call(ctx, "textDocument/documentSymbol", protocol.DocumentSymbolParams{
456+ TextDocument: protocol.TextDocumentIdentifier{
457+ URI: protocol.DocumentUri(uri),
458+ },
459+ }, &symbols)
460+ if err == nil {
461+ return nil
462+ }
463+ }
464+ }
465+
466+ // If we have no open TypeScript files, try to find and open one
467+ workDir := config.WorkingDirectory()
468+ err := filepath.WalkDir(workDir, func(path string, d os.DirEntry, err error) error {
469+ if err != nil {
470+ return err
471+ }
472+
473+ // Skip directories and non-TypeScript files
474+ if d.IsDir() {
475+ return nil
476+ }
477+
478+ ext := filepath.Ext(path)
479+ if ext == ".ts" || ext == ".js" || ext == ".tsx" || ext == ".jsx" {
480+ // Found a TypeScript file, try to open it
481+ if err := c.OpenFile(ctx, path); err == nil {
482+ // Successfully opened, stop walking
483+ return filepath.SkipAll
484+ }
485+ }
486+
487+ return nil
488+ })
489+ if err != nil {
490+ logging.Debug("Error walking directory for TypeScript files", "error", err)
491+ }
492+
493+ // Final fallback - just try a generic capability
494+ return c.pingWithServerCapabilities(ctx)
495+}
496+
497+// openTypeScriptFiles finds and opens TypeScript files to help initialize the server
498+func (c *Client) openTypeScriptFiles(ctx context.Context, workDir string) {
499+ cnf := config.Get()
500+ filesOpened := 0
501+ maxFilesToOpen := 5 // Limit to a reasonable number of files
502+
503+ // Find and open TypeScript files
504+ err := filepath.WalkDir(workDir, func(path string, d os.DirEntry, err error) error {
505+ if err != nil {
506+ return err
507+ }
508+
509+ // Skip directories and non-TypeScript files
510+ if d.IsDir() {
511+ // Skip common directories to avoid wasting time
512+ if shouldSkipDir(path) {
513+ return filepath.SkipDir
514+ }
515+ return nil
516+ }
517+
518+ // Check if we've opened enough files
519+ if filesOpened >= maxFilesToOpen {
520+ return filepath.SkipAll
521+ }
522+
523+ // Check file extension
524+ ext := filepath.Ext(path)
525+ if ext == ".ts" || ext == ".tsx" || ext == ".js" || ext == ".jsx" {
526+ // Try to open the file
527+ if err := c.OpenFile(ctx, path); err == nil {
528+ filesOpened++
529+ if cnf.DebugLSP {
530+ logging.Debug("Opened TypeScript file for initialization", "file", path)
531+ }
532+ }
533+ }
534+
535+ return nil
536+ })
537+
538+ if err != nil && cnf.DebugLSP {
539+ logging.Debug("Error walking directory for TypeScript files", "error", err)
540+ }
541+
542+ if cnf.DebugLSP {
543+ logging.Debug("Opened TypeScript files for initialization", "count", filesOpened)
544+ }
545+}
546+
547+// shouldSkipDir returns true if the directory should be skipped during file search
548+func shouldSkipDir(path string) bool {
549+ dirName := filepath.Base(path)
550+
551+ // Skip hidden directories
552+ if strings.HasPrefix(dirName, ".") {
553+ return true
554+ }
555+
556+ // Skip common directories that won't contain relevant source files
557+ skipDirs := map[string]bool{
558+ "node_modules": true,
559+ "dist": true,
560+ "build": true,
561+ "coverage": true,
562+ "vendor": true,
563+ "target": true,
564+ }
565+
566+ return skipDirs[dirName]
567+}
568+
569+// pingWithWorkspaceSymbol tries a workspace/symbol request
570+func (c *Client) pingWithWorkspaceSymbol(ctx context.Context) error {
571+ var result []protocol.SymbolInformation
572+ return c.Call(ctx, "workspace/symbol", protocol.WorkspaceSymbolParams{
573+ Query: "",
574+ }, &result)
575+}
576+
577+// pingWithServerCapabilities tries to get server capabilities
578+func (c *Client) pingWithServerCapabilities(ctx context.Context) error {
579+ // This is a very lightweight request that should work for most servers
580+ return c.Notify(ctx, "$/cancelRequest", struct{ ID int }{ID: -1})
275581}
276582
277583type OpenFileInfo struct {
@@ -374,7 +680,7 @@ func (c *Client) CloseFile(ctx context.Context, filepath string) error {
374680 },
375681 }
376682
377− if cnf.Debug {
683+ if cnf.DebugLSP {
378684 logging.Debug("Closing file", "file", filepath)
379685 }
380686 if err := c.Notify(ctx, "textDocument/didClose", params); err != nil {
@@ -413,12 +719,12 @@ func (c *Client) CloseAllFiles(ctx context.Context) {
413719 // Then close them all
414720 for _, filePath := range filesToClose {
415721 err := c.CloseFile(ctx, filePath)
416− if err != nil && cnf.Debug {
722+ if err != nil && cnf.DebugLSP {
417723 logging.Warn("Error closing file", "file", filePath, "error", err)
418724 }
419725 }
420726
421− if cnf.Debug {
727+ if cnf.DebugLSP {
422728 logging.Debug("Closed all files", "files", filesToClose)
423729 }
424730}
@@ -430,6 +736,43 @@ func (c *Client) GetFileDiagnostics(uri protocol.DocumentUri) []protocol.Diagnos
430736 return c.diagnostics[uri]
431737}
432738
739+// GetDiagnostics returns all diagnostics for all files
433740func (c *Client) GetDiagnostics() map[protocol.DocumentUri][]protocol.Diagnostic {
434741 return c.diagnostics
435742}
743+
744+// OpenFileOnDemand opens a file only if it's not already open
745+// This is used for lazy-loading files when they're actually needed
746+func (c *Client) OpenFileOnDemand(ctx context.Context, filepath string) error {
747+ // Check if the file is already open
748+ if c.IsFileOpen(filepath) {
749+ return nil
750+ }
751+
752+ // Open the file
753+ return c.OpenFile(ctx, filepath)
754+}
755+
756+// GetDiagnosticsForFile ensures a file is open and returns its diagnostics
757+// This is useful for on-demand diagnostics when using lazy loading
758+func (c *Client) GetDiagnosticsForFile(ctx context.Context, filepath string) ([]protocol.Diagnostic, error) {
759+ uri := fmt.Sprintf("file://%s", filepath)
760+ documentUri := protocol.DocumentUri(uri)
761+
762+ // Make sure the file is open
763+ if !c.IsFileOpen(filepath) {
764+ if err := c.OpenFile(ctx, filepath); err != nil {
765+ return nil, fmt.Errorf("failed to open file for diagnostics: %w", err)
766+ }
767+
768+ // Give the LSP server a moment to process the file
769+ time.Sleep(100 * time.Millisecond)
770+ }
771+
772+ // Get diagnostics
773+ c.diagnosticsMu.RLock()
774+ diagnostics := c.diagnostics[documentUri]
775+ c.diagnosticsMu.RUnlock()
776+
777+ return diagnostics, nil
778+}
internal/lsp/handlers.go+5−5
@@ -3,10 +3,10 @@ package lsp
33import (
44 "encoding/json"
55
6− "github.com/kujtimiihoxha/termai/internal/config"
7− "github.com/kujtimiihoxha/termai/internal/logging"
8− "github.com/kujtimiihoxha/termai/internal/lsp/protocol"
9− "github.com/kujtimiihoxha/termai/internal/lsp/util"
6+ "github.com/kujtimiihoxha/opencode/internal/config"
7+ "github.com/kujtimiihoxha/opencode/internal/logging"
8+ "github.com/kujtimiihoxha/opencode/internal/lsp/protocol"
9+ "github.com/kujtimiihoxha/opencode/internal/lsp/util"
1010)
1111
1212// Requests
@@ -88,7 +88,7 @@ func HandleServerMessage(params json.RawMessage) {
8888 Message string `json:"message"`
8989 }
9090 if err := json.Unmarshal(params, &msg); err == nil {
91− if cnf.Debug {
91+ if cnf.DebugLSP {
9292 logging.Debug("Server message", "type", msg.Type, "message", msg.Message)
9393 }
9494 }
internal/lsp/language.go+1−1
@@ -4,7 +4,7 @@ import (
44 "path/filepath"
55 "strings"
66
7− "github.com/kujtimiihoxha/termai/internal/lsp/protocol"
7+ "github.com/kujtimiihoxha/opencode/internal/lsp/protocol"
88)
99
1010func DetectLanguageID(uri string) protocol.LanguageKind {
internal/lsp/methods.go+1−1
@@ -4,7 +4,7 @@ package lsp
44import (
55 "context"
66
7− "github.com/kujtimiihoxha/termai/internal/lsp/protocol"
7+ "github.com/kujtimiihoxha/opencode/internal/lsp/protocol"
88)
99
1010// Implementation sends a textDocument/implementation request to the LSP server.
internal/lsp/transport.go+16−16
@@ -8,8 +8,8 @@ import (
88 "io"
99 "strings"
1010
11− "github.com/kujtimiihoxha/termai/internal/config"
12− "github.com/kujtimiihoxha/termai/internal/logging"
11+ "github.com/kujtimiihoxha/opencode/internal/config"
12+ "github.com/kujtimiihoxha/opencode/internal/logging"
1313)
1414
1515// Write writes an LSP message to the given writer
@@ -20,7 +20,7 @@ func WriteMessage(w io.Writer, msg *Message) error {
2020 }
2121 cnf := config.Get()
2222
23− if cnf.Debug {
23+ if cnf.DebugLSP {
2424 logging.Debug("Sending message to server", "method", msg.Method, "id", msg.ID)
2525 }
2626
@@ -49,7 +49,7 @@ func ReadMessage(r *bufio.Reader) (*Message, error) {
4949 }
5050 line = strings.TrimSpace(line)
5151
52− if cnf.Debug {
52+ if cnf.DebugLSP {
5353 logging.Debug("Received header", "line", line)
5454 }
5555
@@ -65,7 +65,7 @@ func ReadMessage(r *bufio.Reader) (*Message, error) {
6565 }
6666 }
6767
68− if cnf.Debug {
68+ if cnf.DebugLSP {
6969 logging.Debug("Content-Length", "length", contentLength)
7070 }
7171
@@ -76,7 +76,7 @@ func ReadMessage(r *bufio.Reader) (*Message, error) {
7676 return nil, fmt.Errorf("failed to read content: %w", err)
7777 }
7878
79− if cnf.Debug {
79+ if cnf.DebugLSP {
8080 logging.Debug("Received content", "content", string(content))
8181 }
8282
@@ -95,7 +95,7 @@ func (c *Client) handleMessages() {
9595 for {
9696 msg, err := ReadMessage(c.stdout)
9797 if err != nil {
98− if cnf.Debug {
98+ if cnf.DebugLSP {
9999 logging.Error("Error reading message", "error", err)
100100 }
101101 return
@@ -103,7 +103,7 @@ func (c *Client) handleMessages() {
103103
104104 // Handle server->client request (has both Method and ID)
105105 if msg.Method != "" && msg.ID != 0 {
106− if cnf.Debug {
106+ if cnf.DebugLSP {
107107 logging.Debug("Received request from server", "method", msg.Method, "id", msg.ID)
108108 }
109109
@@ -157,11 +157,11 @@ func (c *Client) handleMessages() {
157157 c.notificationMu.RUnlock()
158158
159159 if ok {
160− if cnf.Debug {
160+ if cnf.DebugLSP {
161161 logging.Debug("Handling notification", "method", msg.Method)
162162 }
163163 go handler(msg.Params)
164− } else if cnf.Debug {
164+ } else if cnf.DebugLSP {
165165 logging.Debug("No handler for notification", "method", msg.Method)
166166 }
167167 continue
@@ -174,12 +174,12 @@ func (c *Client) handleMessages() {
174174 c.handlersMu.RUnlock()
175175
176176 if ok {
177− if cnf.Debug {
177+ if cnf.DebugLSP {
178178 logging.Debug("Received response for request", "id", msg.ID)
179179 }
180180 ch <- msg
181181 close(ch)
182− } else if cnf.Debug {
182+ } else if cnf.DebugLSP {
183183 logging.Debug("No handler for response", "id", msg.ID)
184184 }
185185 }
@@ -191,7 +191,7 @@ func (c *Client) Call(ctx context.Context, method string, params any, result any
191191 cnf := config.Get()
192192 id := c.nextID.Add(1)
193193
194− if cnf.Debug {
194+ if cnf.DebugLSP {
195195 logging.Debug("Making call", "method", method, "id", id)
196196 }
197197
@@ -217,14 +217,14 @@ func (c *Client) Call(ctx context.Context, method string, params any, result any
217217 return fmt.Errorf("failed to send request: %w", err)
218218 }
219219
220− if cnf.Debug {
220+ if cnf.DebugLSP {
221221 logging.Debug("Request sent", "method", method, "id", id)
222222 }
223223
224224 // Wait for response
225225 resp := <-ch
226226
227− if cnf.Debug {
227+ if cnf.DebugLSP {
228228 logging.Debug("Received response", "id", id)
229229 }
230230
@@ -250,7 +250,7 @@ func (c *Client) Call(ctx context.Context, method string, params any, result any
250250// Notify sends a notification (a request without an ID that doesn't expect a response)
251251func (c *Client) Notify(ctx context.Context, method string, params any) error {
252252 cnf := config.Get()
253− if cnf.Debug {
253+ if cnf.DebugLSP {
254254 logging.Debug("Sending notification", "method", method)
255255 }
256256
internal/lsp/util/edit.go+1−1
@@ -7,7 +7,7 @@ import (
77 "sort"
88 "strings"
99
10− "github.com/kujtimiihoxha/termai/internal/lsp/protocol"
10+ "github.com/kujtimiihoxha/opencode/internal/lsp/protocol"
1111)
1212
1313func applyTextEdits(uri protocol.DocumentUri, edits []protocol.TextEdit) error {
internal/lsp/watcher/watcher.go+386−60
@@ -9,11 +9,12 @@ import (
99 "sync"
1010 "time"
1111
12+ "github.com/bmatcuk/doublestar/v4"
1213 "github.com/fsnotify/fsnotify"
13− "github.com/kujtimiihoxha/termai/internal/config"
14− "github.com/kujtimiihoxha/termai/internal/logging"
15− "github.com/kujtimiihoxha/termai/internal/lsp"
16− "github.com/kujtimiihoxha/termai/internal/lsp/protocol"
14+ "github.com/kujtimiihoxha/opencode/internal/config"
15+ "github.com/kujtimiihoxha/opencode/internal/logging"
16+ "github.com/kujtimiihoxha/opencode/internal/lsp"
17+ "github.com/kujtimiihoxha/opencode/internal/lsp/protocol"
1718)
1819
1920// WorkspaceWatcher manages LSP file watching
@@ -43,6 +44,8 @@ func NewWorkspaceWatcher(client *lsp.Client) *WorkspaceWatcher {
4344// AddRegistrations adds file watchers to track
4445func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watchers []protocol.FileSystemWatcher) {
4546 cnf := config.Get()
47+
48+ logging.Debug("Adding file watcher registrations")
4649 w.registrationMu.Lock()
4750 defer w.registrationMu.Unlock()
4851
@@ -50,12 +53,11 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
5053 w.registrations = append(w.registrations, watchers...)
5154
5255 // Print detailed registration information for debugging
53− if cnf.Debug {
56+ if cnf.DebugLSP {
5457 logging.Debug("Adding file watcher registrations",
5558 "id", id,
5659 "watchers", len(watchers),
5760 "total", len(w.registrations),
58− "watchers", watchers,
5961 )
6062
6163 for i, watcher := range watchers {
@@ -88,66 +90,217 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
8890 }
8991
9092 logging.Debug("WatchKind", "kind", watchKind)
91−
92− // Test match against some example paths
93− testPaths := []string{
94− "/Users/phil/dev/mcp-language-server/internal/watcher/watcher.go",
95− "/Users/phil/dev/mcp-language-server/go.mod",
96− }
97−
98− for _, testPath := range testPaths {
99− isMatch := w.matchesPattern(testPath, watcher.GlobPattern)
100− logging.Debug("Test path", "path", testPath, "matches", isMatch)
101− }
10293 }
10394 }
10495
105− // Find and open all existing files that match the newly registered patterns
106− // TODO: not all language servers require this, but typescript does. Make this configurable
107− go func() {
108− startTime := time.Now()
109− filesOpened := 0
110−
111− err := filepath.WalkDir(w.workspacePath, func(path string, d os.DirEntry, err error) error {
112− if err != nil {
113− return err
96+ // Determine server type for specialized handling
97+ serverName := getServerNameFromContext(ctx)
98+ logging.Debug("Server type detected", "serverName", serverName)
99+
100+ // Check if this server has sent file watchers
101+ hasFileWatchers := len(watchers) > 0
102+
103+ // For servers that need file preloading, we'll use a smart approach
104+ if shouldPreloadFiles(serverName) || !hasFileWatchers {
105+ go func() {
106+ startTime := time.Now()
107+ filesOpened := 0
108+
109+ // Determine max files to open based on server type
110+ maxFilesToOpen := 50 // Default conservative limit
111+
112+ switch serverName {
113+ case "typescript", "typescript-language-server", "tsserver", "vtsls":
114+ // TypeScript servers benefit from seeing more files
115+ maxFilesToOpen = 100
116+ case "java", "jdtls":
117+ // Java servers need to see many files for project model
118+ maxFilesToOpen = 200
119+ }
120+
121+ // First, open high-priority files
122+ highPriorityFilesOpened := w.openHighPriorityFiles(ctx, serverName)
123+ filesOpened += highPriorityFilesOpened
124+
125+ if cnf.DebugLSP {
126+ logging.Debug("Opened high-priority files",
127+ "count", highPriorityFilesOpened,
128+ "serverName", serverName)
129+ }
130+
131+ // If we've already opened enough high-priority files, we might not need more
132+ if filesOpened >= maxFilesToOpen {
133+ if cnf.DebugLSP {
134+ logging.Debug("Reached file limit with high-priority files",
135+ "filesOpened", filesOpened,
136+ "maxFiles", maxFilesToOpen)
137+ }
138+ return
114139 }
140+
141+ // For the remaining slots, walk the directory and open matching files
142+
143+ err := filepath.WalkDir(w.workspacePath, func(path string, d os.DirEntry, err error) error {
144+ if err != nil {
145+ return err
146+ }
115147
116− // Skip directories that should be excluded
117− if d.IsDir() {
118− if path != w.workspacePath && shouldExcludeDir(path) {
119− if cnf.Debug {
120− logging.Debug("Skipping excluded directory", "path", path)
148+ // Skip directories that should be excluded
149+ if d.IsDir() {
150+ if path != w.workspacePath && shouldExcludeDir(path) {
151+ if cnf.DebugLSP {
152+ logging.Debug("Skipping excluded directory", "path", path)
153+ }
154+ return filepath.SkipDir
155+ }
156+ } else {
157+ // Process files, but limit the total number
158+ if filesOpened < maxFilesToOpen {
159+ // Only process if it's not already open (high-priority files were opened earlier)
160+ if !w.client.IsFileOpen(path) {
161+ w.openMatchingFile(ctx, path)
162+ filesOpened++
163+
164+ // Add a small delay after every 10 files to prevent overwhelming the server
165+ if filesOpened%10 == 0 {
166+ time.Sleep(50 * time.Millisecond)
167+ }
168+ }
169+ } else {
170+ // We've reached our limit, stop walking
171+ return filepath.SkipAll
121172 }
122− return filepath.SkipDir
123173 }
124− } else {
125− // Process files
126− w.openMatchingFile(ctx, path)
127− filesOpened++
128174
129− // Add a small delay after every 100 files to prevent overwhelming the server
130− if filesOpened%100 == 0 {
131− time.Sleep(10 * time.Millisecond)
132− }
175+ return nil
176+ })
177+
178+ elapsedTime := time.Since(startTime)
179+ if cnf.DebugLSP {
180+ logging.Debug("Limited workspace scan complete",
181+ "filesOpened", filesOpened,
182+ "maxFiles", maxFilesToOpen,
183+ "elapsedTime", elapsedTime.Seconds(),
184+ "workspacePath", w.workspacePath,
185+ )
133186 }
134187
135− return nil
136− })
188+ if err != nil && cnf.DebugLSP {
189+ logging.Debug("Error scanning workspace for files to open", "error", err)
190+ }
191+ }()
192+ } else if cnf.DebugLSP {
193+ logging.Debug("Using on-demand file loading for server", "server", serverName)
194+ }
195+}
137196
138− elapsedTime := time.Since(startTime)
139− if cnf.Debug {
140− logging.Debug("Workspace scan complete",
141− "filesOpened", filesOpened,
142− "elapsedTime", elapsedTime.Seconds(),
143− "workspacePath", w.workspacePath,
144− )
197+// openHighPriorityFiles opens important files for the server type
198+// Returns the number of files opened
199+func (w *WorkspaceWatcher) openHighPriorityFiles(ctx context.Context, serverName string) int {
200+ cnf := config.Get()
201+ filesOpened := 0
202+
203+ // Define patterns for high-priority files based on server type
204+ var patterns []string
205+
206+ switch serverName {
207+ case "typescript", "typescript-language-server", "tsserver", "vtsls":
208+ patterns = []string{
209+ "**/tsconfig.json",
210+ "**/package.json",
211+ "**/jsconfig.json",
212+ "**/index.ts",
213+ "**/index.js",
214+ "**/main.ts",
215+ "**/main.js",
145216 }
146−
147− if err != nil && cnf.Debug {
148− logging.Debug("Error scanning workspace for files to open", "error", err)
217+ case "gopls":
218+ patterns = []string{
219+ "**/go.mod",
220+ "**/go.sum",
221+ "**/main.go",
222+ }
223+ case "rust-analyzer":
224+ patterns = []string{
225+ "**/Cargo.toml",
226+ "**/Cargo.lock",
227+ "**/src/lib.rs",
228+ "**/src/main.rs",
229+ }
230+ case "python", "pyright", "pylsp":
231+ patterns = []string{
232+ "**/pyproject.toml",
233+ "**/setup.py",
234+ "**/requirements.txt",
235+ "**/__init__.py",
236+ "**/__main__.py",
237+ }
238+ case "clangd":
239+ patterns = []string{
240+ "**/CMakeLists.txt",
241+ "**/Makefile",
242+ "**/compile_commands.json",
243+ }
244+ case "java", "jdtls":
245+ patterns = []string{
246+ "**/pom.xml",
247+ "**/build.gradle",
248+ "**/src/main/java/**/*.java",
249+ }
250+ default:
251+ // For unknown servers, use common configuration files
252+ patterns = []string{
253+ "**/package.json",
254+ "**/Makefile",
255+ "**/CMakeLists.txt",
256+ "**/.editorconfig",
257+ }
258+ }
259+
260+ // For each pattern, find and open matching files
261+ for _, pattern := range patterns {
262+ // Use doublestar.Glob to find files matching the pattern (supports ** patterns)
263+ matches, err := doublestar.Glob(os.DirFS(w.workspacePath), pattern)
264+ if err != nil {
265+ if cnf.DebugLSP {
266+ logging.Debug("Error finding high-priority files", "pattern", pattern, "error", err)
267+ }
268+ continue
269+ }
270+
271+ for _, match := range matches {
272+ // Convert relative path to absolute
273+ fullPath := filepath.Join(w.workspacePath, match)
274+
275+ // Skip directories and excluded files
276+ info, err := os.Stat(fullPath)
277+ if err != nil || info.IsDir() || shouldExcludeFile(fullPath) {
278+ continue
279+ }
280+
281+ // Open the file
282+ if err := w.client.OpenFile(ctx, fullPath); err != nil {
283+ if cnf.DebugLSP {
284+ logging.Debug("Error opening high-priority file", "path", fullPath, "error", err)
285+ }
286+ } else {
287+ filesOpened++
288+ if cnf.DebugLSP {
289+ logging.Debug("Opened high-priority file", "path", fullPath)
290+ }
291+ }
292+
293+ // Add a small delay to prevent overwhelming the server
294+ time.Sleep(20 * time.Millisecond)
295+
296+ // Limit the number of files opened per pattern
297+ if filesOpened >= 5 && (serverName != "java" && serverName != "jdtls") {
298+ break
299+ }
149300 }
150− }()
301+ }
302+
303+ return filesOpened
151304}
152305
153306// WatchWorkspace sets up file watching for a workspace
@@ -155,6 +308,18 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
155308 cnf := config.Get()
156309 w.workspacePath = workspacePath
157310
311+ // Store the watcher in the context for later use
312+ ctx = context.WithValue(ctx, "workspaceWatcher", w)
313+
314+ // If the server name isn't already in the context, try to detect it
315+ if _, ok := ctx.Value("serverName").(string); !ok {
316+ serverName := getServerNameFromContext(ctx)
317+ ctx = context.WithValue(ctx, "serverName", serverName)
318+ }
319+
320+ serverName := getServerNameFromContext(ctx)
321+ logging.Debug("Starting workspace watcher", "workspacePath", workspacePath, "serverName", serverName)
322+
158323 // Register handler for file watcher registrations from the server
159324 lsp.RegisterFileWatchHandler(func(id string, watchers []protocol.FileSystemWatcher) {
160325 w.AddRegistrations(ctx, id, watchers)
@@ -175,7 +340,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
175340 // Skip excluded directories (except workspace root)
176341 if d.IsDir() && path != workspacePath {
177342 if shouldExcludeDir(path) {
178− if cnf.Debug {
343+ if cnf.DebugLSP {
179344 logging.Debug("Skipping excluded directory", "path", path)
180345 }
181346 return filepath.SkipDir
@@ -228,7 +393,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
228393 }
229394
230395 // Debug logging
231− if cnf.Debug {
396+ if cnf.DebugLSP {
232397 matched, kind := w.isPathWatched(event.Name)
233398 logging.Debug("File event",
234399 "path", event.Name,
@@ -491,7 +656,7 @@ func (w *WorkspaceWatcher) handleFileEvent(ctx context.Context, uri string, chan
491656// notifyFileEvent sends a didChangeWatchedFiles notification for a file event
492657func (w *WorkspaceWatcher) notifyFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) error {
493658 cnf := config.Get()
494− if cnf.Debug {
659+ if cnf.DebugLSP {
495660 logging.Debug("Notifying file event",
496661 "uri", uri,
497662 "changeType", changeType,
@@ -510,6 +675,57 @@ func (w *WorkspaceWatcher) notifyFileEvent(ctx context.Context, uri string, chan
510675 return w.client.DidChangeWatchedFiles(ctx, params)
511676}
512677
678+// getServerNameFromContext extracts the server name from the context
679+// This is a best-effort function that tries to identify which LSP server we're dealing with
680+func getServerNameFromContext(ctx context.Context) string {
681+ // First check if the server name is directly stored in the context
682+ if serverName, ok := ctx.Value("serverName").(string); ok && serverName != "" {
683+ return strings.ToLower(serverName)
684+ }
685+
686+ // Otherwise, try to extract server name from the client command path
687+ if w, ok := ctx.Value("workspaceWatcher").(*WorkspaceWatcher); ok && w != nil && w.client != nil && w.client.Cmd != nil {
688+ path := strings.ToLower(w.client.Cmd.Path)
689+
690+ // Extract server name from path
691+ if strings.Contains(path, "typescript") || strings.Contains(path, "tsserver") || strings.Contains(path, "vtsls") {
692+ return "typescript"
693+ } else if strings.Contains(path, "gopls") {
694+ return "gopls"
695+ } else if strings.Contains(path, "rust-analyzer") {
696+ return "rust-analyzer"
697+ } else if strings.Contains(path, "pyright") || strings.Contains(path, "pylsp") || strings.Contains(path, "python") {
698+ return "python"
699+ } else if strings.Contains(path, "clangd") {
700+ return "clangd"
701+ } else if strings.Contains(path, "jdtls") || strings.Contains(path, "java") {
702+ return "java"
703+ }
704+
705+ // Return the base name as fallback
706+ return filepath.Base(path)
707+ }
708+
709+ return "unknown"
710+}
711+
712+// shouldPreloadFiles determines if we should preload files for a specific language server
713+// Some servers work better with preloaded files, others don't need it
714+func shouldPreloadFiles(serverName string) bool {
715+ // TypeScript/JavaScript servers typically need some files preloaded
716+ // to properly resolve imports and provide intellisense
717+ switch serverName {
718+ case "typescript", "typescript-language-server", "tsserver", "vtsls":
719+ return true
720+ case "java", "jdtls":
721+ // Java servers often need to see source files to build the project model
722+ return true
723+ default:
724+ // For most servers, we'll use lazy loading by default
725+ return false
726+ }
727+}
728+
513729// Common patterns for directories and files to exclude
514730// TODO: make configurable
515731var (
@@ -615,7 +831,7 @@ func shouldExcludeFile(filePath string) bool {
615831
616832 // Skip large files
617833 if info.Size() > maxFileSize {
618− if cnf.Debug {
834+ if cnf.DebugLSP {
619835 logging.Debug("Skipping large file",
620836 "path", filePath,
621837 "size", info.Size(),
@@ -647,9 +863,119 @@ func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
647863
648864 // Check if this path should be watched according to server registrations
649865 if watched, _ := w.isPathWatched(path); watched {
650− // Don't need to check if it's already open - the client.OpenFile handles that
651− if err := w.client.OpenFile(ctx, path); err != nil && cnf.Debug {
652− logging.Error("Error opening file", "path", path, "error", err)
866+ // Get server name for specialized handling
867+ serverName := getServerNameFromContext(ctx)
868+
869+ // Check if the file is a high-priority file that should be opened immediately
870+ // This helps with project initialization for certain language servers
871+ if isHighPriorityFile(path, serverName) {
872+ if cnf.DebugLSP {
873+ logging.Debug("Opening high-priority file", "path", path, "serverName", serverName)
874+ }
875+ if err := w.client.OpenFile(ctx, path); err != nil && cnf.DebugLSP {
876+ logging.Error("Error opening high-priority file", "path", path, "error", err)
877+ }
878+ return
879+ }
880+
881+ // For non-high-priority files, we'll use different strategies based on server type
882+ if shouldPreloadFiles(serverName) {
883+ // For servers that benefit from preloading, open files but with limits
884+
885+ // Check file size - for preloading we're more conservative
886+ if info.Size() > (1 * 1024 * 1024) { // 1MB limit for preloaded files
887+ if cnf.DebugLSP {
888+ logging.Debug("Skipping large file for preloading", "path", path, "size", info.Size())
889+ }
890+ return
891+ }
892+
893+ // Check file extension for common source files
894+ ext := strings.ToLower(filepath.Ext(path))
895+
896+ // Only preload source files for the specific language
897+ shouldOpen := false
898+
899+ switch serverName {
900+ case "typescript", "typescript-language-server", "tsserver", "vtsls":
901+ shouldOpen = ext == ".ts" || ext == ".js" || ext == ".tsx" || ext == ".jsx"
902+ case "gopls":
903+ shouldOpen = ext == ".go"
904+ case "rust-analyzer":
905+ shouldOpen = ext == ".rs"
906+ case "python", "pyright", "pylsp":
907+ shouldOpen = ext == ".py"
908+ case "clangd":
909+ shouldOpen = ext == ".c" || ext == ".cpp" || ext == ".h" || ext == ".hpp"
910+ case "java", "jdtls":
911+ shouldOpen = ext == ".java"
912+ default:
913+ // For unknown servers, be conservative
914+ shouldOpen = false
915+ }
916+
917+ if shouldOpen {
918+ // Don't need to check if it's already open - the client.OpenFile handles that
919+ if err := w.client.OpenFile(ctx, path); err != nil && cnf.DebugLSP {
920+ logging.Error("Error opening file", "path", path, "error", err)
921+ }
922+ }
653923 }
654924 }
655925}
926+
927+// isHighPriorityFile determines if a file should be opened immediately
928+// regardless of the preloading strategy
929+func isHighPriorityFile(path string, serverName string) bool {
930+ fileName := filepath.Base(path)
931+ ext := filepath.Ext(path)
932+
933+ switch serverName {
934+ case "typescript", "typescript-language-server", "tsserver", "vtsls":
935+ // For TypeScript, we want to open configuration files immediately
936+ return fileName == "tsconfig.json" ||
937+ fileName == "package.json" ||
938+ fileName == "jsconfig.json" ||
939+ // Also open main entry points
940+ fileName == "index.ts" ||
941+ fileName == "index.js" ||
942+ fileName == "main.ts" ||
943+ fileName == "main.js"
944+ case "gopls":
945+ // For Go, we want to open go.mod files immediately
946+ return fileName == "go.mod" ||
947+ fileName == "go.sum" ||
948+ // Also open main.go files
949+ fileName == "main.go"
950+ case "rust-analyzer":
951+ // For Rust, we want to open Cargo.toml files immediately
952+ return fileName == "Cargo.toml" ||
953+ fileName == "Cargo.lock" ||
954+ // Also open lib.rs and main.rs
955+ fileName == "lib.rs" ||
956+ fileName == "main.rs"
957+ case "python", "pyright", "pylsp":
958+ // For Python, open key project files
959+ return fileName == "pyproject.toml" ||
960+ fileName == "setup.py" ||
961+ fileName == "requirements.txt" ||
962+ fileName == "__init__.py" ||
963+ fileName == "__main__.py"
964+ case "clangd":
965+ // For C/C++, open key project files
966+ return fileName == "CMakeLists.txt" ||
967+ fileName == "Makefile" ||
968+ fileName == "compile_commands.json"
969+ case "java", "jdtls":
970+ // For Java, open key project files
971+ return fileName == "pom.xml" ||
972+ fileName == "build.gradle" ||
973+ ext == ".java" // Java servers often need to see source files
974+ }
975+
976+ // For unknown servers, prioritize common configuration files
977+ return fileName == "package.json" ||
978+ fileName == "Makefile" ||
979+ fileName == "CMakeLists.txt" ||
980+ fileName == ".editorconfig"
981+}
internal/message/content.go+84−4
@@ -2,6 +2,10 @@ package message
22
33import (
44 "encoding/base64"
5+ "slices"
6+ "time"
7+
8+ "github.com/kujtimiihoxha/opencode/internal/llm/models"
59)
610
711type MessageRole string
@@ -13,6 +17,20 @@ const (
1317 Tool MessageRole = "tool"
1418)
1519
20+type FinishReason string
21+
22+const (
23+ FinishReasonEndTurn FinishReason = "end_turn"
24+ FinishReasonMaxTokens FinishReason = "max_tokens"
25+ FinishReasonToolUse FinishReason = "tool_use"
26+ FinishReasonCanceled FinishReason = "canceled"
27+ FinishReasonError FinishReason = "error"
28+ FinishReasonPermissionDenied FinishReason = "permission_denied"
29+
30+ // Should never happen
31+ FinishReasonUnknown FinishReason = "unknown"
32+)
33+
1634type ContentPart interface {
1735 isPart()
1836}
@@ -73,13 +91,15 @@ type ToolResult struct {
7391 ToolCallID string `json:"tool_call_id"`
7492 Name string `json:"name"`
7593 Content string `json:"content"`
94+ Metadata string `json:"metadata"`
7695 IsError bool `json:"is_error"`
7796}
7897
7998func (ToolResult) isPart() {}
8099
81100type Finish struct {
82− Reason string `json:"reason"`
101+ Reason FinishReason `json:"reason"`
102+ Time int64 `json:"time"`
83103}
84104
85105func (Finish) isPart() {}
@@ -89,6 +109,7 @@ type Message struct {
89109 Role MessageRole
90110 SessionID string
91111 Parts []ContentPart
112+ Model models.ModelID
92113
93114 CreatedAt int64
94115 UpdatedAt int64
@@ -161,7 +182,16 @@ func (m *Message) IsFinished() bool {
161182 return false
162183}
163184
164−func (m *Message) FinishReason() string {
185+func (m *Message) FinishPart() *Finish {
186+ for _, part := range m.Parts {
187+ if c, ok := part.(Finish); ok {
188+ return &c
189+ }
190+ }
191+ return nil
192+}
193+
194+func (m *Message) FinishReason() FinishReason {
165195 for _, part := range m.Parts {
166196 if c, ok := part.(Finish); ok {
167197 return c.Reason
@@ -203,6 +233,40 @@ func (m *Message) AppendReasoningContent(delta string) {
203233 }
204234}
205235
236+func (m *Message) FinishToolCall(toolCallID string) {
237+ for i, part := range m.Parts {
238+ if c, ok := part.(ToolCall); ok {
239+ if c.ID == toolCallID {
240+ m.Parts[i] = ToolCall{
241+ ID: c.ID,
242+ Name: c.Name,
243+ Input: c.Input,
244+ Type: c.Type,
245+ Finished: true,
246+ }
247+ return
248+ }
249+ }
250+ }
251+}
252+
253+func (m *Message) AppendToolCallInput(toolCallID string, inputDelta string) {
254+ for i, part := range m.Parts {
255+ if c, ok := part.(ToolCall); ok {
256+ if c.ID == toolCallID {
257+ m.Parts[i] = ToolCall{
258+ ID: c.ID,
259+ Name: c.Name,
260+ Input: c.Input + inputDelta,
261+ Type: c.Type,
262+ Finished: c.Finished,
263+ }
264+ return
265+ }
266+ }
267+ }
268+}
269+
206270func (m *Message) AddToolCall(tc ToolCall) {
207271 for i, part := range m.Parts {
208272 if c, ok := part.(ToolCall); ok {
@@ -216,6 +280,15 @@ func (m *Message) AddToolCall(tc ToolCall) {
216280}
217281
218282func (m *Message) SetToolCalls(tc []ToolCall) {
283+ // remove any existing tool call part it could have multiple
284+ parts := make([]ContentPart, 0)
285+ for _, part := range m.Parts {
286+ if _, ok := part.(ToolCall); ok {
287+ continue
288+ }
289+ parts = append(parts, part)
290+ }
291+ m.Parts = parts
219292 for _, toolCall := range tc {
220293 m.Parts = append(m.Parts, toolCall)
221294 }
@@ -231,8 +304,15 @@ func (m *Message) SetToolResults(tr []ToolResult) {
231304 }
232305}
233306
234−func (m *Message) AddFinish(reason string) {
235− m.Parts = append(m.Parts, Finish{Reason: reason})
307+func (m *Message) AddFinish(reason FinishReason) {
308+ // remove any existing finish part
309+ for i, part := range m.Parts {
310+ if _, ok := part.(Finish); ok {
311+ m.Parts = slices.Delete(m.Parts, i, i+1)
312+ break
313+ }
314+ }
315+ m.Parts = append(m.Parts, Finish{Reason: reason, Time: time.Now().Unix()})
236316}
237317
238318func (m *Message) AddImageURL(url, detail string) {
internal/message/message.go+39−28
@@ -2,49 +2,51 @@ package message
22
33import (
44 "context"
5+ "database/sql"
56 "encoding/json"
67 "fmt"
8+ "time"
79
810 "github.com/google/uuid"
9− "github.com/kujtimiihoxha/termai/internal/db"
10− "github.com/kujtimiihoxha/termai/internal/pubsub"
11+ "github.com/kujtimiihoxha/opencode/internal/db"
12+ "github.com/kujtimiihoxha/opencode/internal/llm/models"
13+ "github.com/kujtimiihoxha/opencode/internal/pubsub"
1114)
1215
1316type CreateMessageParams struct {
1417 Role MessageRole
1518 Parts []ContentPart
19+ Model models.ModelID
1620}
1721
1822type Service interface {
1923 pubsub.Suscriber[Message]
20− Create(sessionID string, params CreateMessageParams) (Message, error)
21− Update(message Message) error
22− Get(id string) (Message, error)
23− List(sessionID string) ([]Message, error)
24− Delete(id string) error
25− DeleteSessionMessages(sessionID string) error
24+ Create(ctx context.Context, sessionID string, params CreateMessageParams) (Message, error)
25+ Update(ctx context.Context, message Message) error
26+ Get(ctx context.Context, id string) (Message, error)
27+ List(ctx context.Context, sessionID string) ([]Message, error)
28+ Delete(ctx context.Context, id string) error
29+ DeleteSessionMessages(ctx context.Context, sessionID string) error
2630}
2731
2832type service struct {
2933 *pubsub.Broker[Message]
30− q db.Querier
31− ctx context.Context
34+ q db.Querier
3235}
3336
34−func NewService(ctx context.Context, q db.Querier) Service {
37+func NewService(q db.Querier) Service {
3538 return &service{
3639 Broker: pubsub.NewBroker[Message](),
3740 q: q,
38− ctx: ctx,
3941 }
4042}
4143
42−func (s *service) Delete(id string) error {
43− message, err := s.Get(id)
44+func (s *service) Delete(ctx context.Context, id string) error {
45+ message, err := s.Get(ctx, id)
4446 if err != nil {
4547 return err
4648 }
47− err = s.q.DeleteMessage(s.ctx, message.ID)
49+ err = s.q.DeleteMessage(ctx, message.ID)
4850 if err != nil {
4951 return err
5052 }
@@ -52,7 +54,7 @@ func (s *service) Delete(id string) error {
5254 return nil
5355}
5456
55−func (s *service) Create(sessionID string, params CreateMessageParams) (Message, error) {
57+func (s *service) Create(ctx context.Context, sessionID string, params CreateMessageParams) (Message, error) {
5658 if params.Role != Assistant {
5759 params.Parts = append(params.Parts, Finish{
5860 Reason: "stop",
@@ -63,11 +65,12 @@ func (s *service) Create(sessionID string, params CreateMessageParams) (Message,
6365 return Message{}, err
6466 }
6567
66− dbMessage, err := s.q.CreateMessage(s.ctx, db.CreateMessageParams{
68+ dbMessage, err := s.q.CreateMessage(ctx, db.CreateMessageParams{
6769 ID: uuid.New().String(),
6870 SessionID: sessionID,
6971 Role: string(params.Role),
7072 Parts: string(partsJSON),
73+ Model: sql.NullString{String: string(params.Model), Valid: true},
7174 })
7275 if err != nil {
7376 return Message{}, err
@@ -80,14 +83,14 @@ func (s *service) Create(sessionID string, params CreateMessageParams) (Message,
8083 return message, nil
8184}
8285
83−func (s *service) DeleteSessionMessages(sessionID string) error {
84− messages, err := s.List(sessionID)
86+func (s *service) DeleteSessionMessages(ctx context.Context, sessionID string) error {
87+ messages, err := s.List(ctx, sessionID)
8588 if err != nil {
8689 return err
8790 }
8891 for _, message := range messages {
8992 if message.SessionID == sessionID {
90− err = s.Delete(message.ID)
93+ err = s.Delete(ctx, message.ID)
9194 if err != nil {
9295 return err
9396 }
@@ -96,32 +99,39 @@ func (s *service) DeleteSessionMessages(sessionID string) error {
9699 return nil
97100}
98101
99−func (s *service) Update(message Message) error {
102+func (s *service) Update(ctx context.Context, message Message) error {
100103 parts, err := marshallParts(message.Parts)
101104 if err != nil {
102105 return err
103106 }
104− err = s.q.UpdateMessage(s.ctx, db.UpdateMessageParams{
105− ID: message.ID,
106− Parts: string(parts),
107+ finishedAt := sql.NullInt64{}
108+ if f := message.FinishPart(); f != nil {
109+ finishedAt.Int64 = f.Time
110+ finishedAt.Valid = true
111+ }
112+ err = s.q.UpdateMessage(ctx, db.UpdateMessageParams{
113+ ID: message.ID,
114+ Parts: string(parts),
115+ FinishedAt: finishedAt,
107116 })
108117 if err != nil {
109118 return err
110119 }
120+ message.UpdatedAt = time.Now().Unix()
111121 s.Publish(pubsub.UpdatedEvent, message)
112122 return nil
113123}
114124
115−func (s *service) Get(id string) (Message, error) {
116− dbMessage, err := s.q.GetMessage(s.ctx, id)
125+func (s *service) Get(ctx context.Context, id string) (Message, error) {
126+ dbMessage, err := s.q.GetMessage(ctx, id)
117127 if err != nil {
118128 return Message{}, err
119129 }
120130 return s.fromDBItem(dbMessage)
121131}
122132
123−func (s *service) List(sessionID string) ([]Message, error) {
124− dbMessages, err := s.q.ListMessagesBySession(s.ctx, sessionID)
133+func (s *service) List(ctx context.Context, sessionID string) ([]Message, error) {
134+ dbMessages, err := s.q.ListMessagesBySession(ctx, sessionID)
125135 if err != nil {
126136 return nil, err
127137 }
@@ -145,6 +155,7 @@ func (s *service) fromDBItem(item db.Message) (Message, error) {
145155 SessionID: item.SessionID,
146156 Role: MessageRole(item.Role),
147157 Parts: parts,
158+ Model: models.ModelID(item.Model.String),
148159 CreatedAt: item.CreatedAt,
149160 UpdatedAt: item.UpdatedAt,
150161 }, nil
internal/permission/permission.go+27−5
@@ -1,20 +1,28 @@
11package permission
22
33import (
4+ "errors"
5+ "path/filepath"
6+ "slices"
47 "sync"
58 "time"
69
710 "github.com/google/uuid"
8− "github.com/kujtimiihoxha/termai/internal/pubsub"
11+ "github.com/kujtimiihoxha/opencode/internal/config"
12+ "github.com/kujtimiihoxha/opencode/internal/pubsub"
913)
1014
15+var ErrorPermissionDenied = errors.New("permission denied")
16+
1117type CreatePermissionRequest struct {
18+ SessionID string `json:"session_id"`
1219 ToolName string `json:"tool_name"`
1320 Description string `json:"description"`
1421 Action string `json:"action"`
1522 Params any `json:"params"`
1623 Path string `json:"path"`
1724}
25+
1826type PermissionRequest struct {
1927 ID string `json:"id"`
2028 SessionID string `json:"session_id"`
@@ -31,13 +39,15 @@ type Service interface {
3139 Grant(permission PermissionRequest)
3240 Deny(permission PermissionRequest)
3341 Request(opts CreatePermissionRequest) bool
42+ AutoApproveSession(sessionID string)
3443}
3544
3645type permissionService struct {
3746 *pubsub.Broker[PermissionRequest]
3847
39− sessionPermissions []PermissionRequest
40− pendingRequests sync.Map
48+ sessionPermissions []PermissionRequest
49+ pendingRequests sync.Map
50+ autoApproveSessions []string
4151}
4252
4353func (s *permissionService) GrantPersistant(permission PermissionRequest) {
@@ -63,9 +73,17 @@ func (s *permissionService) Deny(permission PermissionRequest) {
6373}
6474
6575func (s *permissionService) Request(opts CreatePermissionRequest) bool {
76+ if slices.Contains(s.autoApproveSessions, opts.SessionID) {
77+ return true
78+ }
79+ dir := filepath.Dir(opts.Path)
80+ if dir == "." {
81+ dir = config.WorkingDirectory()
82+ }
6683 permission := PermissionRequest{
6784 ID: uuid.New().String(),
68− Path: opts.Path,
85+ Path: dir,
86+ SessionID: opts.SessionID,
6987 ToolName: opts.ToolName,
7088 Description: opts.Description,
7189 Action: opts.Action,
@@ -73,7 +91,7 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
7391 }
7492
7593 for _, p := range s.sessionPermissions {
76− if p.ToolName == permission.ToolName && p.Action == permission.Action {
94+ if p.ToolName == permission.ToolName && p.Action == permission.Action && p.SessionID == permission.SessionID && p.Path == permission.Path {
7795 return true
7896 }
7997 }
@@ -94,6 +112,10 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
94112 }
95113}
96114
115+func (s *permissionService) AutoApproveSession(sessionID string) {
116+ s.autoApproveSessions = append(s.autoApproveSessions, sessionID)
117+}
118+
97119func NewPermissionService() Service {
98120 return &permissionService{
99121 Broker: pubsub.NewBroker[PermissionRequest](),
internal/pubsub/broker.go+47−32
@@ -5,54 +5,53 @@ import (
55 "sync"
66)
77
8−const bufferSize = 1024 * 1024
8+const bufferSize = 64
99
10−type Logger interface {
11− Debug(msg string, args ...any)
12− Info(msg string, args ...any)
13− Warn(msg string, args ...any)
14− Error(msg string, args ...any)
15−}
16−
17−// Broker allows clients to publish events and subscribe to events
1810type Broker[T any] struct {
19− subs map[chan Event[T]]struct{} // subscriptions
20− mu sync.Mutex // sync access to map
21− done chan struct{} // close when broker is shutting down
11+ subs map[chan Event[T]]struct{}
12+ mu sync.RWMutex
13+ done chan struct{}
14+ subCount int
15+ maxEvents int
2216}
2317
24−// NewBroker constructs a pub/sub broker.
2518func NewBroker[T any]() *Broker[T] {
19+ return NewBrokerWithOptions[T](bufferSize, 1000)
20+}
21+
22+func NewBrokerWithOptions[T any](channelBufferSize, maxEvents int) *Broker[T] {
2623 b := &Broker[T]{
27− subs: make(map[chan Event[T]]struct{}),
28− done: make(chan struct{}),
24+ subs: make(map[chan Event[T]]struct{}),
25+ done: make(chan struct{}),
26+ subCount: 0,
27+ maxEvents: maxEvents,
2928 }
3029 return b
3130}
3231
33−// Shutdown the broker, terminating any subscriptions.
3432func (b *Broker[T]) Shutdown() {
35− close(b.done)
33+ select {
34+ case <-b.done: // Already closed
35+ return
36+ default:
37+ close(b.done)
38+ }
3639
3740 b.mu.Lock()
3841 defer b.mu.Unlock()
3942
40− // Remove each subscriber entry, so Publish() cannot send any further
41− // messages, and close each subscriber's channel, so the subscriber cannot
42− // consume any more messages.
4343 for ch := range b.subs {
4444 delete(b.subs, ch)
4545 close(ch)
4646 }
47+
48+ b.subCount = 0
4749}
4850
49−// Subscribe subscribes the caller to a stream of events. The returned channel
50−// is closed when the broker is shutdown.
5151func (b *Broker[T]) Subscribe(ctx context.Context) <-chan Event[T] {
5252 b.mu.Lock()
5353 defer b.mu.Unlock()
5454
55− // Check if broker has shutdown and if so return closed channel
5655 select {
5756 case <-b.done:
5857 ch := make(chan Event[T])
@@ -61,18 +60,16 @@ func (b *Broker[T]) Subscribe(ctx context.Context) <-chan Event[T] {
6160 default:
6261 }
6362
64− // Subscribe
6563 sub := make(chan Event[T], bufferSize)
6664 b.subs[sub] = struct{}{}
65+ b.subCount++
6766
68− // Unsubscribe when context is done.
6967 go func() {
7068 <-ctx.Done()
7169
7270 b.mu.Lock()
7371 defer b.mu.Unlock()
7472
75− // Check if broker has shutdown and if so do nothing
7673 select {
7774 case <-b.done:
7875 return
@@ -81,21 +78,39 @@ func (b *Broker[T]) Subscribe(ctx context.Context) <-chan Event[T] {
8178
8279 delete(b.subs, sub)
8380 close(sub)
81+ b.subCount--
8482 }()
8583
8684 return sub
8785}
8886
89−// Publish an event to subscribers.
87+func (b *Broker[T]) GetSubscriberCount() int {
88+ b.mu.RLock()
89+ defer b.mu.RUnlock()
90+ return b.subCount
91+}
92+
9093func (b *Broker[T]) Publish(t EventType, payload T) {
91− b.mu.Lock()
92− defer b.mu.Unlock()
94+ b.mu.RLock()
95+ select {
96+ case <-b.done:
97+ b.mu.RUnlock()
98+ return
99+ default:
100+ }
93101
102+ subscribers := make([]chan Event[T], 0, len(b.subs))
94103 for sub := range b.subs {
104+ subscribers = append(subscribers, sub)
105+ }
106+ b.mu.RUnlock()
107+
108+ event := Event[T]{Type: t, Payload: payload}
109+
110+ for _, sub := range subscribers {
95111 select {
96− case sub <- Event[T]{Type: t, Payload: payload}:
97− case <-b.done:
98− return
112+ case sub <- event:
113+ default:
99114 }
100115 }
101116}
internal/session/session.go+38−25
@@ -5,8 +5,8 @@ import (
55 "database/sql"
66
77 "github.com/google/uuid"
8− "github.com/kujtimiihoxha/termai/internal/db"
9− "github.com/kujtimiihoxha/termai/internal/pubsub"
8+ "github.com/kujtimiihoxha/opencode/internal/db"
9+ "github.com/kujtimiihoxha/opencode/internal/pubsub"
1010)
1111
1212type Session struct {
@@ -23,22 +23,22 @@ type Session struct {
2323
2424type Service interface {
2525 pubsub.Suscriber[Session]
26− Create(title string) (Session, error)
27− CreateTaskSession(toolCallID, parentSessionID, title string) (Session, error)
28− Get(id string) (Session, error)
29− List() ([]Session, error)
30− Save(session Session) (Session, error)
31− Delete(id string) error
26+ Create(ctx context.Context, title string) (Session, error)
27+ CreateTitleSession(ctx context.Context, parentSessionID string) (Session, error)
28+ CreateTaskSession(ctx context.Context, toolCallID, parentSessionID, title string) (Session, error)
29+ Get(ctx context.Context, id string) (Session, error)
30+ List(ctx context.Context) ([]Session, error)
31+ Save(ctx context.Context, session Session) (Session, error)
32+ Delete(ctx context.Context, id string) error
3233}
3334
3435type service struct {
3536 *pubsub.Broker[Session]
36− q db.Querier
37− ctx context.Context
37+ q db.Querier
3838}
3939
40−func (s *service) Create(title string) (Session, error) {
41− dbSession, err := s.q.CreateSession(s.ctx, db.CreateSessionParams{
40+func (s *service) Create(ctx context.Context, title string) (Session, error) {
41+ dbSession, err := s.q.CreateSession(ctx, db.CreateSessionParams{
4242 ID: uuid.New().String(),
4343 Title: title,
4444 })
@@ -50,8 +50,8 @@ func (s *service) Create(title string) (Session, error) {
5050 return session, nil
5151}
5252
53−func (s *service) CreateTaskSession(toolCallID, parentSessionID, title string) (Session, error) {
54− dbSession, err := s.q.CreateSession(s.ctx, db.CreateSessionParams{
53+func (s *service) CreateTaskSession(ctx context.Context, toolCallID, parentSessionID, title string) (Session, error) {
54+ dbSession, err := s.q.CreateSession(ctx, db.CreateSessionParams{
5555 ID: toolCallID,
5656 ParentSessionID: sql.NullString{String: parentSessionID, Valid: true},
5757 Title: title,
@@ -64,12 +64,26 @@ func (s *service) CreateTaskSession(toolCallID, parentSessionID, title string) (
6464 return session, nil
6565}
6666
67−func (s *service) Delete(id string) error {
68− session, err := s.Get(id)
67+func (s *service) CreateTitleSession(ctx context.Context, parentSessionID string) (Session, error) {
68+ dbSession, err := s.q.CreateSession(ctx, db.CreateSessionParams{
69+ ID: "title-" + parentSessionID,
70+ ParentSessionID: sql.NullString{String: parentSessionID, Valid: true},
71+ Title: "Generate a title",
72+ })
73+ if err != nil {
74+ return Session{}, err
75+ }
76+ session := s.fromDBItem(dbSession)
77+ s.Publish(pubsub.CreatedEvent, session)
78+ return session, nil
79+}
80+
81+func (s *service) Delete(ctx context.Context, id string) error {
82+ session, err := s.Get(ctx, id)
6983 if err != nil {
7084 return err
7185 }
72− err = s.q.DeleteSession(s.ctx, session.ID)
86+ err = s.q.DeleteSession(ctx, session.ID)
7387 if err != nil {
7488 return err
7589 }
@@ -77,16 +91,16 @@ func (s *service) Delete(id string) error {
7791 return nil
7892}
7993
80−func (s *service) Get(id string) (Session, error) {
81− dbSession, err := s.q.GetSessionByID(s.ctx, id)
94+func (s *service) Get(ctx context.Context, id string) (Session, error) {
95+ dbSession, err := s.q.GetSessionByID(ctx, id)
8296 if err != nil {
8397 return Session{}, err
8498 }
8599 return s.fromDBItem(dbSession), nil
86100}
87101
88−func (s *service) Save(session Session) (Session, error) {
89− dbSession, err := s.q.UpdateSession(s.ctx, db.UpdateSessionParams{
102+func (s *service) Save(ctx context.Context, session Session) (Session, error) {
103+ dbSession, err := s.q.UpdateSession(ctx, db.UpdateSessionParams{
90104 ID: session.ID,
91105 Title: session.Title,
92106 PromptTokens: session.PromptTokens,
@@ -101,8 +115,8 @@ func (s *service) Save(session Session) (Session, error) {
101115 return session, nil
102116}
103117
104−func (s *service) List() ([]Session, error) {
105− dbSessions, err := s.q.ListSessions(s.ctx)
118+func (s *service) List(ctx context.Context) ([]Session, error) {
119+ dbSessions, err := s.q.ListSessions(ctx)
106120 if err != nil {
107121 return nil, err
108122 }
@@ -127,11 +141,10 @@ func (s service) fromDBItem(item db.Session) Session {
127141 }
128142}
129143
130−func NewService(ctx context.Context, q db.Querier) Service {
144+func NewService(q db.Querier) Service {
131145 broker := pubsub.NewBroker[Session]()
132146 return &service{
133147 broker,
134148 q,
135− ctx,
136149 }
137150}
internal/tui/components/chat/chat.gonew file+119
@@ -0,0 +1,119 @@
1+package chat
2+
3+import (
4+ "fmt"
5+ "sort"
6+
7+ "github.com/charmbracelet/lipgloss"
8+ "github.com/charmbracelet/x/ansi"
9+ "github.com/kujtimiihoxha/opencode/internal/config"
10+ "github.com/kujtimiihoxha/opencode/internal/session"
11+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
12+ "github.com/kujtimiihoxha/opencode/internal/version"
13+)
14+
15+type SendMsg struct {
16+ Text string
17+}
18+
19+type SessionSelectedMsg = session.Session
20+
21+type SessionClearedMsg struct{}
22+
23+type EditorFocusMsg bool
24+
25+func lspsConfigured(width int) string {
26+ cfg := config.Get()
27+ title := "LSP Configuration"
28+ title = ansi.Truncate(title, width, "…")
29+
30+ lsps := styles.BaseStyle.Width(width).Foreground(styles.PrimaryColor).Bold(true).Render(title)
31+
32+ // Get LSP names and sort them for consistent ordering
33+ var lspNames []string
34+ for name := range cfg.LSP {
35+ lspNames = append(lspNames, name)
36+ }
37+ sort.Strings(lspNames)
38+
39+ var lspViews []string
40+ for _, name := range lspNames {
41+ lsp := cfg.LSP[name]
42+ lspName := styles.BaseStyle.Foreground(styles.Forground).Render(
43+ fmt.Sprintf("• %s", name),
44+ )
45+ cmd := lsp.Command
46+ cmd = ansi.Truncate(cmd, width-lipgloss.Width(lspName)-3, "…")
47+ lspPath := styles.BaseStyle.Foreground(styles.ForgroundDim).Render(
48+ fmt.Sprintf(" (%s)", cmd),
49+ )
50+ lspViews = append(lspViews,
51+ styles.BaseStyle.
52+ Width(width).
53+ Render(
54+ lipgloss.JoinHorizontal(
55+ lipgloss.Left,
56+ lspName,
57+ lspPath,
58+ ),
59+ ),
60+ )
61+ }
62+ return styles.BaseStyle.
63+ Width(width).
64+ Render(
65+ lipgloss.JoinVertical(
66+ lipgloss.Left,
67+ lsps,
68+ lipgloss.JoinVertical(
69+ lipgloss.Left,
70+ lspViews...,
71+ ),
72+ ),
73+ )
74+}
75+
76+func logo(width int) string {
77+ logo := fmt.Sprintf("%s %s", styles.OpenCodeIcon, "OpenCode")
78+
79+ version := styles.BaseStyle.Foreground(styles.ForgroundDim).Render(version.Version)
80+
81+ return styles.BaseStyle.
82+ Bold(true).
83+ Width(width).
84+ Render(
85+ lipgloss.JoinHorizontal(
86+ lipgloss.Left,
87+ logo,
88+ " ",
89+ version,
90+ ),
91+ )
92+}
93+
94+func repo(width int) string {
95+ repo := "https://github.com/kujtimiihoxha/opencode"
96+ return styles.BaseStyle.
97+ Foreground(styles.ForgroundDim).
98+ Width(width).
99+ Render(repo)
100+}
101+
102+func cwd(width int) string {
103+ cwd := fmt.Sprintf("cwd: %s", config.WorkingDirectory())
104+ return styles.BaseStyle.
105+ Foreground(styles.ForgroundDim).
106+ Width(width).
107+ Render(cwd)
108+}
109+
110+func header(width int) string {
111+ header := lipgloss.JoinVertical(
112+ lipgloss.Top,
113+ logo(width),
114+ repo(width),
115+ "",
116+ cwd(width),
117+ )
118+ return header
119+}
internal/tui/components/chat/editor.gonew file+207
@@ -0,0 +1,207 @@
1+package chat
2+
3+import (
4+ "os"
5+ "os/exec"
6+
7+ "github.com/charmbracelet/bubbles/key"
8+ "github.com/charmbracelet/bubbles/textarea"
9+ tea "github.com/charmbracelet/bubbletea"
10+ "github.com/charmbracelet/lipgloss"
11+ "github.com/kujtimiihoxha/opencode/internal/app"
12+ "github.com/kujtimiihoxha/opencode/internal/session"
13+ "github.com/kujtimiihoxha/opencode/internal/tui/layout"
14+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
15+ "github.com/kujtimiihoxha/opencode/internal/tui/util"
16+)
17+
18+type editorCmp struct {
19+ app *app.App
20+ session session.Session
21+ textarea textarea.Model
22+}
23+
24+type FocusEditorMsg bool
25+
26+type focusedEditorKeyMaps struct {
27+ Send key.Binding
28+ OpenEditor key.Binding
29+ Blur key.Binding
30+}
31+
32+type bluredEditorKeyMaps struct {
33+ Send key.Binding
34+ Focus key.Binding
35+ OpenEditor key.Binding
36+}
37+
38+var focusedKeyMaps = focusedEditorKeyMaps{
39+ Send: key.NewBinding(
40+ key.WithKeys("ctrl+s"),
41+ key.WithHelp("ctrl+s", "send message"),
42+ ),
43+ Blur: key.NewBinding(
44+ key.WithKeys("esc"),
45+ key.WithHelp("esc", "focus messages"),
46+ ),
47+ OpenEditor: key.NewBinding(
48+ key.WithKeys("ctrl+e"),
49+ key.WithHelp("ctrl+e", "open editor"),
50+ ),
51+}
52+
53+var bluredKeyMaps = bluredEditorKeyMaps{
54+ Send: key.NewBinding(
55+ key.WithKeys("ctrl+s", "enter"),
56+ key.WithHelp("ctrl+s/enter", "send message"),
57+ ),
58+ Focus: key.NewBinding(
59+ key.WithKeys("i"),
60+ key.WithHelp("i", "focus editor"),
61+ ),
62+ OpenEditor: key.NewBinding(
63+ key.WithKeys("ctrl+e"),
64+ key.WithHelp("ctrl+e", "open editor"),
65+ ),
66+}
67+
68+func openEditor() tea.Cmd {
69+ editor := os.Getenv("EDITOR")
70+ if editor == "" {
71+ editor = "nvim"
72+ }
73+
74+ tmpfile, err := os.CreateTemp("", "msg_*.md")
75+ if err != nil {
76+ return util.ReportError(err)
77+ }
78+ tmpfile.Close()
79+ c := exec.Command(editor, tmpfile.Name()) //nolint:gosec
80+ c.Stdin = os.Stdin
81+ c.Stdout = os.Stdout
82+ c.Stderr = os.Stderr
83+ return tea.ExecProcess(c, func(err error) tea.Msg {
84+ if err != nil {
85+ return util.ReportError(err)
86+ }
87+ content, err := os.ReadFile(tmpfile.Name())
88+ if err != nil {
89+ return util.ReportError(err)
90+ }
91+ os.Remove(tmpfile.Name())
92+ return SendMsg{
93+ Text: string(content),
94+ }
95+ })
96+}
97+
98+func (m *editorCmp) Init() tea.Cmd {
99+ return textarea.Blink
100+}
101+
102+func (m *editorCmp) send() tea.Cmd {
103+ if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
104+ return util.ReportWarn("Agent is working, please wait...")
105+ }
106+
107+ value := m.textarea.Value()
108+ m.textarea.Reset()
109+ m.textarea.Blur()
110+ if value == "" {
111+ return nil
112+ }
113+ return tea.Batch(
114+ util.CmdHandler(SendMsg{
115+ Text: value,
116+ }),
117+ )
118+}
119+
120+func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
121+ var cmd tea.Cmd
122+ switch msg := msg.(type) {
123+ case SessionSelectedMsg:
124+ if msg.ID != m.session.ID {
125+ m.session = msg
126+ }
127+ return m, nil
128+ case FocusEditorMsg:
129+ if msg {
130+ m.textarea.Focus()
131+ return m, tea.Batch(textarea.Blink, util.CmdHandler(EditorFocusMsg(true)))
132+ }
133+ case tea.KeyMsg:
134+ if key.Matches(msg, focusedKeyMaps.OpenEditor) {
135+ if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
136+ return m, util.ReportWarn("Agent is working, please wait...")
137+ }
138+ return m, openEditor()
139+ }
140+ // if the key does not match any binding, return
141+ if m.textarea.Focused() && key.Matches(msg, focusedKeyMaps.Send) {
142+ return m, m.send()
143+ }
144+ if !m.textarea.Focused() && key.Matches(msg, bluredKeyMaps.Send) {
145+ return m, m.send()
146+ }
147+ if m.textarea.Focused() && key.Matches(msg, focusedKeyMaps.Blur) {
148+ m.textarea.Blur()
149+ return m, util.CmdHandler(EditorFocusMsg(false))
150+ }
151+ if !m.textarea.Focused() && key.Matches(msg, bluredKeyMaps.Focus) {
152+ m.textarea.Focus()
153+ return m, tea.Batch(textarea.Blink, util.CmdHandler(EditorFocusMsg(true)))
154+ }
155+ }
156+ m.textarea, cmd = m.textarea.Update(msg)
157+ return m, cmd
158+}
159+
160+func (m *editorCmp) View() string {
161+ style := lipgloss.NewStyle().Padding(0, 0, 0, 1).Bold(true)
162+
163+ return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View())
164+}
165+
166+func (m *editorCmp) SetSize(width, height int) tea.Cmd {
167+ m.textarea.SetWidth(width - 3) // account for the prompt and padding right
168+ m.textarea.SetHeight(height)
169+ return nil
170+}
171+
172+func (m *editorCmp) GetSize() (int, int) {
173+ return m.textarea.Width(), m.textarea.Height()
174+}
175+
176+func (m *editorCmp) BindingKeys() []key.Binding {
177+ bindings := []key.Binding{}
178+ if m.textarea.Focused() {
179+ bindings = append(bindings, layout.KeyMapToSlice(focusedKeyMaps)...)
180+ } else {
181+ bindings = append(bindings, layout.KeyMapToSlice(bluredKeyMaps)...)
182+ }
183+
184+ bindings = append(bindings, layout.KeyMapToSlice(m.textarea.KeyMap)...)
185+ return bindings
186+}
187+
188+func NewEditorCmp(app *app.App) tea.Model {
189+ ti := textarea.New()
190+ ti.Prompt = " "
191+ ti.ShowLineNumbers = false
192+ ti.BlurredStyle.Base = ti.BlurredStyle.Base.Background(styles.Background)
193+ ti.BlurredStyle.CursorLine = ti.BlurredStyle.CursorLine.Background(styles.Background)
194+ ti.BlurredStyle.Placeholder = ti.BlurredStyle.Placeholder.Background(styles.Background)
195+ ti.BlurredStyle.Text = ti.BlurredStyle.Text.Background(styles.Background)
196+
197+ ti.FocusedStyle.Base = ti.FocusedStyle.Base.Background(styles.Background)
198+ ti.FocusedStyle.CursorLine = ti.FocusedStyle.CursorLine.Background(styles.Background)
199+ ti.FocusedStyle.Placeholder = ti.FocusedStyle.Placeholder.Background(styles.Background)
200+ ti.FocusedStyle.Text = ti.BlurredStyle.Text.Background(styles.Background)
201+ ti.CharLimit = -1
202+ ti.Focus()
203+ return &editorCmp{
204+ app: app,
205+ textarea: ti,
206+ }
207+}
internal/tui/components/chat/list.gonew file+415
@@ -0,0 +1,415 @@
1+package chat
2+
3+import (
4+ "context"
5+ "fmt"
6+ "math"
7+
8+ "github.com/charmbracelet/bubbles/key"
9+ "github.com/charmbracelet/bubbles/spinner"
10+ "github.com/charmbracelet/bubbles/viewport"
11+ tea "github.com/charmbracelet/bubbletea"
12+ "github.com/charmbracelet/lipgloss"
13+ "github.com/kujtimiihoxha/opencode/internal/app"
14+ "github.com/kujtimiihoxha/opencode/internal/message"
15+ "github.com/kujtimiihoxha/opencode/internal/pubsub"
16+ "github.com/kujtimiihoxha/opencode/internal/session"
17+ "github.com/kujtimiihoxha/opencode/internal/tui/layout"
18+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
19+ "github.com/kujtimiihoxha/opencode/internal/tui/util"
20+)
21+
22+type cacheItem struct {
23+ width int
24+ content []uiMessage
25+}
26+type messagesCmp struct {
27+ app *app.App
28+ width, height int
29+ writingMode bool
30+ viewport viewport.Model
31+ session session.Session
32+ messages []message.Message
33+ uiMessages []uiMessage
34+ currentMsgID string
35+ cachedContent map[string]cacheItem
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(), m.spinner.Tick)
43+}
44+
45+func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
46+ var cmds []tea.Cmd
47+ switch msg := msg.(type) {
48+ case EditorFocusMsg:
49+ m.writingMode = bool(msg)
50+ case SessionSelectedMsg:
51+ if msg.ID != m.session.ID {
52+ cmd := m.SetSession(msg)
53+ return m, cmd
54+ }
55+ return m, nil
56+ case SessionClearedMsg:
57+ m.session = session.Session{}
58+ m.messages = make([]message.Message, 0)
59+ m.currentMsgID = ""
60+ m.rendering = false
61+ return m, nil
62+
63+ case renderFinishedMsg:
64+ m.rendering = false
65+ m.viewport.GotoBottom()
66+ case tea.KeyMsg:
67+ if m.writingMode {
68+ return m, nil
69+ }
70+ case pubsub.Event[message.Message]:
71+ needsRerender := false
72+ if msg.Type == pubsub.CreatedEvent {
73+ if msg.Payload.SessionID == m.session.ID {
74+
75+ messageExists := false
76+ for _, v := range m.messages {
77+ if v.ID == msg.Payload.ID {
78+ messageExists = true
79+ break
80+ }
81+ }
82+
83+ if !messageExists {
84+ if len(m.messages) > 0 {
85+ lastMsgID := m.messages[len(m.messages)-1].ID
86+ delete(m.cachedContent, lastMsgID)
87+ }
88+
89+ m.messages = append(m.messages, msg.Payload)
90+ delete(m.cachedContent, m.currentMsgID)
91+ m.currentMsgID = msg.Payload.ID
92+ needsRerender = true
93+ }
94+ }
95+ // There are tool calls from the child task
96+ for _, v := range m.messages {
97+ for _, c := range v.ToolCalls() {
98+ if c.ID == msg.Payload.SessionID {
99+ delete(m.cachedContent, v.ID)
100+ needsRerender = true
101+ }
102+ }
103+ }
104+ } else if msg.Type == pubsub.UpdatedEvent && msg.Payload.SessionID == m.session.ID {
105+ for i, v := range m.messages {
106+ if v.ID == msg.Payload.ID {
107+ m.messages[i] = msg.Payload
108+ delete(m.cachedContent, msg.Payload.ID)
109+ needsRerender = true
110+ break
111+ }
112+ }
113+ }
114+ if needsRerender {
115+ m.renderView()
116+ if len(m.messages) > 0 {
117+ if (msg.Type == pubsub.CreatedEvent) ||
118+ (msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == m.messages[len(m.messages)-1].ID) {
119+ m.viewport.GotoBottom()
120+ }
121+ }
122+ }
123+ }
124+
125+ u, cmd := m.viewport.Update(msg)
126+ m.viewport = u
127+ cmds = append(cmds, cmd)
128+
129+ spinner, cmd := m.spinner.Update(msg)
130+ m.spinner = spinner
131+ cmds = append(cmds, cmd)
132+ return m, tea.Batch(cmds...)
133+}
134+
135+func (m *messagesCmp) IsAgentWorking() bool {
136+ return m.app.CoderAgent.IsSessionBusy(m.session.ID)
137+}
138+
139+func formatTimeDifference(unixTime1, unixTime2 int64) string {
140+ diffSeconds := float64(math.Abs(float64(unixTime2 - unixTime1)))
141+
142+ if diffSeconds < 60 {
143+ return fmt.Sprintf("%.1fs", diffSeconds)
144+ }
145+
146+ minutes := int(diffSeconds / 60)
147+ seconds := int(diffSeconds) % 60
148+ return fmt.Sprintf("%dm%ds", minutes, seconds)
149+}
150+
151+func (m *messagesCmp) renderView() {
152+ m.uiMessages = make([]uiMessage, 0)
153+ pos := 0
154+
155+ if m.width == 0 {
156+ return
157+ }
158+ for inx, msg := range m.messages {
159+ switch msg.Role {
160+ case message.User:
161+ if cache, ok := m.cachedContent[msg.ID]; ok && cache.width == m.width {
162+ m.uiMessages = append(m.uiMessages, cache.content...)
163+ continue
164+ }
165+ userMsg := renderUserMessage(
166+ msg,
167+ msg.ID == m.currentMsgID,
168+ m.width,
169+ pos,
170+ )
171+ m.uiMessages = append(m.uiMessages, userMsg)
172+ m.cachedContent[msg.ID] = cacheItem{
173+ width: m.width,
174+ content: []uiMessage{userMsg},
175+ }
176+ pos += userMsg.height + 1 // + 1 for spacing
177+ case message.Assistant:
178+ if cache, ok := m.cachedContent[msg.ID]; ok && cache.width == m.width {
179+ m.uiMessages = append(m.uiMessages, cache.content...)
180+ continue
181+ }
182+ assistantMessages := renderAssistantMessage(
183+ msg,
184+ inx,
185+ m.messages,
186+ m.app.Messages,
187+ m.currentMsgID,
188+ m.width,
189+ pos,
190+ )
191+ for _, msg := range assistantMessages {
192+ m.uiMessages = append(m.uiMessages, msg)
193+ pos += msg.height + 1 // + 1 for spacing
194+ }
195+ m.cachedContent[msg.ID] = cacheItem{
196+ width: m.width,
197+ content: assistantMessages,
198+ }
199+ }
200+ }
201+
202+ messages := make([]string, 0)
203+ for _, v := range m.uiMessages {
204+ messages = append(messages, v.content,
205+ styles.BaseStyle.
206+ Width(m.width).
207+ Render(
208+ "",
209+ ),
210+ )
211+ }
212+ m.viewport.SetContent(
213+ styles.BaseStyle.
214+ Width(m.width).
215+ Render(
216+ lipgloss.JoinVertical(
217+ lipgloss.Top,
218+ messages...,
219+ ),
220+ ),
221+ )
222+}
223+
224+func (m *messagesCmp) View() string {
225+ if m.rendering {
226+ return styles.BaseStyle.
227+ Width(m.width).
228+ Render(
229+ lipgloss.JoinVertical(
230+ lipgloss.Top,
231+ "Loading...",
232+ m.working(),
233+ m.help(),
234+ ),
235+ )
236+ }
237+ if len(m.messages) == 0 {
238+ content := styles.BaseStyle.
239+ Width(m.width).
240+ Height(m.height - 1).
241+ Render(
242+ m.initialScreen(),
243+ )
244+
245+ return styles.BaseStyle.
246+ Width(m.width).
247+ Render(
248+ lipgloss.JoinVertical(
249+ lipgloss.Top,
250+ content,
251+ "",
252+ m.help(),
253+ ),
254+ )
255+ }
256+
257+ return styles.BaseStyle.
258+ Width(m.width).
259+ Render(
260+ lipgloss.JoinVertical(
261+ lipgloss.Top,
262+ m.viewport.View(),
263+ m.working(),
264+ m.help(),
265+ ),
266+ )
267+}
268+
269+func hasToolsWithoutResponse(messages []message.Message) bool {
270+ toolCalls := make([]message.ToolCall, 0)
271+ toolResults := make([]message.ToolResult, 0)
272+ for _, m := range messages {
273+ toolCalls = append(toolCalls, m.ToolCalls()...)
274+ toolResults = append(toolResults, m.ToolResults()...)
275+ }
276+
277+ for _, v := range toolCalls {
278+ found := false
279+ for _, r := range toolResults {
280+ if v.ID == r.ToolCallID {
281+ found = true
282+ break
283+ }
284+ }
285+ if !found && v.Finished {
286+ return true
287+ }
288+ }
289+ return false
290+}
291+
292+func hasUnfinishedToolCalls(messages []message.Message) bool {
293+ toolCalls := make([]message.ToolCall, 0)
294+ for _, m := range messages {
295+ toolCalls = append(toolCalls, m.ToolCalls()...)
296+ }
297+ for _, v := range toolCalls {
298+ if !v.Finished {
299+ return true
300+ }
301+ }
302+ return false
303+}
304+
305+func (m *messagesCmp) working() string {
306+ text := ""
307+ if m.IsAgentWorking() && len(m.messages) > 0 {
308+ task := "Thinking..."
309+ lastMessage := m.messages[len(m.messages)-1]
310+ if hasToolsWithoutResponse(m.messages) {
311+ task = "Waiting for tool response..."
312+ } else if hasUnfinishedToolCalls(m.messages) {
313+ task = "Building tool call..."
314+ } else if !lastMessage.IsFinished() {
315+ task = "Generating..."
316+ }
317+ if task != "" {
318+ text += styles.BaseStyle.Width(m.width).Foreground(styles.PrimaryColor).Bold(true).Render(
319+ fmt.Sprintf("%s %s ", m.spinner.View(), task),
320+ )
321+ }
322+ }
323+ return text
324+}
325+
326+func (m *messagesCmp) help() string {
327+ text := ""
328+
329+ if m.writingMode {
330+ text += lipgloss.JoinHorizontal(
331+ lipgloss.Left,
332+ styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "),
333+ styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("esc"),
334+ styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render(" to exit writing mode"),
335+ )
336+ } else {
337+ text += lipgloss.JoinHorizontal(
338+ lipgloss.Left,
339+ styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "),
340+ styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("i"),
341+ styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render(" to start writing"),
342+ )
343+ }
344+
345+ return styles.BaseStyle.
346+ Width(m.width).
347+ Render(text)
348+}
349+
350+func (m *messagesCmp) initialScreen() string {
351+ return styles.BaseStyle.Width(m.width).Render(
352+ lipgloss.JoinVertical(
353+ lipgloss.Top,
354+ header(m.width),
355+ "",
356+ lspsConfigured(m.width),
357+ ),
358+ )
359+}
360+
361+func (m *messagesCmp) SetSize(width, height int) tea.Cmd {
362+ if m.width == width && m.height == height {
363+ return nil
364+ }
365+ m.width = width
366+ m.height = height
367+ m.viewport.Width = width
368+ m.viewport.Height = height - 2
369+ for _, msg := range m.messages {
370+ delete(m.cachedContent, msg.ID)
371+ }
372+ m.uiMessages = make([]uiMessage, 0)
373+ m.renderView()
374+ return nil
375+}
376+
377+func (m *messagesCmp) GetSize() (int, int) {
378+ return m.width, m.height
379+}
380+
381+func (m *messagesCmp) SetSession(session session.Session) tea.Cmd {
382+ if m.session.ID == session.ID {
383+ return nil
384+ }
385+ m.session = session
386+ messages, err := m.app.Messages.List(context.Background(), session.ID)
387+ if err != nil {
388+ return util.ReportError(err)
389+ }
390+ m.messages = messages
391+ m.currentMsgID = m.messages[len(m.messages)-1].ID
392+ delete(m.cachedContent, m.currentMsgID)
393+ m.rendering = true
394+ return func() tea.Msg {
395+ m.renderView()
396+ return renderFinishedMsg{}
397+ }
398+}
399+
400+func (m *messagesCmp) BindingKeys() []key.Binding {
401+ bindings := layout.KeyMapToSlice(m.viewport.KeyMap)
402+ return bindings
403+}
404+
405+func NewMessagesCmp(app *app.App) tea.Model {
406+ s := spinner.New()
407+ s.Spinner = spinner.Pulse
408+ return &messagesCmp{
409+ app: app,
410+ writingMode: true,
411+ cachedContent: make(map[string]cacheItem),
412+ viewport: viewport.New(0, 0),
413+ spinner: s,
414+ }
415+}
internal/tui/components/chat/message.gonew file+624
@@ -0,0 +1,624 @@
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+ messages := []uiMessage{}
117+ content := msg.Content().String()
118+ thinking := msg.IsThinking()
119+ thinkingContent := msg.ReasoningContent().Thinking
120+ finished := msg.IsFinished()
121+ finishData := msg.FinishPart()
122+ info := []string{}
123+
124+ // Add finish info if available
125+ if finished {
126+ switch finishData.Reason {
127+ case message.FinishReasonEndTurn:
128+ took := formatTimeDifference(msg.CreatedAt, finishData.Time)
129+ info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
130+ fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, took),
131+ ))
132+ case message.FinishReasonCanceled:
133+ info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
134+ fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "canceled"),
135+ ))
136+ case message.FinishReasonError:
137+ info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
138+ fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "error"),
139+ ))
140+ case message.FinishReasonPermissionDenied:
141+ info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
142+ fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "permission denied"),
143+ ))
144+ }
145+ }
146+ if content != "" || (finished && finishData.Reason == message.FinishReasonEndTurn) {
147+ if content == "" {
148+ content = "*Finished without output*"
149+ }
150+
151+ content = renderMessage(content, false, msg.ID == focusedUIMessageId, width, info...)
152+ messages = append(messages, uiMessage{
153+ ID: msg.ID,
154+ messageType: assistantMessageType,
155+ position: position,
156+ height: lipgloss.Height(content),
157+ content: content,
158+ })
159+ position += messages[0].height
160+ position++ // for the space
161+ } else if thinking && thinkingContent != "" {
162+ // Render the thinking content
163+ content = renderMessage(thinkingContent, false, msg.ID == focusedUIMessageId, width)
164+ }
165+
166+ for i, toolCall := range msg.ToolCalls() {
167+ toolCallContent := renderToolMessage(
168+ toolCall,
169+ allMessages,
170+ messagesService,
171+ focusedUIMessageId,
172+ false,
173+ width,
174+ i+1,
175+ )
176+ messages = append(messages, toolCallContent)
177+ position += toolCallContent.height
178+ position++ // for the space
179+ }
180+ return messages
181+}
182+
183+func findToolResponse(toolCallID string, futureMessages []message.Message) *message.ToolResult {
184+ for _, msg := range futureMessages {
185+ for _, result := range msg.ToolResults() {
186+ if result.ToolCallID == toolCallID {
187+ return &result
188+ }
189+ }
190+ }
191+ return nil
192+}
193+
194+func toolName(name string) string {
195+ switch name {
196+ case agent.AgentToolName:
197+ return "Task"
198+ case tools.BashToolName:
199+ return "Bash"
200+ case tools.EditToolName:
201+ return "Edit"
202+ case tools.FetchToolName:
203+ return "Fetch"
204+ case tools.GlobToolName:
205+ return "Glob"
206+ case tools.GrepToolName:
207+ return "Grep"
208+ case tools.LSToolName:
209+ return "List"
210+ case tools.SourcegraphToolName:
211+ return "Sourcegraph"
212+ case tools.ViewToolName:
213+ return "View"
214+ case tools.WriteToolName:
215+ return "Write"
216+ case tools.PatchToolName:
217+ return "Patch"
218+ }
219+ return name
220+}
221+
222+func getToolAction(name string) string {
223+ switch name {
224+ case agent.AgentToolName:
225+ return "Preparing prompt..."
226+ case tools.BashToolName:
227+ return "Building command..."
228+ case tools.EditToolName:
229+ return "Preparing edit..."
230+ case tools.FetchToolName:
231+ return "Writing fetch..."
232+ case tools.GlobToolName:
233+ return "Finding files..."
234+ case tools.GrepToolName:
235+ return "Searching content..."
236+ case tools.LSToolName:
237+ return "Listing directory..."
238+ case tools.SourcegraphToolName:
239+ return "Searching code..."
240+ case tools.ViewToolName:
241+ return "Reading file..."
242+ case tools.WriteToolName:
243+ return "Preparing write..."
244+ case tools.PatchToolName:
245+ return "Preparing patch..."
246+ }
247+ return "Working..."
248+}
249+
250+// renders params, params[0] (params[1]=params[2] ....)
251+func renderParams(paramsWidth int, params ...string) string {
252+ if len(params) == 0 {
253+ return ""
254+ }
255+ mainParam := params[0]
256+ if len(mainParam) > paramsWidth {
257+ mainParam = mainParam[:paramsWidth-3] + "..."
258+ }
259+
260+ if len(params) == 1 {
261+ return mainParam
262+ }
263+ otherParams := params[1:]
264+ // create pairs of key/value
265+ // if odd number of params, the last one is a key without value
266+ if len(otherParams)%2 != 0 {
267+ otherParams = append(otherParams, "")
268+ }
269+ parts := make([]string, 0, len(otherParams)/2)
270+ for i := 0; i < len(otherParams); i += 2 {
271+ key := otherParams[i]
272+ value := otherParams[i+1]
273+ if value == "" {
274+ continue
275+ }
276+ parts = append(parts, fmt.Sprintf("%s=%s", key, value))
277+ }
278+
279+ partsRendered := strings.Join(parts, ", ")
280+ remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 5 // for the space
281+ if remainingWidth < 30 {
282+ // No space for the params, just show the main
283+ return mainParam
284+ }
285+
286+ if len(parts) > 0 {
287+ mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
288+ }
289+
290+ return ansi.Truncate(mainParam, paramsWidth, "...")
291+}
292+
293+func removeWorkingDirPrefix(path string) string {
294+ wd := config.WorkingDirectory()
295+ if strings.HasPrefix(path, wd) {
296+ path = strings.TrimPrefix(path, wd)
297+ }
298+ if strings.HasPrefix(path, "/") {
299+ path = strings.TrimPrefix(path, "/")
300+ }
301+ if strings.HasPrefix(path, "./") {
302+ path = strings.TrimPrefix(path, "./")
303+ }
304+ if strings.HasPrefix(path, "../") {
305+ path = strings.TrimPrefix(path, "../")
306+ }
307+ return path
308+}
309+
310+func renderToolParams(paramWidth int, toolCall message.ToolCall) string {
311+ params := ""
312+ switch toolCall.Name {
313+ case agent.AgentToolName:
314+ var params agent.AgentParams
315+ json.Unmarshal([]byte(toolCall.Input), &params)
316+ prompt := strings.ReplaceAll(params.Prompt, "\n", " ")
317+ return renderParams(paramWidth, prompt)
318+ case tools.BashToolName:
319+ var params tools.BashParams
320+ json.Unmarshal([]byte(toolCall.Input), &params)
321+ command := strings.ReplaceAll(params.Command, "\n", " ")
322+ return renderParams(paramWidth, command)
323+ case tools.EditToolName:
324+ var params tools.EditParams
325+ json.Unmarshal([]byte(toolCall.Input), &params)
326+ filePath := removeWorkingDirPrefix(params.FilePath)
327+ return renderParams(paramWidth, filePath)
328+ case tools.FetchToolName:
329+ var params tools.FetchParams
330+ json.Unmarshal([]byte(toolCall.Input), &params)
331+ url := params.URL
332+ toolParams := []string{
333+ url,
334+ }
335+ if params.Format != "" {
336+ toolParams = append(toolParams, "format", params.Format)
337+ }
338+ if params.Timeout != 0 {
339+ toolParams = append(toolParams, "timeout", (time.Duration(params.Timeout) * time.Second).String())
340+ }
341+ return renderParams(paramWidth, toolParams...)
342+ case tools.GlobToolName:
343+ var params tools.GlobParams
344+ json.Unmarshal([]byte(toolCall.Input), &params)
345+ pattern := params.Pattern
346+ toolParams := []string{
347+ pattern,
348+ }
349+ if params.Path != "" {
350+ toolParams = append(toolParams, "path", params.Path)
351+ }
352+ return renderParams(paramWidth, toolParams...)
353+ case tools.GrepToolName:
354+ var params tools.GrepParams
355+ json.Unmarshal([]byte(toolCall.Input), &params)
356+ pattern := params.Pattern
357+ toolParams := []string{
358+ pattern,
359+ }
360+ if params.Path != "" {
361+ toolParams = append(toolParams, "path", params.Path)
362+ }
363+ if params.Include != "" {
364+ toolParams = append(toolParams, "include", params.Include)
365+ }
366+ if params.LiteralText {
367+ toolParams = append(toolParams, "literal", "true")
368+ }
369+ return renderParams(paramWidth, toolParams...)
370+ case tools.LSToolName:
371+ var params tools.LSParams
372+ json.Unmarshal([]byte(toolCall.Input), &params)
373+ path := params.Path
374+ if path == "" {
375+ path = "."
376+ }
377+ return renderParams(paramWidth, path)
378+ case tools.SourcegraphToolName:
379+ var params tools.SourcegraphParams
380+ json.Unmarshal([]byte(toolCall.Input), &params)
381+ return renderParams(paramWidth, params.Query)
382+ case tools.ViewToolName:
383+ var params tools.ViewParams
384+ json.Unmarshal([]byte(toolCall.Input), &params)
385+ filePath := removeWorkingDirPrefix(params.FilePath)
386+ toolParams := []string{
387+ filePath,
388+ }
389+ if params.Limit != 0 {
390+ toolParams = append(toolParams, "limit", fmt.Sprintf("%d", params.Limit))
391+ }
392+ if params.Offset != 0 {
393+ toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset))
394+ }
395+ return renderParams(paramWidth, toolParams...)
396+ case tools.WriteToolName:
397+ var params tools.WriteParams
398+ json.Unmarshal([]byte(toolCall.Input), &params)
399+ filePath := removeWorkingDirPrefix(params.FilePath)
400+ return renderParams(paramWidth, filePath)
401+ default:
402+ input := strings.ReplaceAll(toolCall.Input, "\n", " ")
403+ params = renderParams(paramWidth, input)
404+ }
405+ return params
406+}
407+
408+func truncateHeight(content string, height int) string {
409+ lines := strings.Split(content, "\n")
410+ if len(lines) > height {
411+ return strings.Join(lines[:height], "\n")
412+ }
413+ return content
414+}
415+
416+func renderToolResponse(toolCall message.ToolCall, response message.ToolResult, width int) string {
417+ if response.IsError {
418+ errContent := fmt.Sprintf("Error: %s", strings.ReplaceAll(response.Content, "\n", " "))
419+ errContent = ansi.Truncate(errContent, width-1, "...")
420+ return styles.BaseStyle.
421+ Width(width).
422+ Foreground(styles.Error).
423+ Render(errContent)
424+ }
425+ resultContent := truncateHeight(response.Content, maxResultHeight)
426+ switch toolCall.Name {
427+ case agent.AgentToolName:
428+ return styles.ForceReplaceBackgroundWithLipgloss(
429+ toMarkdown(resultContent, false, width),
430+ styles.Background,
431+ )
432+ case tools.BashToolName:
433+ resultContent = fmt.Sprintf("```bash\n%s\n```", resultContent)
434+ return styles.ForceReplaceBackgroundWithLipgloss(
435+ toMarkdown(resultContent, true, width),
436+ styles.Background,
437+ )
438+ case tools.EditToolName:
439+ metadata := tools.EditResponseMetadata{}
440+ json.Unmarshal([]byte(response.Metadata), &metadata)
441+ truncDiff := truncateHeight(metadata.Diff, maxResultHeight)
442+ formattedDiff, _ := diff.FormatDiff(truncDiff, diff.WithTotalWidth(width), diff.WithStyle(diffStyle))
443+ return formattedDiff
444+ case tools.FetchToolName:
445+ var params tools.FetchParams
446+ json.Unmarshal([]byte(toolCall.Input), &params)
447+ mdFormat := "markdown"
448+ switch params.Format {
449+ case "text":
450+ mdFormat = "text"
451+ case "html":
452+ mdFormat = "html"
453+ }
454+ resultContent = fmt.Sprintf("```%s\n%s\n```", mdFormat, resultContent)
455+ return styles.ForceReplaceBackgroundWithLipgloss(
456+ toMarkdown(resultContent, true, width),
457+ styles.Background,
458+ )
459+ case tools.GlobToolName:
460+ return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
461+ case tools.GrepToolName:
462+ return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
463+ case tools.LSToolName:
464+ return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
465+ case tools.SourcegraphToolName:
466+ return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
467+ case tools.ViewToolName:
468+ metadata := tools.ViewResponseMetadata{}
469+ json.Unmarshal([]byte(response.Metadata), &metadata)
470+ ext := filepath.Ext(metadata.FilePath)
471+ if ext == "" {
472+ ext = ""
473+ } else {
474+ ext = strings.ToLower(ext[1:])
475+ }
476+ resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(metadata.Content, maxResultHeight))
477+ return styles.ForceReplaceBackgroundWithLipgloss(
478+ toMarkdown(resultContent, true, width),
479+ styles.Background,
480+ )
481+ case tools.WriteToolName:
482+ params := tools.WriteParams{}
483+ json.Unmarshal([]byte(toolCall.Input), &params)
484+ metadata := tools.WriteResponseMetadata{}
485+ json.Unmarshal([]byte(response.Metadata), &metadata)
486+ ext := filepath.Ext(params.FilePath)
487+ if ext == "" {
488+ ext = ""
489+ } else {
490+ ext = strings.ToLower(ext[1:])
491+ }
492+ resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(params.Content, maxResultHeight))
493+ return styles.ForceReplaceBackgroundWithLipgloss(
494+ toMarkdown(resultContent, true, width),
495+ styles.Background,
496+ )
497+ default:
498+ resultContent = fmt.Sprintf("```text\n%s\n```", resultContent)
499+ return styles.ForceReplaceBackgroundWithLipgloss(
500+ toMarkdown(resultContent, true, width),
501+ styles.Background,
502+ )
503+ }
504+}
505+
506+func renderToolMessage(
507+ toolCall message.ToolCall,
508+ allMessages []message.Message,
509+ messagesService message.Service,
510+ focusedUIMessageId string,
511+ nested bool,
512+ width int,
513+ position int,
514+) uiMessage {
515+ if nested {
516+ width = width - 3
517+ }
518+ style := styles.BaseStyle.
519+ Width(width - 1).
520+ BorderLeft(true).
521+ BorderStyle(lipgloss.ThickBorder()).
522+ PaddingLeft(1).
523+ BorderForeground(styles.ForgroundDim)
524+
525+ response := findToolResponse(toolCall.ID, allMessages)
526+ toolName := styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf("%s: ", toolName(toolCall.Name)))
527+
528+ if !toolCall.Finished {
529+ // Get a brief description of what the tool is doing
530+ toolAction := getToolAction(toolCall.Name)
531+
532+ // toolInput := strings.ReplaceAll(toolCall.Input, "\n", " ")
533+ // truncatedInput := toolInput
534+ // if len(truncatedInput) > 10 {
535+ // truncatedInput = truncatedInput[len(truncatedInput)-10:]
536+ // }
537+ //
538+ // truncatedInput = styles.BaseStyle.
539+ // Italic(true).
540+ // Width(width - 2 - lipgloss.Width(toolName)).
541+ // Background(styles.BackgroundDim).
542+ // Foreground(styles.ForgroundMid).
543+ // Render(truncatedInput)
544+
545+ progressText := styles.BaseStyle.
546+ Width(width - 2 - lipgloss.Width(toolName)).
547+ Foreground(styles.ForgroundDim).
548+ Render(fmt.Sprintf("%s", toolAction))
549+
550+ content := style.Render(lipgloss.JoinHorizontal(lipgloss.Left, toolName, progressText))
551+ toolMsg := uiMessage{
552+ messageType: toolMessageType,
553+ position: position,
554+ height: lipgloss.Height(content),
555+ content: content,
556+ }
557+ return toolMsg
558+ }
559+ params := renderToolParams(width-2-lipgloss.Width(toolName), toolCall)
560+ responseContent := ""
561+ if response != nil {
562+ responseContent = renderToolResponse(toolCall, *response, width-2)
563+ responseContent = strings.TrimSuffix(responseContent, "\n")
564+ } else {
565+ responseContent = styles.BaseStyle.
566+ Italic(true).
567+ Width(width - 2).
568+ Foreground(styles.ForgroundDim).
569+ Render("Waiting for response...")
570+ }
571+
572+ parts := []string{}
573+ if !nested {
574+ params := styles.BaseStyle.
575+ Width(width - 2 - lipgloss.Width(toolName)).
576+ Foreground(styles.ForgroundDim).
577+ Render(params)
578+
579+ parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, toolName, params))
580+ } else {
581+ prefix := styles.BaseStyle.
582+ Foreground(styles.ForgroundDim).
583+ Render(" └ ")
584+ params := styles.BaseStyle.
585+ Width(width - 2 - lipgloss.Width(toolName)).
586+ Foreground(styles.ForgroundMid).
587+ Render(params)
588+ parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, prefix, toolName, params))
589+ }
590+ if toolCall.Name == agent.AgentToolName {
591+ taskMessages, _ := messagesService.List(context.Background(), toolCall.ID)
592+ toolCalls := []message.ToolCall{}
593+ for _, v := range taskMessages {
594+ toolCalls = append(toolCalls, v.ToolCalls()...)
595+ }
596+ for _, call := range toolCalls {
597+ rendered := renderToolMessage(call, []message.Message{}, messagesService, focusedUIMessageId, true, width, 0)
598+ parts = append(parts, rendered.content)
599+ }
600+ }
601+ if responseContent != "" && !nested {
602+ parts = append(parts, responseContent)
603+ }
604+
605+ content := style.Render(
606+ lipgloss.JoinVertical(
607+ lipgloss.Left,
608+ parts...,
609+ ),
610+ )
611+ if nested {
612+ content = lipgloss.JoinVertical(
613+ lipgloss.Left,
614+ parts...,
615+ )
616+ }
617+ toolMsg := uiMessage{
618+ messageType: toolMessageType,
619+ position: position,
620+ height: lipgloss.Height(content),
621+ content: content,
622+ }
623+ return toolMsg
624+}
internal/tui/components/chat/sidebar.gonew file+337
@@ -0,0 +1,337 @@
1+package chat
2+
3+import (
4+ "context"
5+ "fmt"
6+ "sort"
7+ "strings"
8+
9+ tea "github.com/charmbracelet/bubbletea"
10+ "github.com/charmbracelet/lipgloss"
11+ "github.com/kujtimiihoxha/opencode/internal/config"
12+ "github.com/kujtimiihoxha/opencode/internal/diff"
13+ "github.com/kujtimiihoxha/opencode/internal/history"
14+ "github.com/kujtimiihoxha/opencode/internal/pubsub"
15+ "github.com/kujtimiihoxha/opencode/internal/session"
16+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
17+)
18+
19+type sidebarCmp struct {
20+ width, height int
21+ session session.Session
22+ history history.Service
23+ modFiles map[string]struct {
24+ additions int
25+ removals int
26+ }
27+}
28+
29+func (m *sidebarCmp) Init() tea.Cmd {
30+ if m.history != nil {
31+ ctx := context.Background()
32+ // Subscribe to file events
33+ filesCh := m.history.Subscribe(ctx)
34+
35+ // Initialize the modified files map
36+ m.modFiles = make(map[string]struct {
37+ additions int
38+ removals int
39+ })
40+
41+ // Load initial files and calculate diffs
42+ m.loadModifiedFiles(ctx)
43+
44+ // Return a command that will send file events to the Update method
45+ return func() tea.Msg {
46+ return <-filesCh
47+ }
48+ }
49+ return nil
50+}
51+
52+func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
53+ switch msg := msg.(type) {
54+ case SessionSelectedMsg:
55+ if msg.ID != m.session.ID {
56+ m.session = msg
57+ ctx := context.Background()
58+ m.loadModifiedFiles(ctx)
59+ }
60+ case pubsub.Event[session.Session]:
61+ if msg.Type == pubsub.UpdatedEvent {
62+ if m.session.ID == msg.Payload.ID {
63+ m.session = msg.Payload
64+ }
65+ }
66+ case pubsub.Event[history.File]:
67+ if msg.Payload.SessionID == m.session.ID {
68+ // Process the individual file change instead of reloading all files
69+ ctx := context.Background()
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+ }
78+ }
79+ }
80+ return m, nil
81+}
82+
83+func (m *sidebarCmp) View() string {
84+ return styles.BaseStyle.
85+ Width(m.width).
86+ PaddingLeft(4).
87+ PaddingRight(2).
88+ Height(m.height - 1).
89+ Render(
90+ lipgloss.JoinVertical(
91+ lipgloss.Top,
92+ header(m.width),
93+ " ",
94+ m.sessionSection(),
95+ " ",
96+ lspsConfigured(m.width),
97+ " ",
98+ m.modifiedFiles(),
99+ ),
100+ )
101+}
102+
103+func (m *sidebarCmp) sessionSection() string {
104+ sessionKey := styles.BaseStyle.Foreground(styles.PrimaryColor).Bold(true).Render("Session")
105+ sessionValue := styles.BaseStyle.
106+ Foreground(styles.Forground).
107+ Width(m.width - lipgloss.Width(sessionKey)).
108+ Render(fmt.Sprintf(": %s", m.session.Title))
109+ return lipgloss.JoinHorizontal(
110+ lipgloss.Left,
111+ sessionKey,
112+ sessionValue,
113+ )
114+}
115+
116+func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) string {
117+ stats := ""
118+ if additions > 0 && removals > 0 {
119+ stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf(" %d additions and %d removals", additions, removals))
120+ } else if additions > 0 {
121+ stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf(" %d additions", additions))
122+ } else if removals > 0 {
123+ stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf(" %d removals", removals))
124+ }
125+ filePathStr := styles.BaseStyle.Foreground(styles.Forground).Render(filePath)
126+
127+ return styles.BaseStyle.
128+ Width(m.width).
129+ Render(
130+ lipgloss.JoinHorizontal(
131+ lipgloss.Left,
132+ filePathStr,
133+ stats,
134+ ),
135+ )
136+}
137+
138+func (m *sidebarCmp) modifiedFiles() string {
139+ modifiedFiles := styles.BaseStyle.Width(m.width).Foreground(styles.PrimaryColor).Bold(true).Render("Modified Files:")
140+
141+ // If no modified files, show a placeholder message
142+ if m.modFiles == nil || len(m.modFiles) == 0 {
143+ message := "No modified files"
144+ remainingWidth := m.width - lipgloss.Width(message)
145+ if remainingWidth > 0 {
146+ message += strings.Repeat(" ", remainingWidth)
147+ }
148+ return styles.BaseStyle.
149+ Width(m.width).
150+ Render(
151+ lipgloss.JoinVertical(
152+ lipgloss.Top,
153+ modifiedFiles,
154+ styles.BaseStyle.Foreground(styles.ForgroundDim).Render(message),
155+ ),
156+ )
157+ }
158+
159+ // Sort file paths alphabetically for consistent ordering
160+ var paths []string
161+ for path := range m.modFiles {
162+ paths = append(paths, path)
163+ }
164+ sort.Strings(paths)
165+
166+ // Create views for each file in sorted order
167+ var fileViews []string
168+ for _, path := range paths {
169+ stats := m.modFiles[path]
170+ fileViews = append(fileViews, m.modifiedFile(path, stats.additions, stats.removals))
171+ }
172+
173+ return styles.BaseStyle.
174+ Width(m.width).
175+ Render(
176+ lipgloss.JoinVertical(
177+ lipgloss.Top,
178+ modifiedFiles,
179+ lipgloss.JoinVertical(
180+ lipgloss.Left,
181+ fileViews...,
182+ ),
183+ ),
184+ )
185+}
186+
187+func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
188+ m.width = width
189+ m.height = height
190+ return nil
191+}
192+
193+func (m *sidebarCmp) GetSize() (int, int) {
194+ return m.width, m.height
195+}
196+
197+func NewSidebarCmp(session session.Session, history history.Service) tea.Model {
198+ return &sidebarCmp{
199+ session: session,
200+ history: history,
201+ }
202+}
203+
204+func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) {
205+ if m.history == nil || m.session.ID == "" {
206+ return
207+ }
208+
209+ // Get all latest files for this session
210+ latestFiles, err := m.history.ListLatestSessionFiles(ctx, m.session.ID)
211+ if err != nil {
212+ return
213+ }
214+
215+ // Get all files for this session (to find initial versions)
216+ allFiles, err := m.history.ListBySession(ctx, m.session.ID)
217+ if err != nil {
218+ return
219+ }
220+
221+ // Clear the existing map to rebuild it
222+ m.modFiles = make(map[string]struct {
223+ additions int
224+ removals int
225+ })
226+
227+ // Process each latest file
228+ for _, file := range latestFiles {
229+ // Skip if this is the initial version (no changes to show)
230+ if file.Version == history.InitialVersion {
231+ continue
232+ }
233+
234+ // Find the initial version for this specific file
235+ var initialVersion history.File
236+ for _, v := range allFiles {
237+ if v.Path == file.Path && v.Version == history.InitialVersion {
238+ initialVersion = v
239+ break
240+ }
241+ }
242+
243+ // Skip if we can't find the initial version
244+ if initialVersion.ID == "" {
245+ continue
246+ }
247+ if initialVersion.Content == file.Content {
248+ continue
249+ }
250+
251+ // Calculate diff between initial and latest version
252+ _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
253+
254+ // Only add to modified files if there are changes
255+ if additions > 0 || removals > 0 {
256+ // Remove working directory prefix from file path
257+ displayPath := file.Path
258+ workingDir := config.WorkingDirectory()
259+ displayPath = strings.TrimPrefix(displayPath, workingDir)
260+ displayPath = strings.TrimPrefix(displayPath, "/")
261+
262+ m.modFiles[displayPath] = struct {
263+ additions int
264+ removals int
265+ }{
266+ additions: additions,
267+ removals: removals,
268+ }
269+ }
270+ }
271+}
272+
273+func (m *sidebarCmp) processFileChanges(ctx context.Context, file history.File) {
274+ // Skip if this is the initial version (no changes to show)
275+ if file.Version == history.InitialVersion {
276+ return
277+ }
278+
279+ // Find the initial version for this file
280+ initialVersion, err := m.findInitialVersion(ctx, file.Path)
281+ if err != nil || initialVersion.ID == "" {
282+ return
283+ }
284+
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)
291+ return
292+ }
293+
294+ // Calculate diff between initial and latest version
295+ _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
296+
297+ // Only add to modified files if there are changes
298+ if additions > 0 || removals > 0 {
299+ displayPath := getDisplayPath(file.Path)
300+ m.modFiles[displayPath] = struct {
301+ additions int
302+ removals int
303+ }{
304+ additions: additions,
305+ removals: removals,
306+ }
307+ } else {
308+ // If no changes, remove from modified files
309+ displayPath := getDisplayPath(file.Path)
310+ delete(m.modFiles, displayPath)
311+ }
312+}
313+
314+// Helper function to find the initial version of a file
315+func (m *sidebarCmp) findInitialVersion(ctx context.Context, path string) (history.File, error) {
316+ // Get all versions of this file for the session
317+ fileVersions, err := m.history.ListBySession(ctx, m.session.ID)
318+ if err != nil {
319+ return history.File{}, err
320+ }
321+
322+ // Find the initial version
323+ for _, v := range fileVersions {
324+ if v.Path == path && v.Version == history.InitialVersion {
325+ return v, nil
326+ }
327+ }
328+
329+ return history.File{}, fmt.Errorf("initial version not found")
330+}
331+
332+// Helper function to get the display path for a file
333+func getDisplayPath(path string) string {
334+ workingDir := config.WorkingDirectory()
335+ displayPath := strings.TrimPrefix(path, workingDir)
336+ return strings.TrimPrefix(displayPath, "/")
337+}
internal/tui/components/core/button.godeleted−287
@@ -1,287 +0,0 @@
1−package core
2−
3−import (
4− "github.com/charmbracelet/bubbles/key"
5− tea "github.com/charmbracelet/bubbletea"
6− "github.com/charmbracelet/lipgloss"
7− "github.com/kujtimiihoxha/termai/internal/tui/styles"
8−)
9−
10−// ButtonKeyMap defines key bindings for the button component
11−type ButtonKeyMap struct {
12− Enter key.Binding
13−}
14−
15−// DefaultButtonKeyMap returns default key bindings for the button
16−func DefaultButtonKeyMap() ButtonKeyMap {
17− return ButtonKeyMap{
18− Enter: key.NewBinding(
19− key.WithKeys("enter"),
20− key.WithHelp("enter", "select"),
21− ),
22− }
23−}
24−
25−// ShortHelp returns keybinding help
26−func (k ButtonKeyMap) ShortHelp() []key.Binding {
27− return []key.Binding{k.Enter}
28−}
29−
30−// FullHelp returns full help info for keybindings
31−func (k ButtonKeyMap) FullHelp() [][]key.Binding {
32− return [][]key.Binding{
33− {k.Enter},
34− }
35−}
36−
37−// ButtonState represents the state of a button
38−type ButtonState int
39−
40−const (
41− // ButtonNormal is the default state
42− ButtonNormal ButtonState = iota
43− // ButtonHovered is when the button is focused/hovered
44− ButtonHovered
45− // ButtonPressed is when the button is being pressed
46− ButtonPressed
47− // ButtonDisabled is when the button is disabled
48− ButtonDisabled
49−)
50−
51−// ButtonVariant defines the visual style variant of a button
52−type ButtonVariant int
53−
54−const (
55− // ButtonPrimary uses primary color styling
56− ButtonPrimary ButtonVariant = iota
57− // ButtonSecondary uses secondary color styling
58− ButtonSecondary
59− // ButtonDanger uses danger/error color styling
60− ButtonDanger
61− // ButtonWarning uses warning color styling
62− ButtonWarning
63− // ButtonNeutral uses neutral color styling
64− ButtonNeutral
65−)
66−
67−// ButtonMsg is sent when a button is clicked
68−type ButtonMsg struct {
69− ID string
70− Payload any
71−}
72−
73−// ButtonCmp represents a clickable button component
74−type ButtonCmp struct {
75− id string
76− label string
77− width int
78− height int
79− state ButtonState
80− variant ButtonVariant
81− keyMap ButtonKeyMap
82− payload any
83− style lipgloss.Style
84− hoverStyle lipgloss.Style
85−}
86−
87−// NewButtonCmp creates a new button component
88−func NewButtonCmp(id, label string) *ButtonCmp {
89− b := &ButtonCmp{
90− id: id,
91− label: label,
92− state: ButtonNormal,
93− variant: ButtonPrimary,
94− keyMap: DefaultButtonKeyMap(),
95− width: len(label) + 4, // add some padding
96− height: 1,
97− }
98− b.updateStyles()
99− return b
100−}
101−
102−// WithVariant sets the button variant
103−func (b *ButtonCmp) WithVariant(variant ButtonVariant) *ButtonCmp {
104− b.variant = variant
105− b.updateStyles()
106− return b
107−}
108−
109−// WithPayload sets the payload sent with button events
110−func (b *ButtonCmp) WithPayload(payload any) *ButtonCmp {
111− b.payload = payload
112− return b
113−}
114−
115−// WithWidth sets a custom width
116−func (b *ButtonCmp) WithWidth(width int) *ButtonCmp {
117− b.width = width
118− b.updateStyles()
119− return b
120−}
121−
122−// updateStyles recalculates styles based on current state and variant
123−func (b *ButtonCmp) updateStyles() {
124− // Base styles
125− b.style = styles.Regular.
126− Padding(0, 1).
127− Width(b.width).
128− Align(lipgloss.Center).
129− BorderStyle(lipgloss.RoundedBorder())
130−
131− b.hoverStyle = b.style.
132− Bold(true)
133−
134− // Variant-specific styling
135− switch b.variant {
136− case ButtonPrimary:
137− b.style = b.style.
138− Foreground(styles.Base).
139− Background(styles.Primary).
140− BorderForeground(styles.Primary)
141−
142− b.hoverStyle = b.hoverStyle.
143− Foreground(styles.Base).
144− Background(styles.Blue).
145− BorderForeground(styles.Blue)
146−
147− case ButtonSecondary:
148− b.style = b.style.
149− Foreground(styles.Base).
150− Background(styles.Secondary).
151− BorderForeground(styles.Secondary)
152−
153− b.hoverStyle = b.hoverStyle.
154− Foreground(styles.Base).
155− Background(styles.Mauve).
156− BorderForeground(styles.Mauve)
157−
158− case ButtonDanger:
159− b.style = b.style.
160− Foreground(styles.Base).
161− Background(styles.Error).
162− BorderForeground(styles.Error)
163−
164− b.hoverStyle = b.hoverStyle.
165− Foreground(styles.Base).
166− Background(styles.Red).
167− BorderForeground(styles.Red)
168−
169− case ButtonWarning:
170− b.style = b.style.
171− Foreground(styles.Text).
172− Background(styles.Warning).
173− BorderForeground(styles.Warning)
174−
175− b.hoverStyle = b.hoverStyle.
176− Foreground(styles.Text).
177− Background(styles.Peach).
178− BorderForeground(styles.Peach)
179−
180− case ButtonNeutral:
181− b.style = b.style.
182− Foreground(styles.Text).
183− Background(styles.Grey).
184− BorderForeground(styles.Grey)
185−
186− b.hoverStyle = b.hoverStyle.
187− Foreground(styles.Text).
188− Background(styles.DarkGrey).
189− BorderForeground(styles.DarkGrey)
190− }
191−
192− // Disabled style override
193− if b.state == ButtonDisabled {
194− b.style = b.style.
195− Foreground(styles.SubText0).
196− Background(styles.LightGrey).
197− BorderForeground(styles.LightGrey)
198− }
199−}
200−
201−// SetSize sets the button size
202−func (b *ButtonCmp) SetSize(width, height int) {
203− b.width = width
204− b.height = height
205− b.updateStyles()
206−}
207−
208−// Focus sets the button to focused state
209−func (b *ButtonCmp) Focus() tea.Cmd {
210− if b.state != ButtonDisabled {
211− b.state = ButtonHovered
212− }
213− return nil
214−}
215−
216−// Blur sets the button to normal state
217−func (b *ButtonCmp) Blur() tea.Cmd {
218− if b.state != ButtonDisabled {
219− b.state = ButtonNormal
220− }
221− return nil
222−}
223−
224−// Disable sets the button to disabled state
225−func (b *ButtonCmp) Disable() {
226− b.state = ButtonDisabled
227− b.updateStyles()
228−}
229−
230−// Enable enables the button if disabled
231−func (b *ButtonCmp) Enable() {
232− if b.state == ButtonDisabled {
233− b.state = ButtonNormal
234− b.updateStyles()
235− }
236−}
237−
238−// IsDisabled returns whether the button is disabled
239−func (b *ButtonCmp) IsDisabled() bool {
240− return b.state == ButtonDisabled
241−}
242−
243−// IsFocused returns whether the button is focused
244−func (b *ButtonCmp) IsFocused() bool {
245− return b.state == ButtonHovered
246−}
247−
248−// Init initializes the button
249−func (b *ButtonCmp) Init() tea.Cmd {
250− return nil
251−}
252−
253−// Update handles messages and user input
254−func (b *ButtonCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
255− // Skip updates if disabled
256− if b.state == ButtonDisabled {
257− return b, nil
258− }
259−
260− switch msg := msg.(type) {
261− case tea.KeyMsg:
262− // Handle key presses when focused
263− if b.state == ButtonHovered {
264− switch {
265− case key.Matches(msg, b.keyMap.Enter):
266− b.state = ButtonPressed
267− return b, func() tea.Msg {
268− return ButtonMsg{
269− ID: b.id,
270− Payload: b.payload,
271− }
272− }
273− }
274− }
275− }
276−
277− return b, nil
278−}
279−
280−// View renders the button
281−func (b *ButtonCmp) View() string {
282− if b.state == ButtonHovered || b.state == ButtonPressed {
283− return b.hoverStyle.Render(b.label)
284− }
285− return b.style.Render(b.label)
286−}
287−
internal/tui/components/core/dialog.godeleted−117
@@ -1,117 +0,0 @@
1−package core
2−
3−import (
4− "github.com/charmbracelet/bubbles/key"
5− tea "github.com/charmbracelet/bubbletea"
6− "github.com/charmbracelet/lipgloss"
7− "github.com/kujtimiihoxha/termai/internal/tui/layout"
8− "github.com/kujtimiihoxha/termai/internal/tui/util"
9−)
10−
11−type SizeableModel interface {
12− tea.Model
13− layout.Sizeable
14−}
15−
16−type DialogMsg struct {
17− Content SizeableModel
18− WidthRatio float64
19− HeightRatio float64
20−
21− MinWidth int
22− MinHeight int
23−}
24−
25−type DialogCloseMsg struct{}
26−
27−type KeyBindings struct {
28− Return key.Binding
29−}
30−
31−var keys = KeyBindings{
32− Return: key.NewBinding(
33− key.WithKeys("esc"),
34− key.WithHelp("esc", "close"),
35− ),
36−}
37−
38−type DialogCmp interface {
39− tea.Model
40− layout.Bindings
41−}
42−
43−type dialogCmp struct {
44− content SizeableModel
45− screenWidth int
46− screenHeight int
47−
48− widthRatio float64
49− heightRatio float64
50−
51− minWidth int
52− minHeight int
53−
54− width int
55− height int
56−}
57−
58−func (d *dialogCmp) Init() tea.Cmd {
59− return nil
60−}
61−
62−func (d *dialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
63− switch msg := msg.(type) {
64− case tea.WindowSizeMsg:
65− d.screenWidth = msg.Width
66− d.screenHeight = msg.Height
67− d.width = max(int(float64(d.screenWidth)*d.widthRatio), d.minWidth)
68− d.height = max(int(float64(d.screenHeight)*d.heightRatio), d.minHeight)
69− if d.content != nil {
70− d.content.SetSize(d.width, d.height)
71− }
72− return d, nil
73− case DialogMsg:
74− d.content = msg.Content
75− d.widthRatio = msg.WidthRatio
76− d.heightRatio = msg.HeightRatio
77− d.minWidth = msg.MinWidth
78− d.minHeight = msg.MinHeight
79− d.width = max(int(float64(d.screenWidth)*d.widthRatio), d.minWidth)
80− d.height = max(int(float64(d.screenHeight)*d.heightRatio), d.minHeight)
81− if d.content != nil {
82− d.content.SetSize(d.width, d.height)
83− }
84− case DialogCloseMsg:
85− d.content = nil
86− return d, nil
87− case tea.KeyMsg:
88− if key.Matches(msg, keys.Return) {
89− return d, util.CmdHandler(DialogCloseMsg{})
90− }
91− }
92− if d.content != nil {
93− u, cmd := d.content.Update(msg)
94− d.content = u.(SizeableModel)
95− return d, cmd
96− }
97− return d, nil
98−}
99−
100−func (d *dialogCmp) BindingKeys() []key.Binding {
101− bindings := []key.Binding{keys.Return}
102− if d.content == nil {
103− return bindings
104− }
105− if c, ok := d.content.(layout.Bindings); ok {
106− return append(bindings, c.BindingKeys()...)
107− }
108− return bindings
109−}
110−
111−func (d *dialogCmp) View() string {
112− return lipgloss.NewStyle().Width(d.width).Height(d.height).Render(d.content.View())
113−}
114−
115−func NewDialogCmp() DialogCmp {
116− return &dialogCmp{}
117−}
internal/tui/components/core/help.godeleted−119
@@ -1,119 +0,0 @@
1−package core
2−
3−import (
4− "strings"
5−
6− "github.com/charmbracelet/bubbles/key"
7− tea "github.com/charmbracelet/bubbletea"
8− "github.com/charmbracelet/lipgloss"
9− "github.com/kujtimiihoxha/termai/internal/tui/styles"
10−)
11−
12−type HelpCmp interface {
13− tea.Model
14− SetBindings(bindings []key.Binding)
15− Height() int
16−}
17−
18−const (
19− helpWidgetHeight = 12
20−)
21−
22−type helpCmp struct {
23− width int
24− bindings []key.Binding
25−}
26−
27−func (h *helpCmp) Init() tea.Cmd {
28− return nil
29−}
30−
31−func (h *helpCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
32− switch msg := msg.(type) {
33− case tea.WindowSizeMsg:
34− h.width = msg.Width
35− }
36− return h, nil
37−}
38−
39−func (h *helpCmp) View() string {
40− helpKeyStyle := styles.Bold.Foreground(styles.Rosewater).Margin(0, 1, 0, 0)
41− helpDescStyle := styles.Regular.Foreground(styles.Flamingo)
42− // Compile list of bindings to render
43− bindings := removeDuplicateBindings(h.bindings)
44− // Enumerate through each group of bindings, populating a series of
45− // pairs of columns, one for keys, one for descriptions
46− var (
47− pairs []string
48− width int
49− rows = helpWidgetHeight - 2
50− )
51− for i := 0; i < len(bindings); i += rows {
52− var (
53− keys []string
54− descs []string
55− )
56− for j := i; j < min(i+rows, len(bindings)); j++ {
57− keys = append(keys, helpKeyStyle.Render(bindings[j].Help().Key))
58− descs = append(descs, helpDescStyle.Render(bindings[j].Help().Desc))
59− }
60− // Render pair of columns; beyond the first pair, render a three space
61− // left margin, in order to visually separate the pairs.
62− var cols []string
63− if len(pairs) > 0 {
64− cols = []string{" "}
65− }
66− cols = append(cols,
67− strings.Join(keys, "\n"),
68− strings.Join(descs, "\n"),
69− )
70−
71− pair := lipgloss.JoinHorizontal(lipgloss.Top, cols...)
72− // check whether it exceeds the maximum width avail (the width of the
73− // terminal, subtracting 2 for the borders).
74− width += lipgloss.Width(pair)
75− if width > h.width-2 {
76− break
77− }
78− pairs = append(pairs, pair)
79− }
80−
81− // Join pairs of columns and enclose in a border
82− content := lipgloss.JoinHorizontal(lipgloss.Top, pairs...)
83− return styles.DoubleBorder.Height(rows).PaddingLeft(1).Width(h.width - 2).Render(content)
84−}
85−
86−func removeDuplicateBindings(bindings []key.Binding) []key.Binding {
87− seen := make(map[string]struct{})
88− result := make([]key.Binding, 0, len(bindings))
89−
90− // Process bindings in reverse order
91− for i := len(bindings) - 1; i >= 0; i-- {
92− b := bindings[i]
93− k := strings.Join(b.Keys(), " ")
94− if _, ok := seen[k]; ok {
95− // duplicate, skip
96− continue
97− }
98− seen[k] = struct{}{}
99− // Add to the beginning of result to maintain original order
100− result = append([]key.Binding{b}, result...)
101− }
102−
103− return result
104−}
105−
106−func (h *helpCmp) SetBindings(bindings []key.Binding) {
107− h.bindings = bindings
108−}
109−
110−func (h helpCmp) Height() int {
111− return helpWidgetHeight
112−}
113−
114−func NewHelpCmp() HelpCmp {
115− return &helpCmp{
116− width: 0,
117− bindings: make([]key.Binding, 0),
118− }
119−}
internal/tui/components/core/status.go+171−21
@@ -1,21 +1,34 @@
11package core
22
33import (
4+ "fmt"
5+ "strings"
46 "time"
57
68 tea "github.com/charmbracelet/bubbletea"
79 "github.com/charmbracelet/lipgloss"
8− "github.com/kujtimiihoxha/termai/internal/config"
9− "github.com/kujtimiihoxha/termai/internal/llm/models"
10− "github.com/kujtimiihoxha/termai/internal/tui/styles"
11− "github.com/kujtimiihoxha/termai/internal/tui/util"
12− "github.com/kujtimiihoxha/termai/internal/version"
10+ "github.com/kujtimiihoxha/opencode/internal/config"
11+ "github.com/kujtimiihoxha/opencode/internal/llm/models"
12+ "github.com/kujtimiihoxha/opencode/internal/lsp"
13+ "github.com/kujtimiihoxha/opencode/internal/lsp/protocol"
14+ "github.com/kujtimiihoxha/opencode/internal/pubsub"
15+ "github.com/kujtimiihoxha/opencode/internal/session"
16+ "github.com/kujtimiihoxha/opencode/internal/tui/components/chat"
17+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
18+ "github.com/kujtimiihoxha/opencode/internal/tui/util"
1319)
1420
21+type StatusCmp interface {
22+ tea.Model
23+ SetHelpMsg(string)
24+}
25+
1526type statusCmp struct {
1627 info util.InfoMsg
1728 width int
1829 messageTTL time.Duration
30+ lspClients map[string]*lsp.Client
31+ session session.Session
1932}
2033
2134// clearMessageCmd is a command that clears status messages after a timeout
@@ -34,6 +47,16 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
3447 case tea.WindowSizeMsg:
3548 m.width = msg.Width
3649 return m, nil
50+ case chat.SessionSelectedMsg:
51+ m.session = msg
52+ case chat.SessionClearedMsg:
53+ m.session = session.Session{}
54+ case pubsub.Event[session.Session]:
55+ if msg.Type == pubsub.UpdatedEvent {
56+ if m.session.ID == msg.Payload.ID {
57+ m.session = msg.Payload
58+ }
59+ }
3760 case util.InfoMsg:
3861 m.info = msg
3962 ttl := msg.TTL
@@ -47,20 +70,53 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
4770 return m, nil
4871}
4972
50−var (
51− versionWidget = styles.Padded.Background(styles.DarkGrey).Foreground(styles.Text).Render(version.Version)
52− helpWidget = styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render("? help")
53−)
73+var helpWidget = styles.Padded.Background(styles.ForgroundMid).Foreground(styles.BackgroundDarker).Bold(true).Render("ctrl+? help")
74+
75+func formatTokensAndCost(tokens int64, cost float64) string {
76+ // Format tokens in human-readable format (e.g., 110K, 1.2M)
77+ var formattedTokens string
78+ switch {
79+ case tokens >= 1_000_000:
80+ formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
81+ case tokens >= 1_000:
82+ formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
83+ default:
84+ formattedTokens = fmt.Sprintf("%d", tokens)
85+ }
86+
87+ // Remove .0 suffix if present
88+ if strings.HasSuffix(formattedTokens, ".0K") {
89+ formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
90+ }
91+ if strings.HasSuffix(formattedTokens, ".0M") {
92+ formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
93+ }
94+
95+ // Format cost with $ symbol and 2 decimal places
96+ formattedCost := fmt.Sprintf("$%.2f", cost)
97+
98+ return fmt.Sprintf("Tokens: %s, Cost: %s", formattedTokens, formattedCost)
99+}
54100
55101func (m statusCmp) View() string {
56− status := styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render("? help")
102+ status := helpWidget
103+ if m.session.ID != "" {
104+ tokens := formatTokensAndCost(m.session.PromptTokens+m.session.CompletionTokens, m.session.Cost)
105+ tokensStyle := styles.Padded.
106+ Background(styles.Forground).
107+ Foreground(styles.BackgroundDim).
108+ Render(tokens)
109+ status += tokensStyle
110+ }
111+
112+ diagnostics := styles.Padded.Background(styles.BackgroundDarker).Render(m.projectDiagnostics())
57113 if m.info.Msg != "" {
58114 infoStyle := styles.Padded.
59115 Foreground(styles.Base).
60− Width(m.availableFooterMsgWidth())
116+ Width(m.availableFooterMsgWidth(diagnostics))
61117 switch m.info.Type {
62118 case util.InfoTypeInfo:
63− infoStyle = infoStyle.Background(styles.Blue)
119+ infoStyle = infoStyle.Background(styles.BorderColor)
64120 case util.InfoTypeWarn:
65121 infoStyle = infoStyle.Background(styles.Peach)
66122 case util.InfoTypeError:
@@ -68,7 +124,7 @@ func (m statusCmp) View() string {
68124 }
69125 // Truncate message if it's longer than available width
70126 msg := m.info.Msg
71− availWidth := m.availableFooterMsgWidth() - 10
127+ availWidth := m.availableFooterMsgWidth(diagnostics) - 10
72128 if len(msg) > availWidth && availWidth > 0 {
73129 msg = msg[:availWidth] + "..."
74130 }
@@ -76,27 +132,121 @@ func (m statusCmp) View() string {
76132 } else {
77133 status += styles.Padded.
78134 Foreground(styles.Base).
79− Background(styles.LightGrey).
80− Width(m.availableFooterMsgWidth()).
135+ Background(styles.BackgroundDim).
136+ Width(m.availableFooterMsgWidth(diagnostics)).
81137 Render("")
82138 }
139+
140+ status += diagnostics
83141 status += m.model()
84− status += versionWidget
85142 return status
86143}
87144
88−func (m statusCmp) availableFooterMsgWidth() int {
89− // -2 to accommodate padding
90− return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(versionWidget)-lipgloss.Width(m.model()))
145+func (m *statusCmp) projectDiagnostics() string {
146+ // Check if any LSP server is still initializing
147+ initializing := false
148+ for _, client := range m.lspClients {
149+ if client.GetServerState() == lsp.StateStarting {
150+ initializing = true
151+ break
152+ }
153+ }
154+
155+ // If any server is initializing, show that status
156+ if initializing {
157+ return lipgloss.NewStyle().
158+ Background(styles.BackgroundDarker).
159+ Foreground(styles.Peach).
160+ Render(fmt.Sprintf("%s Initializing LSP...", styles.SpinnerIcon))
161+ }
162+
163+ errorDiagnostics := []protocol.Diagnostic{}
164+ warnDiagnostics := []protocol.Diagnostic{}
165+ hintDiagnostics := []protocol.Diagnostic{}
166+ infoDiagnostics := []protocol.Diagnostic{}
167+ for _, client := range m.lspClients {
168+ for _, d := range client.GetDiagnostics() {
169+ for _, diag := range d {
170+ switch diag.Severity {
171+ case protocol.SeverityError:
172+ errorDiagnostics = append(errorDiagnostics, diag)
173+ case protocol.SeverityWarning:
174+ warnDiagnostics = append(warnDiagnostics, diag)
175+ case protocol.SeverityHint:
176+ hintDiagnostics = append(hintDiagnostics, diag)
177+ case protocol.SeverityInformation:
178+ infoDiagnostics = append(infoDiagnostics, diag)
179+ }
180+ }
181+ }
182+ }
183+
184+ if len(errorDiagnostics) == 0 && len(warnDiagnostics) == 0 && len(hintDiagnostics) == 0 && len(infoDiagnostics) == 0 {
185+ return "No diagnostics"
186+ }
187+
188+ diagnostics := []string{}
189+
190+ if len(errorDiagnostics) > 0 {
191+ errStr := lipgloss.NewStyle().
192+ Background(styles.BackgroundDarker).
193+ Foreground(styles.Error).
194+ Render(fmt.Sprintf("%s %d", styles.ErrorIcon, len(errorDiagnostics)))
195+ diagnostics = append(diagnostics, errStr)
196+ }
197+ if len(warnDiagnostics) > 0 {
198+ warnStr := lipgloss.NewStyle().
199+ Background(styles.BackgroundDarker).
200+ Foreground(styles.Warning).
201+ Render(fmt.Sprintf("%s %d", styles.WarningIcon, len(warnDiagnostics)))
202+ diagnostics = append(diagnostics, warnStr)
203+ }
204+ if len(hintDiagnostics) > 0 {
205+ hintStr := lipgloss.NewStyle().
206+ Background(styles.BackgroundDarker).
207+ Foreground(styles.Text).
208+ Render(fmt.Sprintf("%s %d", styles.HintIcon, len(hintDiagnostics)))
209+ diagnostics = append(diagnostics, hintStr)
210+ }
211+ if len(infoDiagnostics) > 0 {
212+ infoStr := lipgloss.NewStyle().
213+ Background(styles.BackgroundDarker).
214+ Foreground(styles.Peach).
215+ Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics)))
216+ diagnostics = append(diagnostics, infoStr)
217+ }
218+
219+ return strings.Join(diagnostics, " ")
220+}
221+
222+func (m statusCmp) availableFooterMsgWidth(diagnostics string) int {
223+ tokens := ""
224+ tokensWidth := 0
225+ if m.session.ID != "" {
226+ tokens = formatTokensAndCost(m.session.PromptTokens+m.session.CompletionTokens, m.session.Cost)
227+ tokensWidth = lipgloss.Width(tokens) + 2
228+ }
229+ return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-tokensWidth)
91230}
92231
93232func (m statusCmp) model() string {
94− model := models.SupportedModels[config.Get().Model.Coder]
233+ cfg := config.Get()
234+
235+ coder, ok := cfg.Agents[config.AgentCoder]
236+ if !ok {
237+ return "Unknown"
238+ }
239+ model := models.SupportedModels[coder.Model]
95240 return styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render(model.Name)
96241}
97242
98−func NewStatusCmp() tea.Model {
243+func (m statusCmp) SetHelpMsg(s string) {
244+ helpWidget = styles.Padded.Background(styles.Forground).Foreground(styles.BackgroundDarker).Bold(true).Render(s)
245+}
246+
247+func NewStatusCmp(lspClients map[string]*lsp.Client) StatusCmp {
99248 return &statusCmp{
100249 messageTTL: 10 * time.Second,
250+ lspClients: lspClients,
101251 }
102252}
internal/tui/components/dialog/commands.gonew file+247
@@ -0,0 +1,247 @@
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/tui/layout"
8+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
9+ "github.com/kujtimiihoxha/opencode/internal/tui/util"
10+)
11+
12+// Command represents a command that can be executed
13+type Command struct {
14+ ID string
15+ Title string
16+ Description string
17+ Handler func(cmd Command) tea.Cmd
18+}
19+
20+// CommandSelectedMsg is sent when a command is selected
21+type CommandSelectedMsg struct {
22+ Command Command
23+}
24+
25+// CloseCommandDialogMsg is sent when the command dialog is closed
26+type CloseCommandDialogMsg struct{}
27+
28+// CommandDialog interface for the command selection dialog
29+type CommandDialog interface {
30+ tea.Model
31+ layout.Bindings
32+ SetCommands(commands []Command)
33+ SetSelectedCommand(commandID string)
34+}
35+
36+type commandDialogCmp struct {
37+ commands []Command
38+ selectedIdx int
39+ width int
40+ height int
41+ selectedCommandID string
42+}
43+
44+type commandKeyMap struct {
45+ Up key.Binding
46+ Down key.Binding
47+ Enter key.Binding
48+ Escape key.Binding
49+ J key.Binding
50+ K key.Binding
51+}
52+
53+var commandKeys = commandKeyMap{
54+ Up: key.NewBinding(
55+ key.WithKeys("up"),
56+ key.WithHelp("↑", "previous command"),
57+ ),
58+ Down: key.NewBinding(
59+ key.WithKeys("down"),
60+ key.WithHelp("↓", "next command"),
61+ ),
62+ Enter: key.NewBinding(
63+ key.WithKeys("enter"),
64+ key.WithHelp("enter", "select command"),
65+ ),
66+ Escape: key.NewBinding(
67+ key.WithKeys("esc"),
68+ key.WithHelp("esc", "close"),
69+ ),
70+ J: key.NewBinding(
71+ key.WithKeys("j"),
72+ key.WithHelp("j", "next command"),
73+ ),
74+ K: key.NewBinding(
75+ key.WithKeys("k"),
76+ key.WithHelp("k", "previous command"),
77+ ),
78+}
79+
80+func (c *commandDialogCmp) Init() tea.Cmd {
81+ return nil
82+}
83+
84+func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
85+ switch msg := msg.(type) {
86+ case tea.KeyMsg:
87+ switch {
88+ case key.Matches(msg, commandKeys.Up) || key.Matches(msg, commandKeys.K):
89+ if c.selectedIdx > 0 {
90+ c.selectedIdx--
91+ }
92+ return c, nil
93+ case key.Matches(msg, commandKeys.Down) || key.Matches(msg, commandKeys.J):
94+ if c.selectedIdx < len(c.commands)-1 {
95+ c.selectedIdx++
96+ }
97+ return c, nil
98+ case key.Matches(msg, commandKeys.Enter):
99+ if len(c.commands) > 0 {
100+ return c, util.CmdHandler(CommandSelectedMsg{
101+ Command: c.commands[c.selectedIdx],
102+ })
103+ }
104+ case key.Matches(msg, commandKeys.Escape):
105+ return c, util.CmdHandler(CloseCommandDialogMsg{})
106+ }
107+ case tea.WindowSizeMsg:
108+ c.width = msg.Width
109+ c.height = msg.Height
110+ }
111+ return c, nil
112+}
113+
114+func (c *commandDialogCmp) View() string {
115+ if len(c.commands) == 0 {
116+ return styles.BaseStyle.Padding(1, 2).
117+ Border(lipgloss.RoundedBorder()).
118+ BorderBackground(styles.Background).
119+ BorderForeground(styles.ForgroundDim).
120+ Width(40).
121+ Render("No commands available")
122+ }
123+
124+ // Calculate max width needed for command titles
125+ maxWidth := 40 // Minimum width
126+ for _, cmd := range c.commands {
127+ if len(cmd.Title) > maxWidth-4 { // Account for padding
128+ maxWidth = len(cmd.Title) + 4
129+ }
130+ if len(cmd.Description) > maxWidth-4 {
131+ maxWidth = len(cmd.Description) + 4
132+ }
133+ }
134+
135+ // Limit height to avoid taking up too much screen space
136+ maxVisibleCommands := min(10, len(c.commands))
137+
138+ // Build the command list
139+ commandItems := make([]string, 0, maxVisibleCommands)
140+ startIdx := 0
141+
142+ // If we have more commands than can be displayed, adjust the start index
143+ if len(c.commands) > maxVisibleCommands {
144+ // Center the selected item when possible
145+ halfVisible := maxVisibleCommands / 2
146+ if c.selectedIdx >= halfVisible && c.selectedIdx < len(c.commands)-halfVisible {
147+ startIdx = c.selectedIdx - halfVisible
148+ } else if c.selectedIdx >= len(c.commands)-halfVisible {
149+ startIdx = len(c.commands) - maxVisibleCommands
150+ }
151+ }
152+
153+ endIdx := min(startIdx+maxVisibleCommands, len(c.commands))
154+
155+ for i := startIdx; i < endIdx; i++ {
156+ cmd := c.commands[i]
157+ itemStyle := styles.BaseStyle.Width(maxWidth)
158+ descStyle := styles.BaseStyle.Width(maxWidth).Foreground(styles.ForgroundDim)
159+
160+ if i == c.selectedIdx {
161+ itemStyle = itemStyle.
162+ Background(styles.PrimaryColor).
163+ Foreground(styles.Background).
164+ Bold(true)
165+ descStyle = descStyle.
166+ Background(styles.PrimaryColor).
167+ Foreground(styles.Background)
168+ }
169+
170+ title := itemStyle.Padding(0, 1).Render(cmd.Title)
171+ description := ""
172+ if cmd.Description != "" {
173+ description = descStyle.Padding(0, 1).Render(cmd.Description)
174+ commandItems = append(commandItems, lipgloss.JoinVertical(lipgloss.Left, title, description))
175+ } else {
176+ commandItems = append(commandItems, title)
177+ }
178+ }
179+
180+ title := styles.BaseStyle.
181+ Foreground(styles.PrimaryColor).
182+ Bold(true).
183+ Width(maxWidth).
184+ Padding(0, 1).
185+ Render("Commands")
186+
187+ content := lipgloss.JoinVertical(
188+ lipgloss.Left,
189+ title,
190+ styles.BaseStyle.Width(maxWidth).Render(""),
191+ styles.BaseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, commandItems...)),
192+ styles.BaseStyle.Width(maxWidth).Render(""),
193+ styles.BaseStyle.Width(maxWidth).Padding(0, 1).Foreground(styles.ForgroundDim).Render("↑/k: up ↓/j: down enter: select esc: cancel"),
194+ )
195+
196+ return styles.BaseStyle.Padding(1, 2).
197+ Border(lipgloss.RoundedBorder()).
198+ BorderBackground(styles.Background).
199+ BorderForeground(styles.ForgroundDim).
200+ Width(lipgloss.Width(content) + 4).
201+ Render(content)
202+}
203+
204+func (c *commandDialogCmp) BindingKeys() []key.Binding {
205+ return layout.KeyMapToSlice(commandKeys)
206+}
207+
208+func (c *commandDialogCmp) SetCommands(commands []Command) {
209+ c.commands = commands
210+
211+ // If we have a selected command ID, find its index
212+ if c.selectedCommandID != "" {
213+ for i, cmd := range commands {
214+ if cmd.ID == c.selectedCommandID {
215+ c.selectedIdx = i
216+ return
217+ }
218+ }
219+ }
220+
221+ // Default to first command if selected not found
222+ c.selectedIdx = 0
223+}
224+
225+func (c *commandDialogCmp) SetSelectedCommand(commandID string) {
226+ c.selectedCommandID = commandID
227+
228+ // Update the selected index if commands are already loaded
229+ if len(c.commands) > 0 {
230+ for i, cmd := range c.commands {
231+ if cmd.ID == commandID {
232+ c.selectedIdx = i
233+ return
234+ }
235+ }
236+ }
237+}
238+
239+// NewCommandDialogCmp creates a new command selection dialog
240+func NewCommandDialogCmp() CommandDialog {
241+ return &commandDialogCmp{
242+ commands: []Command{},
243+ selectedIdx: 0,
244+ selectedCommandID: "",
245+ }
246+}
247+
internal/tui/components/dialog/help.gonew file+182
@@ -0,0 +1,182 @@
1+package dialog
2+
3+import (
4+ "strings"
5+
6+ "github.com/charmbracelet/bubbles/key"
7+ tea "github.com/charmbracelet/bubbletea"
8+ "github.com/charmbracelet/lipgloss"
9+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
10+)
11+
12+type helpCmp struct {
13+ width int
14+ height int
15+ keys []key.Binding
16+}
17+
18+func (h *helpCmp) Init() tea.Cmd {
19+ return nil
20+}
21+
22+func (h *helpCmp) SetBindings(k []key.Binding) {
23+ h.keys = k
24+}
25+
26+func (h *helpCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
27+ switch msg := msg.(type) {
28+ case tea.WindowSizeMsg:
29+ h.width = 90
30+ h.height = msg.Height
31+ }
32+ return h, nil
33+}
34+
35+func removeDuplicateBindings(bindings []key.Binding) []key.Binding {
36+ seen := make(map[string]struct{})
37+ result := make([]key.Binding, 0, len(bindings))
38+
39+ // Process bindings in reverse order
40+ for i := len(bindings) - 1; i >= 0; i-- {
41+ b := bindings[i]
42+ k := strings.Join(b.Keys(), " ")
43+ if _, ok := seen[k]; ok {
44+ // duplicate, skip
45+ continue
46+ }
47+ seen[k] = struct{}{}
48+ // Add to the beginning of result to maintain original order
49+ result = append([]key.Binding{b}, result...)
50+ }
51+
52+ return result
53+}
54+
55+func (h *helpCmp) render() string {
56+ helpKeyStyle := styles.Bold.Background(styles.Background).Foreground(styles.Forground).Padding(0, 1, 0, 0)
57+ helpDescStyle := styles.Regular.Background(styles.Background).Foreground(styles.ForgroundMid)
58+ // Compile list of bindings to render
59+ bindings := removeDuplicateBindings(h.keys)
60+ // Enumerate through each group of bindings, populating a series of
61+ // pairs of columns, one for keys, one for descriptions
62+ var (
63+ pairs []string
64+ width int
65+ rows = 14 - 2
66+ )
67+ for i := 0; i < len(bindings); i += rows {
68+ var (
69+ keys []string
70+ descs []string
71+ )
72+ for j := i; j < min(i+rows, len(bindings)); j++ {
73+ keys = append(keys, helpKeyStyle.Render(bindings[j].Help().Key))
74+ descs = append(descs, helpDescStyle.Render(bindings[j].Help().Desc))
75+ }
76+ // Render pair of columns; beyond the first pair, render a three space
77+ // left margin, in order to visually separate the pairs.
78+ var cols []string
79+ if len(pairs) > 0 {
80+ cols = []string{styles.BaseStyle.Render(" ")}
81+ }
82+
83+ maxDescWidth := 0
84+ for _, desc := range descs {
85+ if maxDescWidth < lipgloss.Width(desc) {
86+ maxDescWidth = lipgloss.Width(desc)
87+ }
88+ }
89+ for i := range descs {
90+ remainingWidth := maxDescWidth - lipgloss.Width(descs[i])
91+ if remainingWidth > 0 {
92+ descs[i] = descs[i] + styles.BaseStyle.Render(strings.Repeat(" ", remainingWidth))
93+ }
94+ }
95+ maxKeyWidth := 0
96+ for _, key := range keys {
97+ if maxKeyWidth < lipgloss.Width(key) {
98+ maxKeyWidth = lipgloss.Width(key)
99+ }
100+ }
101+ for i := range keys {
102+ remainingWidth := maxKeyWidth - lipgloss.Width(keys[i])
103+ if remainingWidth > 0 {
104+ keys[i] = keys[i] + styles.BaseStyle.Render(strings.Repeat(" ", remainingWidth))
105+ }
106+ }
107+
108+ cols = append(cols,
109+ strings.Join(keys, "\n"),
110+ strings.Join(descs, "\n"),
111+ )
112+
113+ pair := styles.BaseStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, cols...))
114+ // check whether it exceeds the maximum width avail (the width of the
115+ // terminal, subtracting 2 for the borders).
116+ width += lipgloss.Width(pair)
117+ if width > h.width-2 {
118+ break
119+ }
120+ pairs = append(pairs, pair)
121+ }
122+
123+ // https://github.com/charmbracelet/lipgloss/issues/209
124+ if len(pairs) > 1 {
125+ prefix := pairs[:len(pairs)-1]
126+ lastPair := pairs[len(pairs)-1]
127+ prefix = append(prefix, lipgloss.Place(
128+ lipgloss.Width(lastPair), // width
129+ lipgloss.Height(prefix[0]), // height
130+ lipgloss.Left, // x
131+ lipgloss.Top, // y
132+ lastPair, // content
133+ lipgloss.WithWhitespaceBackground(styles.Background), // background
134+ ))
135+ content := styles.BaseStyle.Width(h.width).Render(
136+ lipgloss.JoinHorizontal(
137+ lipgloss.Top,
138+ prefix...,
139+ ),
140+ )
141+ return content
142+ }
143+ // Join pairs of columns and enclose in a border
144+ content := styles.BaseStyle.Width(h.width).Render(
145+ lipgloss.JoinHorizontal(
146+ lipgloss.Top,
147+ pairs...,
148+ ),
149+ )
150+ return content
151+}
152+
153+func (h *helpCmp) View() string {
154+ content := h.render()
155+ header := styles.BaseStyle.
156+ Bold(true).
157+ Width(lipgloss.Width(content)).
158+ Foreground(styles.PrimaryColor).
159+ Render("Keyboard Shortcuts")
160+
161+ return styles.BaseStyle.Padding(1).
162+ Border(lipgloss.RoundedBorder()).
163+ BorderForeground(styles.ForgroundDim).
164+ Width(h.width).
165+ BorderBackground(styles.Background).
166+ Render(
167+ lipgloss.JoinVertical(lipgloss.Center,
168+ header,
169+ styles.BaseStyle.Render(strings.Repeat(" ", lipgloss.Width(header))),
170+ content,
171+ ),
172+ )
173+}
174+
175+type HelpCmp interface {
176+ tea.Model
177+ SetBindings([]key.Binding)
178+}
179+
180+func NewHelpCmp() HelpCmp {
181+ return &helpCmp{}
182+}
internal/tui/components/dialog/init.gonew file+191
@@ -0,0 +1,191 @@
1+package dialog
2+
3+import (
4+ "github.com/charmbracelet/bubbles/key"
5+ tea "github.com/charmbracelet/bubbletea"
6+ "github.com/charmbracelet/lipgloss"
7+
8+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
9+ "github.com/kujtimiihoxha/opencode/internal/tui/util"
10+)
11+
12+// InitDialogCmp is a component that asks the user if they want to initialize the project.
13+type InitDialogCmp struct {
14+ width, height int
15+ selected int
16+ keys initDialogKeyMap
17+}
18+
19+// NewInitDialogCmp creates a new InitDialogCmp.
20+func NewInitDialogCmp() InitDialogCmp {
21+ return InitDialogCmp{
22+ selected: 0,
23+ keys: initDialogKeyMap{},
24+ }
25+}
26+
27+type initDialogKeyMap struct {
28+ Tab key.Binding
29+ Left key.Binding
30+ Right key.Binding
31+ Enter key.Binding
32+ Escape key.Binding
33+ Y key.Binding
34+ N key.Binding
35+}
36+
37+// ShortHelp implements key.Map.
38+func (k initDialogKeyMap) ShortHelp() []key.Binding {
39+ return []key.Binding{
40+ key.NewBinding(
41+ key.WithKeys("tab", "left", "right"),
42+ key.WithHelp("tab/←/→", "toggle selection"),
43+ ),
44+ key.NewBinding(
45+ key.WithKeys("enter"),
46+ key.WithHelp("enter", "confirm"),
47+ ),
48+ key.NewBinding(
49+ key.WithKeys("esc"),
50+ key.WithHelp("esc", "cancel"),
51+ ),
52+ key.NewBinding(
53+ key.WithKeys("y", "n"),
54+ key.WithHelp("y/n", "yes/no"),
55+ ),
56+ }
57+}
58+
59+// FullHelp implements key.Map.
60+func (k initDialogKeyMap) FullHelp() [][]key.Binding {
61+ return [][]key.Binding{k.ShortHelp()}
62+}
63+
64+// Init implements tea.Model.
65+func (m InitDialogCmp) Init() tea.Cmd {
66+ return nil
67+}
68+
69+// Update implements tea.Model.
70+func (m InitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
71+ switch msg := msg.(type) {
72+ case tea.KeyMsg:
73+ switch {
74+ case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
75+ return m, util.CmdHandler(CloseInitDialogMsg{Initialize: false})
76+ case key.Matches(msg, key.NewBinding(key.WithKeys("tab", "left", "right", "h", "l"))):
77+ m.selected = (m.selected + 1) % 2
78+ return m, nil
79+ case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
80+ return m, util.CmdHandler(CloseInitDialogMsg{Initialize: m.selected == 0})
81+ case key.Matches(msg, key.NewBinding(key.WithKeys("y"))):
82+ return m, util.CmdHandler(CloseInitDialogMsg{Initialize: true})
83+ case key.Matches(msg, key.NewBinding(key.WithKeys("n"))):
84+ return m, util.CmdHandler(CloseInitDialogMsg{Initialize: false})
85+ }
86+ case tea.WindowSizeMsg:
87+ m.width = msg.Width
88+ m.height = msg.Height
89+ }
90+ return m, nil
91+}
92+
93+// View implements tea.Model.
94+func (m InitDialogCmp) View() string {
95+ // Calculate width needed for content
96+ maxWidth := 60 // Width for explanation text
97+
98+ title := styles.BaseStyle.
99+ Foreground(styles.PrimaryColor).
100+ Bold(true).
101+ Width(maxWidth).
102+ Padding(0, 1).
103+ Render("Initialize Project")
104+
105+ explanation := styles.BaseStyle.
106+ Foreground(styles.Forground).
107+ Width(maxWidth).
108+ Padding(0, 1).
109+ Render("Initialization generates a new OpenCode.md file that contains information about your codebase, this file serves as memory for each project, you can freely add to it to help the agents be better at their job.")
110+
111+ question := styles.BaseStyle.
112+ Foreground(styles.Forground).
113+ Width(maxWidth).
114+ Padding(1, 1).
115+ Render("Would you like to initialize this project?")
116+
117+ yesStyle := styles.BaseStyle
118+ noStyle := styles.BaseStyle
119+
120+ if m.selected == 0 {
121+ yesStyle = yesStyle.
122+ Background(styles.PrimaryColor).
123+ Foreground(styles.Background).
124+ Bold(true)
125+ noStyle = noStyle.
126+ Background(styles.Background).
127+ Foreground(styles.PrimaryColor)
128+ } else {
129+ noStyle = noStyle.
130+ Background(styles.PrimaryColor).
131+ Foreground(styles.Background).
132+ Bold(true)
133+ yesStyle = yesStyle.
134+ Background(styles.Background).
135+ Foreground(styles.PrimaryColor)
136+ }
137+
138+ yes := yesStyle.Padding(0, 3).Render("Yes")
139+ no := noStyle.Padding(0, 3).Render("No")
140+
141+ buttons := lipgloss.JoinHorizontal(lipgloss.Center, yes, styles.BaseStyle.Render(" "), no)
142+ buttons = styles.BaseStyle.
143+ Width(maxWidth).
144+ Padding(1, 0).
145+ Render(buttons)
146+
147+ help := styles.BaseStyle.
148+ Width(maxWidth).
149+ Padding(0, 1).
150+ Foreground(styles.ForgroundDim).
151+ Render("tab/←/→: toggle y/n: yes/no enter: confirm esc: cancel")
152+
153+ content := lipgloss.JoinVertical(
154+ lipgloss.Left,
155+ title,
156+ styles.BaseStyle.Width(maxWidth).Render(""),
157+ explanation,
158+ question,
159+ buttons,
160+ styles.BaseStyle.Width(maxWidth).Render(""),
161+ help,
162+ )
163+
164+ return styles.BaseStyle.Padding(1, 2).
165+ Border(lipgloss.RoundedBorder()).
166+ BorderBackground(styles.Background).
167+ BorderForeground(styles.ForgroundDim).
168+ Width(lipgloss.Width(content) + 4).
169+ Render(content)
170+}
171+
172+// SetSize sets the size of the component.
173+func (m *InitDialogCmp) SetSize(width, height int) {
174+ m.width = width
175+ m.height = height
176+}
177+
178+// Bindings implements layout.Bindings.
179+func (m InitDialogCmp) Bindings() []key.Binding {
180+ return m.keys.ShortHelp()
181+}
182+
183+// CloseInitDialogMsg is a message that is sent when the init dialog is closed.
184+type CloseInitDialogMsg struct {
185+ Initialize bool
186+}
187+
188+// ShowInitDialogMsg is a message that is sent to show the init dialog.
189+type ShowInitDialogMsg struct {
190+ Show bool
191+}
internal/tui/components/dialog/permission.go+360−350
@@ -9,14 +9,12 @@ import (
99 tea "github.com/charmbracelet/bubbletea"
1010 "github.com/charmbracelet/glamour"
1111 "github.com/charmbracelet/lipgloss"
12− "github.com/kujtimiihoxha/termai/internal/llm/tools"
13− "github.com/kujtimiihoxha/termai/internal/permission"
14− "github.com/kujtimiihoxha/termai/internal/tui/components/core"
15− "github.com/kujtimiihoxha/termai/internal/tui/layout"
16− "github.com/kujtimiihoxha/termai/internal/tui/styles"
17− "github.com/kujtimiihoxha/termai/internal/tui/util"
18−
19− "github.com/charmbracelet/huh"
12+ "github.com/kujtimiihoxha/opencode/internal/diff"
13+ "github.com/kujtimiihoxha/opencode/internal/llm/tools"
14+ "github.com/kujtimiihoxha/opencode/internal/permission"
15+ "github.com/kujtimiihoxha/opencode/internal/tui/layout"
16+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
17+ "github.com/kujtimiihoxha/opencode/internal/tui/util"
2018)
2119
2220type PermissionAction string
@@ -34,69 +32,69 @@ type PermissionResponseMsg struct {
3432 Action PermissionAction
3533}
3634
37−// PermissionDialog interface for permission dialog component
38−type PermissionDialog interface {
35+// PermissionDialogCmp interface for permission dialog component
36+type PermissionDialogCmp interface {
3937 tea.Model
40− layout.Sizeable
4138 layout.Bindings
39+ SetPermissions(permission permission.PermissionRequest) tea.Cmd
4240}
4341
44−type keyMap struct {
45− ChangeFocus key.Binding
42+type permissionsMapping struct {
43+ Left key.Binding
44+ Right key.Binding
45+ EnterSpace key.Binding
46+ Allow key.Binding
47+ AllowSession key.Binding
48+ Deny key.Binding
49+ Tab key.Binding
4650}
4751
48−var keyMapValue = keyMap{
49− ChangeFocus: key.NewBinding(
52+var permissionsKeys = permissionsMapping{
53+ Left: key.NewBinding(
54+ key.WithKeys("left"),
55+ key.WithHelp("←", "switch options"),
56+ ),
57+ Right: key.NewBinding(
58+ key.WithKeys("right"),
59+ key.WithHelp("→", "switch options"),
60+ ),
61+ EnterSpace: key.NewBinding(
62+ key.WithKeys("enter", " "),
63+ key.WithHelp("enter/space", "confirm"),
64+ ),
65+ Allow: key.NewBinding(
66+ key.WithKeys("a"),
67+ key.WithHelp("a", "allow"),
68+ ),
69+ AllowSession: key.NewBinding(
70+ key.WithKeys("A"),
71+ key.WithHelp("A", "allow for session"),
72+ ),
73+ Deny: key.NewBinding(
74+ key.WithKeys("d"),
75+ key.WithHelp("d", "deny"),
76+ ),
77+ Tab: key.NewBinding(
5078 key.WithKeys("tab"),
51− key.WithHelp("tab", "change focus"),
79+ key.WithHelp("tab", "switch options"),
5280 ),
5381}
5482
5583// permissionDialogCmp is the implementation of PermissionDialog
5684type permissionDialogCmp struct {
57− form *huh.Form
5885 width int
5986 height int
6087 permission permission.PermissionRequest
6188 windowSize tea.WindowSizeMsg
62− r *glamour.TermRenderer
6389 contentViewPort viewport.Model
64− isViewportFocus bool
65− selectOption *huh.Select[string]
66−}
90+ selectedOption int // 0: Allow, 1: Allow for session, 2: Deny
6791
68−// formatDiff formats a diff string with colors for additions and deletions
69−func formatDiff(diffText string) string {
70− lines := strings.Split(diffText, "\n")
71− var formattedLines []string
72−
73− // Define styles for different line types
74− addStyle := lipgloss.NewStyle().Foreground(styles.Green)
75− removeStyle := lipgloss.NewStyle().Foreground(styles.Red)
76− headerStyle := lipgloss.NewStyle().Bold(true).Foreground(styles.Blue)
77− contextStyle := lipgloss.NewStyle().Foreground(styles.SubText0)
78−
79− // Process each line
80− for _, line := range lines {
81− if strings.HasPrefix(line, "+") {
82− formattedLines = append(formattedLines, addStyle.Render(line))
83− } else if strings.HasPrefix(line, "-") {
84− formattedLines = append(formattedLines, removeStyle.Render(line))
85− } else if strings.HasPrefix(line, "Changes:") || strings.HasPrefix(line, " ...") {
86− formattedLines = append(formattedLines, headerStyle.Render(line))
87− } else if strings.HasPrefix(line, " ") {
88− formattedLines = append(formattedLines, contextStyle.Render(line))
89− } else {
90− formattedLines = append(formattedLines, line)
91− }
92− }
93−
94− // Join all formatted lines
95− return strings.Join(formattedLines, "\n")
92+ diffCache map[string]string
93+ markdownCache map[string]string
9694}
9795
9896func (p *permissionDialogCmp) Init() tea.Cmd {
99− return nil
97+ return p.contentViewPort.Init()
10098}
10199
102100func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -105,369 +103,381 @@ func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
105103 switch msg := msg.(type) {
106104 case tea.WindowSizeMsg:
107105 p.windowSize = msg
106+ cmd := p.SetSize()
107+ cmds = append(cmds, cmd)
108+ p.markdownCache = make(map[string]string)
109+ p.diffCache = make(map[string]string)
108110 case tea.KeyMsg:
109− if key.Matches(msg, keyMapValue.ChangeFocus) {
110− p.isViewportFocus = !p.isViewportFocus
111− if p.isViewportFocus {
112− p.selectOption.Blur()
113− // Add a visual indicator for focus change
114− cmds = append(cmds, tea.Batch(
115− util.ReportInfo("Viewing content - use arrow keys to scroll"),
116− ))
117− } else {
118− p.selectOption.Focus()
119− // Add a visual indicator for focus change
120− cmds = append(cmds, tea.Batch(
121− util.CmdHandler(util.ReportInfo("Select an action")),
122− ))
123− }
124− return p, tea.Batch(cmds...)
111+ switch {
112+ case key.Matches(msg, permissionsKeys.Right) || key.Matches(msg, permissionsKeys.Tab):
113+ p.selectedOption = (p.selectedOption + 1) % 3
114+ return p, nil
115+ case key.Matches(msg, permissionsKeys.Left):
116+ p.selectedOption = (p.selectedOption + 2) % 3
117+ case key.Matches(msg, permissionsKeys.EnterSpace):
118+ return p, p.selectCurrentOption()
119+ case key.Matches(msg, permissionsKeys.Allow):
120+ return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionAllow, Permission: p.permission})
121+ case key.Matches(msg, permissionsKeys.AllowSession):
122+ return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionAllowForSession, Permission: p.permission})
123+ case key.Matches(msg, permissionsKeys.Deny):
124+ return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionDeny, Permission: p.permission})
125+ default:
126+ // Pass other keys to viewport
127+ viewPort, cmd := p.contentViewPort.Update(msg)
128+ p.contentViewPort = viewPort
129+ cmds = append(cmds, cmd)
125130 }
126131 }
127132
128− if p.isViewportFocus {
129− viewPort, cmd := p.contentViewPort.Update(msg)
130− p.contentViewPort = viewPort
131− cmds = append(cmds, cmd)
132− } else {
133− form, cmd := p.form.Update(msg)
134− if f, ok := form.(*huh.Form); ok {
135− p.form = f
136− cmds = append(cmds, cmd)
137− }
133+ return p, tea.Batch(cmds...)
134+}
138135
139− if p.form.State == huh.StateCompleted {
140− // Get the selected action
141− action := p.form.GetString("action")
136+func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
137+ var action PermissionAction
142138
143− // Close the dialog and return the response
144− return p, tea.Batch(
145− util.CmdHandler(core.DialogCloseMsg{}),
146− util.CmdHandler(PermissionResponseMsg{Action: PermissionAction(action), Permission: p.permission}),
147− )
148− }
139+ switch p.selectedOption {
140+ case 0:
141+ action = PermissionAllow
142+ case 1:
143+ action = PermissionAllowForSession
144+ case 2:
145+ action = PermissionDeny
149146 }
150− return p, tea.Batch(cmds...)
147+
148+ return util.CmdHandler(PermissionResponseMsg{Action: action, Permission: p.permission})
151149}
152150
153−func (p *permissionDialogCmp) render() string {
154− keyStyle := lipgloss.NewStyle().Bold(true).Foreground(styles.Rosewater)
155− valueStyle := lipgloss.NewStyle().Foreground(styles.Peach)
151+func (p *permissionDialogCmp) renderButtons() string {
152+ allowStyle := styles.BaseStyle
153+ allowSessionStyle := styles.BaseStyle
154+ denyStyle := styles.BaseStyle
155+ spacerStyle := styles.BaseStyle.Background(styles.Background)
156+
157+ // Style the selected button
158+ switch p.selectedOption {
159+ case 0:
160+ allowStyle = allowStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
161+ allowSessionStyle = allowSessionStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
162+ denyStyle = denyStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
163+ case 1:
164+ allowStyle = allowStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
165+ allowSessionStyle = allowSessionStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
166+ denyStyle = denyStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
167+ case 2:
168+ allowStyle = allowStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
169+ allowSessionStyle = allowSessionStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
170+ denyStyle = denyStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
171+ }
156172
157− form := p.form.View()
173+ allowButton := allowStyle.Padding(0, 1).Render("Allow (a)")
174+ allowSessionButton := allowSessionStyle.Padding(0, 1).Render("Allow for session (A)")
175+ denyButton := denyStyle.Padding(0, 1).Render("Deny (d)")
176+
177+ content := lipgloss.JoinHorizontal(
178+ lipgloss.Left,
179+ allowButton,
180+ spacerStyle.Render(" "),
181+ allowSessionButton,
182+ spacerStyle.Render(" "),
183+ denyButton,
184+ spacerStyle.Render(" "),
185+ )
158186
159− headerParts := []string{
160− lipgloss.JoinHorizontal(lipgloss.Left, keyStyle.Render("Tool:"), " ", valueStyle.Render(p.permission.ToolName)),
161− " ",
162− lipgloss.JoinHorizontal(lipgloss.Left, keyStyle.Render("Path:"), " ", valueStyle.Render(p.permission.Path)),
163− " ",
187+ remainingWidth := p.width - lipgloss.Width(content)
188+ if remainingWidth > 0 {
189+ content = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + content
164190 }
191+ return content
192+}
165193
166− // Create the header content first so it can be used in all cases
167− headerContent := lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
194+func (p *permissionDialogCmp) renderHeader() string {
195+ toolKey := styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("Tool")
196+ toolValue := styles.BaseStyle.
197+ Foreground(styles.Forground).
198+ Width(p.width - lipgloss.Width(toolKey)).
199+ Render(fmt.Sprintf(": %s", p.permission.ToolName))
168200
169− r, _ := glamour.NewTermRenderer(
170− glamour.WithStyles(styles.CatppuccinMarkdownStyle()),
171− glamour.WithWordWrap(p.width-10),
172− glamour.WithEmoji(),
173− )
201+ pathKey := styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("Path")
202+ pathValue := styles.BaseStyle.
203+ Foreground(styles.Forground).
204+ Width(p.width - lipgloss.Width(pathKey)).
205+ Render(fmt.Sprintf(": %s", p.permission.Path))
174206
175− // Handle different tool types
207+ headerParts := []string{
208+ lipgloss.JoinHorizontal(
209+ lipgloss.Left,
210+ toolKey,
211+ toolValue,
212+ ),
213+ styles.BaseStyle.Render(strings.Repeat(" ", p.width)),
214+ lipgloss.JoinHorizontal(
215+ lipgloss.Left,
216+ pathKey,
217+ pathValue,
218+ ),
219+ styles.BaseStyle.Render(strings.Repeat(" ", p.width)),
220+ }
221+
222+ // Add tool-specific header information
176223 switch p.permission.ToolName {
177224 case tools.BashToolName:
178− pr := p.permission.Params.(tools.BashPermissionsParams)
179− headerParts = append(headerParts, keyStyle.Render("Command:"))
180− content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
181−
182− renderedContent, _ := r.Render(content)
183− p.contentViewPort.Width = p.width - 2 - 2
225+ headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("Command"))
226+ case tools.EditToolName:
227+ headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("Diff"))
228+ case tools.WriteToolName:
229+ headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("Diff"))
230+ case tools.FetchToolName:
231+ headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("URL"))
232+ }
184233
185− // Calculate content height dynamically based on content
186− contentLines := len(strings.Split(renderedContent, "\n"))
187− // Set a reasonable min/max for the viewport height
188− minContentHeight := 3
189− maxContentHeight := p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
234+ return lipgloss.NewStyle().Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
235+}
190236
191− // Add some padding to the content lines
192− contentHeight := contentLines + 2
193− contentHeight = max(contentHeight, minContentHeight)
194− contentHeight = min(contentHeight, maxContentHeight)
195− p.contentViewPort.Height = contentHeight
237+func (p *permissionDialogCmp) renderBashContent() string {
238+ if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
239+ content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
196240
197− p.contentViewPort.SetContent(renderedContent)
241+ // Use the cache for markdown rendering
242+ renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
243+ r, _ := glamour.NewTermRenderer(
244+ glamour.WithStyles(styles.MarkdownTheme(true)),
245+ glamour.WithWordWrap(p.width-10),
246+ )
247+ s, err := r.Render(content)
248+ return styles.ForceReplaceBackgroundWithLipgloss(s, styles.Background), err
249+ })
250+
251+ finalContent := styles.BaseStyle.
252+ Width(p.contentViewPort.Width).
253+ Render(renderedContent)
254+ p.contentViewPort.SetContent(finalContent)
255+ return p.styleViewport()
256+ }
257+ return ""
258+}
198259
199− // Style the viewport
200− var contentBorder lipgloss.Border
201− var borderColor lipgloss.TerminalColor
260+func (p *permissionDialogCmp) renderEditContent() string {
261+ if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
262+ diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
263+ return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
264+ })
202265
203− if p.isViewportFocus {
204− contentBorder = lipgloss.DoubleBorder()
205− borderColor = styles.Blue
206− } else {
207− contentBorder = lipgloss.RoundedBorder()
208− borderColor = styles.Flamingo
209− }
266+ p.contentViewPort.SetContent(diff)
267+ return p.styleViewport()
268+ }
269+ return ""
270+}
210271
211− contentStyle := lipgloss.NewStyle().
212− MarginTop(1).
213− Padding(0, 1).
214− Border(contentBorder).
215− BorderForeground(borderColor)
272+func (p *permissionDialogCmp) renderPatchContent() string {
273+ if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
274+ diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
275+ return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
276+ })
216277
217− if p.isViewportFocus {
218− contentStyle = contentStyle.BorderBackground(styles.Surface0)
219− }
278+ p.contentViewPort.SetContent(diff)
279+ return p.styleViewport()
280+ }
281+ return ""
282+}
220283
221− contentFinal := contentStyle.Render(p.contentViewPort.View())
284+func (p *permissionDialogCmp) renderWriteContent() string {
285+ if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
286+ // Use the cache for diff rendering
287+ diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
288+ return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
289+ })
222290
223− return lipgloss.JoinVertical(
224− lipgloss.Top,
225− headerContent,
226− contentFinal,
227− form,
228− )
291+ p.contentViewPort.SetContent(diff)
292+ return p.styleViewport()
293+ }
294+ return ""
295+}
229296
230− case tools.EditToolName:
231− pr := p.permission.Params.(tools.EditPermissionsParams)
232− headerParts = append(headerParts, keyStyle.Render("Update"))
233− // Recreate header content with the updated headerParts
234− headerContent = lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
235−
236− // Format the diff with colors
237− formattedDiff := formatDiff(pr.Diff)
238−
239− // Set up viewport for the diff content
240− p.contentViewPort.Width = p.width - 2 - 2
241−
242− // Calculate content height dynamically based on window size
243− maxContentHeight := p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
244− p.contentViewPort.Height = maxContentHeight
245− p.contentViewPort.SetContent(formattedDiff)
246−
247− // Style the viewport
248− var contentBorder lipgloss.Border
249− var borderColor lipgloss.TerminalColor
250−
251− if p.isViewportFocus {
252− contentBorder = lipgloss.DoubleBorder()
253− borderColor = styles.Blue
254− } else {
255− contentBorder = lipgloss.RoundedBorder()
256− borderColor = styles.Flamingo
257− }
297+func (p *permissionDialogCmp) renderFetchContent() string {
298+ if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
299+ content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
258300
259− contentStyle := lipgloss.NewStyle().
260− MarginTop(1).
261− Padding(0, 1).
262− Border(contentBorder).
263− BorderForeground(borderColor)
301+ // Use the cache for markdown rendering
302+ renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
303+ r, _ := glamour.NewTermRenderer(
304+ glamour.WithStyles(styles.MarkdownTheme(true)),
305+ glamour.WithWordWrap(p.width-10),
306+ )
307+ s, err := r.Render(content)
308+ return styles.ForceReplaceBackgroundWithLipgloss(s, styles.Background), err
309+ })
264310
265− if p.isViewportFocus {
266− contentStyle = contentStyle.BorderBackground(styles.Surface0)
267− }
311+ p.contentViewPort.SetContent(renderedContent)
312+ return p.styleViewport()
313+ }
314+ return ""
315+}
268316
269− contentFinal := contentStyle.Render(p.contentViewPort.View())
317+func (p *permissionDialogCmp) renderDefaultContent() string {
318+ content := p.permission.Description
270319
271− return lipgloss.JoinVertical(
272− lipgloss.Top,
273− headerContent,
274− contentFinal,
275− form,
320+ // Use the cache for markdown rendering
321+ renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
322+ r, _ := glamour.NewTermRenderer(
323+ glamour.WithStyles(styles.CatppuccinMarkdownStyle()),
324+ glamour.WithWordWrap(p.width-10),
276325 )
326+ s, err := r.Render(content)
327+ return styles.ForceReplaceBackgroundWithLipgloss(s, styles.Background), err
328+ })
277329
278− case tools.WriteToolName:
279− pr := p.permission.Params.(tools.WritePermissionsParams)
280− headerParts = append(headerParts, keyStyle.Render("Content"))
281− // Recreate header content with the updated headerParts
282− headerContent = lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
283−
284− // Format the diff with colors
285− formattedDiff := formatDiff(pr.Content)
286−
287− // Set up viewport for the content
288− p.contentViewPort.Width = p.width - 2 - 2
289−
290− // Calculate content height dynamically based on window size
291− maxContentHeight := p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
292− p.contentViewPort.Height = maxContentHeight
293− p.contentViewPort.SetContent(formattedDiff)
294−
295− // Style the viewport
296− var contentBorder lipgloss.Border
297− var borderColor lipgloss.TerminalColor
298−
299− if p.isViewportFocus {
300− contentBorder = lipgloss.DoubleBorder()
301− borderColor = styles.Blue
302− } else {
303− contentBorder = lipgloss.RoundedBorder()
304− borderColor = styles.Flamingo
305− }
330+ p.contentViewPort.SetContent(renderedContent)
306331
307− contentStyle := lipgloss.NewStyle().
308− MarginTop(1).
309− Padding(0, 1).
310− Border(contentBorder).
311− BorderForeground(borderColor)
332+ if renderedContent == "" {
333+ return ""
334+ }
312335
313− if p.isViewportFocus {
314− contentStyle = contentStyle.BorderBackground(styles.Surface0)
315− }
336+ return p.styleViewport()
337+}
316338
317− contentFinal := contentStyle.Render(p.contentViewPort.View())
339+func (p *permissionDialogCmp) styleViewport() string {
340+ contentStyle := lipgloss.NewStyle().
341+ Background(styles.Background)
318342
319− return lipgloss.JoinVertical(
320− lipgloss.Top,
321− headerContent,
322− contentFinal,
323− form,
324− )
343+ return contentStyle.Render(p.contentViewPort.View())
344+}
325345
346+func (p *permissionDialogCmp) render() string {
347+ title := styles.BaseStyle.
348+ Bold(true).
349+ Width(p.width - 4).
350+ Foreground(styles.PrimaryColor).
351+ Render("Permission Required")
352+ // Render header
353+ headerContent := p.renderHeader()
354+ // Render buttons
355+ buttons := p.renderButtons()
356+
357+ // Calculate content height dynamically based on window size
358+ p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(buttons) - 2 - lipgloss.Height(title)
359+ p.contentViewPort.Width = p.width - 4
360+
361+ // Render content based on tool type
362+ var contentFinal string
363+ switch p.permission.ToolName {
364+ case tools.BashToolName:
365+ contentFinal = p.renderBashContent()
366+ case tools.EditToolName:
367+ contentFinal = p.renderEditContent()
368+ case tools.PatchToolName:
369+ contentFinal = p.renderPatchContent()
370+ case tools.WriteToolName:
371+ contentFinal = p.renderWriteContent()
326372 case tools.FetchToolName:
327− pr := p.permission.Params.(tools.FetchPermissionsParams)
328− headerParts = append(headerParts, keyStyle.Render("URL: "+pr.URL))
329− content := p.permission.Description
330−
331− renderedContent, _ := r.Render(content)
332− p.contentViewPort.Width = p.width - 2 - 2
333− p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
334− p.contentViewPort.SetContent(renderedContent)
335−
336− // Style the viewport
337− contentStyle := lipgloss.NewStyle().
338− MarginTop(1).
339− Padding(0, 1).
340− Border(lipgloss.RoundedBorder()).
341− BorderForeground(styles.Flamingo)
342−
343− contentFinal := contentStyle.Render(p.contentViewPort.View())
344− if renderedContent == "" {
345− contentFinal = ""
346− }
347−
348− return lipgloss.JoinVertical(
349− lipgloss.Top,
350− headerContent,
351− contentFinal,
352− form,
353− )
354−
373+ contentFinal = p.renderFetchContent()
355374 default:
356− content := p.permission.Description
357−
358− renderedContent, _ := r.Render(content)
359− p.contentViewPort.Width = p.width - 2 - 2
360− p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
361− p.contentViewPort.SetContent(renderedContent)
362−
363− // Style the viewport
364− contentStyle := lipgloss.NewStyle().
365− MarginTop(1).
366− Padding(0, 1).
367− Border(lipgloss.RoundedBorder()).
368− BorderForeground(styles.Flamingo)
375+ contentFinal = p.renderDefaultContent()
376+ }
369377
370− contentFinal := contentStyle.Render(p.contentViewPort.View())
371− if renderedContent == "" {
372− contentFinal = ""
373− }
378+ // Add help text
379+ helpText := styles.BaseStyle.Width(p.width - 4).Padding(0, 1).Foreground(styles.ForgroundDim).Render("←/→/tab: switch options a: allow A: allow for session d: deny enter/space: confirm")
380+
381+ content := lipgloss.JoinVertical(
382+ lipgloss.Top,
383+ title,
384+ styles.BaseStyle.Render(strings.Repeat(" ", lipgloss.Width(title))),
385+ headerContent,
386+ contentFinal,
387+ buttons,
388+ styles.BaseStyle.Render(strings.Repeat(" ", p.width - 4)),
389+ helpText,
390+ )
374391
375− return lipgloss.JoinVertical(
376− lipgloss.Top,
377− headerContent,
378− contentFinal,
379− form,
392+ return styles.BaseStyle.
393+ Padding(1, 0, 0, 1).
394+ Border(lipgloss.RoundedBorder()).
395+ BorderBackground(styles.Background).
396+ BorderForeground(styles.ForgroundDim).
397+ Width(p.width).
398+ Height(p.height).
399+ Render(
400+ content,
380401 )
381− }
382402}
383403
384404func (p *permissionDialogCmp) View() string {
385405 return p.render()
386406}
387407
388−func (p *permissionDialogCmp) GetSize() (int, int) {
389− return p.width, p.height
408+func (p *permissionDialogCmp) BindingKeys() []key.Binding {
409+ return layout.KeyMapToSlice(permissionsKeys)
390410}
391411
392−func (p *permissionDialogCmp) SetSize(width int, height int) {
393− p.width = width
394− p.height = height
395− p.form = p.form.WithWidth(width)
412+func (p *permissionDialogCmp) SetSize() tea.Cmd {
413+ if p.permission.ID == "" {
414+ return nil
415+ }
416+ switch p.permission.ToolName {
417+ case tools.BashToolName:
418+ p.width = int(float64(p.windowSize.Width) * 0.4)
419+ p.height = int(float64(p.windowSize.Height) * 0.3)
420+ case tools.EditToolName:
421+ p.width = int(float64(p.windowSize.Width) * 0.8)
422+ p.height = int(float64(p.windowSize.Height) * 0.8)
423+ case tools.WriteToolName:
424+ p.width = int(float64(p.windowSize.Width) * 0.8)
425+ p.height = int(float64(p.windowSize.Height) * 0.8)
426+ case tools.FetchToolName:
427+ p.width = int(float64(p.windowSize.Width) * 0.4)
428+ p.height = int(float64(p.windowSize.Height) * 0.3)
429+ default:
430+ p.width = int(float64(p.windowSize.Width) * 0.7)
431+ p.height = int(float64(p.windowSize.Height) * 0.5)
432+ }
433+ return nil
396434}
397435
398−func (p *permissionDialogCmp) BindingKeys() []key.Binding {
399− return p.form.KeyBinds()
436+func (p *permissionDialogCmp) SetPermissions(permission permission.PermissionRequest) tea.Cmd {
437+ p.permission = permission
438+ return p.SetSize()
400439}
401440
402−func newPermissionDialogCmp(permission permission.PermissionRequest) PermissionDialog {
403− // Create a note field for displaying the content
441+// Helper to get or set cached diff content
442+func (c *permissionDialogCmp) GetOrSetDiff(key string, generator func() (string, error)) string {
443+ if cached, ok := c.diffCache[key]; ok {
444+ return cached
445+ }
404446
405− // Create select field for the permission options
406− selectOption := huh.NewSelect[string]().
407− Key("action").
408− Options(
409− huh.NewOption("Allow", string(PermissionAllow)),
410− huh.NewOption("Allow for this session", string(PermissionAllowForSession)),
411− huh.NewOption("Deny", string(PermissionDeny)),
412− ).
413− Title("Select an action")
447+ content, err := generator()
448+ if err != nil {
449+ return fmt.Sprintf("Error formatting diff: %v", err)
450+ }
414451
415− // Apply theme
416− theme := styles.HuhTheme()
452+ c.diffCache[key] = content
417453
418− // Setup form width and height
419− form := huh.NewForm(huh.NewGroup(selectOption)).
420− WithShowHelp(false).
421− WithTheme(theme).
422− WithShowErrors(false)
454+ return content
455+}
423456
424− // Focus the form for immediate interaction
425− selectOption.Focus()
457+// Helper to get or set cached markdown content
458+func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
459+ if cached, ok := c.markdownCache[key]; ok {
460+ return cached
461+ }
426462
427− return &permissionDialogCmp{
428− permission: permission,
429− form: form,
430− selectOption: selectOption,
463+ content, err := generator()
464+ if err != nil {
465+ return fmt.Sprintf("Error rendering markdown: %v", err)
431466 }
432−}
433467
434−// NewPermissionDialogCmd creates a new permission dialog command
435−func NewPermissionDialogCmd(permission permission.PermissionRequest) tea.Cmd {
436− permDialog := newPermissionDialogCmp(permission)
437−
438− // Create the dialog layout
439− dialogPane := layout.NewSinglePane(
440− permDialog.(*permissionDialogCmp),
441− layout.WithSinglePaneBordered(true),
442− layout.WithSinglePaneFocusable(true),
443− layout.WithSinglePaneActiveColor(styles.Warning),
444− layout.WithSignlePaneBorderText(map[layout.BorderPosition]string{
445− layout.TopMiddleBorder: " Permission Required ",
446− }),
447− )
468+ c.markdownCache[key] = content
448469
449− // Focus the dialog
450− dialogPane.Focus()
451− widthRatio := 0.7
452− heightRatio := 0.6
453− minWidth := 100
454− minHeight := 30
470+ return content
471+}
455472
456− // Make the dialog size more appropriate for different tools
457− switch permission.ToolName {
458− case tools.BashToolName:
459− // For bash commands, use a more compact dialog
460− widthRatio = 0.7
461− heightRatio = 0.4 // Reduced from 0.5
462− minWidth = 100
463− minHeight = 20 // Reduced from 30
473+func NewPermissionDialogCmp() PermissionDialogCmp {
474+ // Create viewport for content
475+ contentViewport := viewport.New(0, 0)
476+
477+ return &permissionDialogCmp{
478+ contentViewPort: contentViewport,
479+ selectedOption: 0, // Default to "Allow"
480+ diffCache: make(map[string]string),
481+ markdownCache: make(map[string]string),
464482 }
465− // Return the dialog command
466− return util.CmdHandler(core.DialogMsg{
467− Content: dialogPane,
468− WidthRatio: widthRatio,
469− HeightRatio: heightRatio,
470− MinWidth: minWidth,
471− MinHeight: minHeight,
472− })
473483}
internal/tui/components/dialog/quit.go+94−68
@@ -1,28 +1,58 @@
11package dialog
22
33import (
4+ "strings"
5+
46 "github.com/charmbracelet/bubbles/key"
57 tea "github.com/charmbracelet/bubbletea"
6− "github.com/kujtimiihoxha/termai/internal/tui/components/core"
7− "github.com/kujtimiihoxha/termai/internal/tui/layout"
8− "github.com/kujtimiihoxha/termai/internal/tui/styles"
9− "github.com/kujtimiihoxha/termai/internal/tui/util"
10−
11− "github.com/charmbracelet/huh"
8+ "github.com/charmbracelet/lipgloss"
9+ "github.com/kujtimiihoxha/opencode/internal/tui/layout"
10+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
11+ "github.com/kujtimiihoxha/opencode/internal/tui/util"
1212)
1313
1414const question = "Are you sure you want to quit?"
1515
16+type CloseQuitMsg struct{}
17+
1618type QuitDialog interface {
1719 tea.Model
18− layout.Sizeable
1920 layout.Bindings
2021}
2122
2223type quitDialogCmp struct {
23− form *huh.Form
24− width int
25− height int
24+ selectedNo bool
25+}
26+
27+type helpMapping struct {
28+ LeftRight key.Binding
29+ EnterSpace key.Binding
30+ Yes key.Binding
31+ No key.Binding
32+ Tab key.Binding
33+}
34+
35+var helpKeys = helpMapping{
36+ LeftRight: key.NewBinding(
37+ key.WithKeys("left", "right"),
38+ key.WithHelp("←/→", "switch options"),
39+ ),
40+ EnterSpace: key.NewBinding(
41+ key.WithKeys("enter", " "),
42+ key.WithHelp("enter/space", "confirm"),
43+ ),
44+ Yes: key.NewBinding(
45+ key.WithKeys("y", "Y"),
46+ key.WithHelp("y/Y", "yes"),
47+ ),
48+ No: key.NewBinding(
49+ key.WithKeys("n", "N"),
50+ key.WithHelp("n/N", "no"),
51+ ),
52+ Tab: key.NewBinding(
53+ key.WithKeys("tab"),
54+ key.WithHelp("tab", "switch options"),
55+ ),
2656}
2757
2858func (q *quitDialogCmp) Init() tea.Cmd {
@@ -30,77 +60,73 @@ func (q *quitDialogCmp) Init() tea.Cmd {
3060}
3161
3262func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
33− var cmds []tea.Cmd
34− form, cmd := q.form.Update(msg)
35− if f, ok := form.(*huh.Form); ok {
36− q.form = f
37− cmds = append(cmds, cmd)
38− }
39−
40− if q.form.State == huh.StateCompleted {
41− v := q.form.GetBool("quit")
42− if v {
63+ switch msg := msg.(type) {
64+ case tea.KeyMsg:
65+ switch {
66+ case key.Matches(msg, helpKeys.LeftRight) || key.Matches(msg, helpKeys.Tab):
67+ q.selectedNo = !q.selectedNo
68+ return q, nil
69+ case key.Matches(msg, helpKeys.EnterSpace):
70+ if !q.selectedNo {
71+ return q, tea.Quit
72+ }
73+ return q, util.CmdHandler(CloseQuitMsg{})
74+ case key.Matches(msg, helpKeys.Yes):
4375 return q, tea.Quit
76+ case key.Matches(msg, helpKeys.No):
77+ return q, util.CmdHandler(CloseQuitMsg{})
4478 }
45− cmds = append(cmds, util.CmdHandler(core.DialogCloseMsg{}))
4679 }
47−
48− return q, tea.Batch(cmds...)
80+ return q, nil
4981}
5082
5183func (q *quitDialogCmp) View() string {
52− return q.form.View()
53−}
84+ yesStyle := styles.BaseStyle
85+ noStyle := styles.BaseStyle
86+ spacerStyle := styles.BaseStyle.Background(styles.Background)
87+
88+ if q.selectedNo {
89+ noStyle = noStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
90+ yesStyle = yesStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
91+ } else {
92+ yesStyle = yesStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
93+ noStyle = noStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
94+ }
5495
55−func (q *quitDialogCmp) GetSize() (int, int) {
56− return q.width, q.height
57−}
96+ yesButton := yesStyle.Padding(0, 1).Render("Yes")
97+ noButton := noStyle.Padding(0, 1).Render("No")
98+
99+ buttons := lipgloss.JoinHorizontal(lipgloss.Left, yesButton, spacerStyle.Render(" "), noButton)
100+
101+ width := lipgloss.Width(question)
102+ remainingWidth := width - lipgloss.Width(buttons)
103+ if remainingWidth > 0 {
104+ buttons = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + buttons
105+ }
58106
59−func (q *quitDialogCmp) SetSize(width int, height int) {
60− q.width = width
61− q.height = height
62− q.form = q.form.WithWidth(width).WithHeight(height)
107+ content := styles.BaseStyle.Render(
108+ lipgloss.JoinVertical(
109+ lipgloss.Center,
110+ question,
111+ "",
112+ buttons,
113+ ),
114+ )
115+
116+ return styles.BaseStyle.Padding(1, 2).
117+ Border(lipgloss.RoundedBorder()).
118+ BorderBackground(styles.Background).
119+ BorderForeground(styles.ForgroundDim).
120+ Width(lipgloss.Width(content) + 4).
121+ Render(content)
63122}
64123
65124func (q *quitDialogCmp) BindingKeys() []key.Binding {
66− return q.form.KeyBinds()
125+ return layout.KeyMapToSlice(helpKeys)
67126}
68127
69−func newQuitDialogCmp() QuitDialog {
70− confirm := huh.NewConfirm().
71− Title(question).
72− Affirmative("Yes!").
73− Key("quit").
74− Negative("No.")
75−
76− theme := styles.HuhTheme()
77− theme.Focused.FocusedButton = theme.Focused.FocusedButton.Background(styles.Warning)
78− theme.Blurred.FocusedButton = theme.Blurred.FocusedButton.Background(styles.Warning)
79− form := huh.NewForm(huh.NewGroup(confirm)).
80− WithShowHelp(false).
81− WithWidth(0).
82− WithHeight(0).
83− WithTheme(theme).
84− WithShowErrors(false)
85− confirm.Focus()
128+func NewQuitCmp() QuitDialog {
86129 return &quitDialogCmp{
87− form: form,
130+ selectedNo: true,
88131 }
89132}
90−
91−func NewQuitDialogCmd() tea.Cmd {
92− content := layout.NewSinglePane(
93− newQuitDialogCmp().(*quitDialogCmp),
94− layout.WithSinglePaneBordered(true),
95− layout.WithSinglePaneFocusable(true),
96− layout.WithSinglePaneActiveColor(styles.Warning),
97− )
98− content.Focus()
99− return util.CmdHandler(core.DialogMsg{
100− Content: content,
101− WidthRatio: 0.2,
102− HeightRatio: 0.1,
103− MinWidth: 40,
104− MinHeight: 5,
105− })
106−}
internal/tui/components/dialog/session.gonew file+226
@@ -0,0 +1,226 @@
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+ Width(maxWidth).
163+ Padding(0, 1).
164+ Render("Switch Session")
165+
166+ content := lipgloss.JoinVertical(
167+ lipgloss.Left,
168+ title,
169+ styles.BaseStyle.Width(maxWidth).Render(""),
170+ styles.BaseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, sessionItems...)),
171+ styles.BaseStyle.Width(maxWidth).Render(""),
172+ styles.BaseStyle.Width(maxWidth).Padding(0, 1).Foreground(styles.ForgroundDim).Render("↑/k: up ↓/j: down enter: select esc: cancel"),
173+ )
174+
175+ return styles.BaseStyle.Padding(1, 2).
176+ Border(lipgloss.RoundedBorder()).
177+ BorderBackground(styles.Background).
178+ BorderForeground(styles.ForgroundDim).
179+ Width(lipgloss.Width(content) + 4).
180+ Render(content)
181+}
182+
183+func (s *sessionDialogCmp) BindingKeys() []key.Binding {
184+ return layout.KeyMapToSlice(sessionKeys)
185+}
186+
187+func (s *sessionDialogCmp) SetSessions(sessions []session.Session) {
188+ s.sessions = sessions
189+
190+ // If we have a selected session ID, find its index
191+ if s.selectedSessionID != "" {
192+ for i, sess := range sessions {
193+ if sess.ID == s.selectedSessionID {
194+ s.selectedIdx = i
195+ return
196+ }
197+ }
198+ }
199+
200+ // Default to first session if selected not found
201+ s.selectedIdx = 0
202+}
203+
204+func (s *sessionDialogCmp) SetSelectedSession(sessionID string) {
205+ s.selectedSessionID = sessionID
206+
207+ // Update the selected index if sessions are already loaded
208+ if len(s.sessions) > 0 {
209+ for i, sess := range s.sessions {
210+ if sess.ID == sessionID {
211+ s.selectedIdx = i
212+ return
213+ }
214+ }
215+ }
216+}
217+
218+// NewSessionDialogCmp creates a new session switching dialog
219+func NewSessionDialogCmp() SessionDialog {
220+ return &sessionDialogCmp{
221+ sessions: []session.Session{},
222+ selectedIdx: 0,
223+ selectedSessionID: "",
224+ }
225+}
226+
internal/tui/components/logs/details.go+8−45
@@ -9,22 +9,19 @@ import (
99 "github.com/charmbracelet/bubbles/viewport"
1010 tea "github.com/charmbracelet/bubbletea"
1111 "github.com/charmbracelet/lipgloss"
12− "github.com/kujtimiihoxha/termai/internal/logging"
13− "github.com/kujtimiihoxha/termai/internal/tui/layout"
14− "github.com/kujtimiihoxha/termai/internal/tui/styles"
12+ "github.com/kujtimiihoxha/opencode/internal/logging"
13+ "github.com/kujtimiihoxha/opencode/internal/tui/layout"
14+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
1515)
1616
1717type DetailComponent interface {
1818 tea.Model
19− layout.Focusable
2019 layout.Sizeable
2120 layout.Bindings
22− layout.Bordered
2321}
2422
2523type detailCmp struct {
2624 width, height int
27− focused bool
2825 currentLog logging.LogMessage
2926 viewport viewport.Model
3027}
@@ -39,11 +36,6 @@ func (i *detailCmp) Init() tea.Cmd {
3936}
4037
4138func (i *detailCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
42− var (
43− cmd tea.Cmd
44− cmds []tea.Cmd
45− )
46−
4739 switch msg := msg.(type) {
4840 case selectedLogMsg:
4941 if msg.ID != i.currentLog.ID {
@@ -52,12 +44,7 @@ func (i *detailCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
5244 }
5345 }
5446
55− if i.focused {
56− i.viewport, cmd = i.viewport.Update(msg)
57− cmds = append(cmds, cmd)
58− }
59−
60− return i, tea.Batch(cmds...)
47+ return i, nil
6148}
6249
6350func (i *detailCmp) updateContent() {
@@ -125,48 +112,24 @@ func getLevelStyle(level string) lipgloss.Style {
125112}
126113
127114func (i *detailCmp) View() string {
128− return i.viewport.View()
129−}
130−
131−func (i *detailCmp) Blur() tea.Cmd {
132− i.focused = false
133− return nil
134−}
135−
136−func (i *detailCmp) Focus() tea.Cmd {
137− i.focused = true
138− return nil
139−}
140−
141−func (i *detailCmp) IsFocused() bool {
142− return i.focused
115+ return styles.ForceReplaceBackgroundWithLipgloss(i.viewport.View(), styles.Background)
143116}
144117
145118func (i *detailCmp) GetSize() (int, int) {
146119 return i.width, i.height
147120}
148121
149−func (i *detailCmp) SetSize(width int, height int) {
122+func (i *detailCmp) SetSize(width int, height int) tea.Cmd {
150123 i.width = width
151124 i.height = height
152125 i.viewport.Width = i.width
153126 i.viewport.Height = i.height
154127 i.updateContent()
128+ return nil
155129}
156130
157131func (i *detailCmp) BindingKeys() []key.Binding {
158− return []key.Binding{
159− i.viewport.KeyMap.PageDown,
160− i.viewport.KeyMap.PageUp,
161− i.viewport.KeyMap.HalfPageDown,
162− i.viewport.KeyMap.HalfPageUp,
163− }
164−}
165−
166−func (i *detailCmp) BorderText() map[layout.BorderPosition]string {
167− return map[layout.BorderPosition]string{
168− layout.TopLeftBorder: "Log Details",
169− }
132+ return layout.KeyMapToSlice(i.viewport.KeyMap)
170133}
171134
172135func NewLogsDetails() DetailComponent {
internal/tui/components/logs/table.go+29−51
@@ -7,31 +7,23 @@ import (
77 "github.com/charmbracelet/bubbles/key"
88 "github.com/charmbracelet/bubbles/table"
99 tea "github.com/charmbracelet/bubbletea"
10− "github.com/kujtimiihoxha/termai/internal/logging"
11− "github.com/kujtimiihoxha/termai/internal/pubsub"
12− "github.com/kujtimiihoxha/termai/internal/tui/layout"
13− "github.com/kujtimiihoxha/termai/internal/tui/styles"
14− "github.com/kujtimiihoxha/termai/internal/tui/util"
10+ "github.com/kujtimiihoxha/opencode/internal/logging"
11+ "github.com/kujtimiihoxha/opencode/internal/pubsub"
12+ "github.com/kujtimiihoxha/opencode/internal/tui/layout"
13+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
14+ "github.com/kujtimiihoxha/opencode/internal/tui/util"
1515)
1616
1717type TableComponent interface {
1818 tea.Model
19− layout.Focusable
2019 layout.Sizeable
2120 layout.Bindings
22− layout.Bordered
2321}
2422
2523type tableCmp struct {
2624 table table.Model
2725}
2826
29−func (i *tableCmp) BorderText() map[layout.BorderPosition]string {
30− return map[layout.BorderPosition]string{
31− layout.TopLeftBorder: "Logs",
32− }
33−}
34−
3527type selectedLogMsg logging.LogMessage
3628
3729func (i *tableCmp) Init() tea.Cmd {
@@ -41,58 +33,42 @@ func (i *tableCmp) Init() tea.Cmd {
4133
4234func (i *tableCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
4335 var cmds []tea.Cmd
44− if i.table.Focused() {
45− switch msg.(type) {
46− case pubsub.Event[logging.LogMessage]:
47− i.setRows()
48− return i, nil
49− }
50− prevSelectedRow := i.table.SelectedRow()
51− t, cmd := i.table.Update(msg)
52− cmds = append(cmds, cmd)
53− i.table = t
54− selectedRow := i.table.SelectedRow()
55− if selectedRow != nil {
56− if prevSelectedRow == nil || selectedRow[0] == prevSelectedRow[0] {
57− var log logging.LogMessage
58− for _, row := range logging.List() {
59− if row.ID == selectedRow[0] {
60− log = row
61− break
62− }
63− }
64− if log.ID != "" {
65− cmds = append(cmds, util.CmdHandler(selectedLogMsg(log)))
36+ switch msg.(type) {
37+ case pubsub.Event[logging.LogMessage]:
38+ i.setRows()
39+ return i, nil
40+ }
41+ prevSelectedRow := i.table.SelectedRow()
42+ t, cmd := i.table.Update(msg)
43+ cmds = append(cmds, cmd)
44+ i.table = t
45+ selectedRow := i.table.SelectedRow()
46+ if selectedRow != nil {
47+ if prevSelectedRow == nil || selectedRow[0] == prevSelectedRow[0] {
48+ var log logging.LogMessage
49+ for _, row := range logging.List() {
50+ if row.ID == selectedRow[0] {
51+ log = row
52+ break
6653 }
6754 }
55+ if log.ID != "" {
56+ cmds = append(cmds, util.CmdHandler(selectedLogMsg(log)))
57+ }
6858 }
6959 }
7060 return i, tea.Batch(cmds...)
7161}
7262
7363func (i *tableCmp) View() string {
74− return i.table.View()
75−}
76−
77−func (i *tableCmp) Blur() tea.Cmd {
78− i.table.Blur()
79− return nil
80−}
81−
82−func (i *tableCmp) Focus() tea.Cmd {
83− i.table.Focus()
84− return nil
85−}
86−
87−func (i *tableCmp) IsFocused() bool {
88− return i.table.Focused()
64+ return styles.ForceReplaceBackgroundWithLipgloss(i.table.View(), styles.Background)
8965}
9066
9167func (i *tableCmp) GetSize() (int, int) {
9268 return i.table.Width(), i.table.Height()
9369}
9470
95−func (i *tableCmp) SetSize(width int, height int) {
71+func (i *tableCmp) SetSize(width int, height int) tea.Cmd {
9672 i.table.SetWidth(width)
9773 i.table.SetHeight(height)
9874 cloumns := i.table.Columns()
@@ -101,6 +77,7 @@ func (i *tableCmp) SetSize(width int, height int) {
10177 cloumns[i] = col
10278 }
10379 i.table.SetColumns(cloumns)
80+ return nil
10481}
10582
10683func (i *tableCmp) BindingKeys() []key.Binding {
@@ -150,6 +127,7 @@ func NewLogsTable() TableComponent {
150127 table.WithColumns(columns),
151128 table.WithStyles(defaultStyles),
152129 )
130+ tableModel.Focus()
153131 return &tableCmp{
154132 table: tableModel,
155133 }
internal/tui/components/repl/editor.godeleted−201
@@ -1,201 +0,0 @@
1−package repl
2−
3−import (
4− "strings"
5−
6− "github.com/charmbracelet/bubbles/key"
7− tea "github.com/charmbracelet/bubbletea"
8− "github.com/charmbracelet/lipgloss"
9− "github.com/kujtimiihoxha/termai/internal/app"
10− "github.com/kujtimiihoxha/termai/internal/llm/agent"
11− "github.com/kujtimiihoxha/termai/internal/tui/layout"
12− "github.com/kujtimiihoxha/termai/internal/tui/styles"
13− "github.com/kujtimiihoxha/termai/internal/tui/util"
14− "github.com/kujtimiihoxha/vimtea"
15− "golang.org/x/net/context"
16−)
17−
18−type EditorCmp interface {
19− tea.Model
20− layout.Focusable
21− layout.Sizeable
22− layout.Bordered
23− layout.Bindings
24−}
25−
26−type editorCmp struct {
27− app *app.App
28− editor vimtea.Editor
29− editorMode vimtea.EditorMode
30− sessionID string
31− focused bool
32− width int
33− height int
34− cancelMessage context.CancelFunc
35−}
36−
37−type editorKeyMap struct {
38− SendMessage key.Binding
39− SendMessageI key.Binding
40− CancelMessage key.Binding
41− InsertMode key.Binding
42− NormaMode key.Binding
43− VisualMode key.Binding
44− VisualLineMode key.Binding
45−}
46−
47−var editorKeyMapValue = editorKeyMap{
48− SendMessage: key.NewBinding(
49− key.WithKeys("enter"),
50− key.WithHelp("enter", "send message normal mode"),
51− ),
52− SendMessageI: key.NewBinding(
53− key.WithKeys("ctrl+s"),
54− key.WithHelp("ctrl+s", "send message insert mode"),
55− ),
56− CancelMessage: key.NewBinding(
57− key.WithKeys("ctrl+x"),
58− key.WithHelp("ctrl+x", "cancel current message"),
59− ),
60− InsertMode: key.NewBinding(
61− key.WithKeys("i"),
62− key.WithHelp("i", "insert mode"),
63− ),
64− NormaMode: key.NewBinding(
65− key.WithKeys("esc"),
66− key.WithHelp("esc", "normal mode"),
67− ),
68− VisualMode: key.NewBinding(
69− key.WithKeys("v"),
70− key.WithHelp("v", "visual mode"),
71− ),
72− VisualLineMode: key.NewBinding(
73− key.WithKeys("V"),
74− key.WithHelp("V", "visual line mode"),
75− ),
76−}
77−
78−func (m *editorCmp) Init() tea.Cmd {
79− return m.editor.Init()
80−}
81−
82−func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
83− switch msg := msg.(type) {
84− case vimtea.EditorModeMsg:
85− m.editorMode = msg.Mode
86− case SelectedSessionMsg:
87− if msg.SessionID != m.sessionID {
88− m.sessionID = msg.SessionID
89− }
90− }
91− if m.IsFocused() {
92− switch msg := msg.(type) {
93− case tea.KeyMsg:
94− switch {
95− case key.Matches(msg, editorKeyMapValue.SendMessage):
96− if m.editorMode == vimtea.ModeNormal {
97− return m, m.Send()
98− }
99− case key.Matches(msg, editorKeyMapValue.SendMessageI):
100− if m.editorMode == vimtea.ModeInsert {
101− return m, m.Send()
102− }
103− case key.Matches(msg, editorKeyMapValue.CancelMessage):
104− return m, m.Cancel()
105− }
106− }
107− u, cmd := m.editor.Update(msg)
108− m.editor = u.(vimtea.Editor)
109− return m, cmd
110− }
111− return m, nil
112−}
113−
114−func (m *editorCmp) Blur() tea.Cmd {
115− m.focused = false
116− return nil
117−}
118−
119−func (m *editorCmp) BorderText() map[layout.BorderPosition]string {
120− title := "New Message"
121− if m.focused {
122− title = lipgloss.NewStyle().Foreground(styles.Primary).Render(title)
123− }
124− return map[layout.BorderPosition]string{
125− layout.BottomLeftBorder: title,
126− }
127−}
128−
129−func (m *editorCmp) Focus() tea.Cmd {
130− m.focused = true
131− return m.editor.Tick()
132−}
133−
134−func (m *editorCmp) GetSize() (int, int) {
135− return m.width, m.height
136−}
137−
138−func (m *editorCmp) IsFocused() bool {
139− return m.focused
140−}
141−
142−func (m *editorCmp) SetSize(width int, height int) {
143− m.width = width
144− m.height = height
145− m.editor.SetSize(width, height)
146−}
147−
148−func (m *editorCmp) Cancel() tea.Cmd {
149− if m.cancelMessage == nil {
150− return util.ReportWarn("No message to cancel")
151− }
152−
153− m.cancelMessage()
154− m.cancelMessage = nil
155− return util.ReportWarn("Message cancelled")
156−}
157−
158−func (m *editorCmp) Send() tea.Cmd {
159− return func() tea.Msg {
160− messages, err := m.app.Messages.List(m.sessionID)
161− if err != nil {
162− return util.ReportError(err)
163− }
164− if hasUnfinishedMessages(messages) {
165− return util.ReportWarn("Assistant is still working on the previous message")
166− }
167− a, err := agent.NewCoderAgent(m.app)
168− if err != nil {
169− return util.ReportError(err)
170− }
171−
172− content := strings.Join(m.editor.GetBuffer().Lines(), "\n")
173− ctx, cancel := context.WithCancel(m.app.Context)
174− m.cancelMessage = cancel
175− go func() {
176− defer cancel()
177− a.Generate(ctx, m.sessionID, content)
178− m.cancelMessage = nil
179− }()
180−
181− return m.editor.Reset()
182− }
183−}
184−
185−func (m *editorCmp) View() string {
186− return m.editor.View()
187−}
188−
189−func (m *editorCmp) BindingKeys() []key.Binding {
190− return layout.KeyMapToSlice(editorKeyMapValue)
191−}
192−
193−func NewEditorCmp(app *app.App) EditorCmp {
194− editor := vimtea.NewEditor(
195− vimtea.WithFileName("message.md"),
196− )
197− return &editorCmp{
198− app: app,
199− editor: editor,
200− }
201−}
internal/tui/components/repl/messages.godeleted−512

This diff is too big to show here.

internal/tui/components/repl/sessions.godeleted−247

This diff is too big to show here.

internal/tui/layout/bento.godeleted−392

This diff is too big to show here.

internal/tui/layout/border.godeleted−121

This diff is too big to show here.

internal/tui/layout/container.gonew file+226

This diff is too big to show here.

internal/tui/layout/grid.godeleted−254

This diff is too big to show here.

internal/tui/layout/layout.go+1−5

This diff is too big to show here.

internal/tui/layout/overlay.go+7−6

This diff is too big to show here.

internal/tui/layout/single.godeleted−189

This diff is too big to show here.

internal/tui/layout/split.gonew file+289

This diff is too big to show here.

internal/tui/page/chat.gonew file+167

This diff is too big to show here.

internal/tui/page/init.godeleted−308

This diff is too big to show here.

internal/tui/page/logs.go+73−10

This diff is too big to show here.

internal/tui/page/repl.godeleted−21

This diff is too big to show here.

internal/tui/styles/background.gonew file+123

This diff is too big to show here.

internal/tui/styles/icons.go+7−13

This diff is too big to show here.

internal/tui/styles/markdown.go+445−2

This diff is too big to show here.

internal/tui/styles/styles.go+49

This diff is too big to show here.

internal/tui/tui.go+454−151

This diff is too big to show here.

internal/version/version.go+1−1

This diff is too big to show here.

main.go+6−1

This diff is too big to show here.

opencode-schema.jsonnew file+269

This diff is too big to show here.