on main help sign in
Merge pull request #27 from kujtimiihoxha/opencode
f33dff8Kujtim Hoxha
.gitignore+2−2
@@ -31,7 +31,7 @@ go.work
31.Trashes
31.Trashes
32ehthumbs.db
32ehthumbs.db
33Thumbs.db
33Thumbs.db
34−debug.log
34+*.log
35
35
36# Binary output directory
36# Binary output directory
37/bin/
37/bin/
@@ -41,5 +41,5 @@ debug.log
41.env
41.env
42.env.local
42.env.local
43
43
44−.termai
44+.opencode/
45
45
.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
2
2
3> **⚠️ 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.
3> **⚠️ 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.
4
4
5A powerful terminal-based AI assistant for developers, providing intelligent coding assistance directly in your terminal.
5A powerful terminal-based AI assistant for developers, providing intelligent coding assistance directly in your terminal.
6
6
7−[![TermAI Demo](https://asciinema.org/a/dtc4nJyGSZX79HRUmFLY3gmoy.svg)](https://asciinema.org/a/dtc4nJyGSZX79HRUmFLY3gmoy)
8−
9## Overview
7## Overview
10
8
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.
12
10
13## Features
11## Features
14
12
15- **Interactive TUI**: Built with [Bubble Tea](https://github.com/charmbracelet/bubbletea) for a smooth terminal experience
13- **Interactive TUI**: Built with [Bubble Tea](https://github.com/charmbracelet/bubbletea) for a smooth terminal experience
16−- **Multiple AI Providers**: Support for OpenAI, Anthropic Claude, and Google Gemini models
14+- **Multiple AI Providers**: Support for OpenAI, Anthropic Claude, Google Gemini, AWS Bedrock, and Groq
17- **Session Management**: Save and manage multiple conversation sessions
15- **Session Management**: Save and manage multiple conversation sessions
18- **Tool Integration**: AI can execute commands, search files, and modify code
16- **Tool Integration**: AI can execute commands, search files, and modify code
19−- **Vim-like Editor**: Integrated editor with Vim keybindings for text input
17+- **Vim-like Editor**: Integrated editor with text input capabilities
20- **Persistent Storage**: SQLite database for storing conversations and sessions
18- **Persistent Storage**: SQLite database for storing conversations and sessions
19+- **LSP Integration**: Language Server Protocol support for code intelligence
20+- **File Change Tracking**: Track and visualize file changes during sessions
21+- **External Editor Support**: Open your preferred editor for composing messages
21
22
22## Installation
23## Installation
23
24
24```bash
25```bash
25# Coming soon
26# Coming soon
26−go install github.com/kujtimiihoxha/termai@latest
27+go install github.com/kujtimiihoxha/opencode@latest
27```
28```
28
29
29## Configuration
30## Configuration
30
31
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+```
32
101
33−- `$HOME/.termai.json`
102+## Supported AI Models
34−- `$XDG_CONFIG_HOME/termai/.termai.json`
35−- `./.termai.json` (local directory)
36
103
37−You can also use environment variables:
104+OpenCode supports a variety of AI models from different providers:
38
105
39−- `ANTHROPIC_API_KEY`: For Claude models
106+### OpenAI
40−- `OPENAI_API_KEY`: For OpenAI models
41−- `GEMINI_API_KEY`: For Google Gemini models
42
107
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
44
114
45−```bash
115+### Anthropic
46−# Start TermAI
47−termai
48
116
49−# Start with debug logging
117+- Claude 3.5 Sonnet
50−termai -d
118+- Claude 3.5 Haiku
51−```
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
52
129
53−### Keyboard Shortcuts
130+### AWS Bedrock
54
131
55−#### Global Shortcuts
132+- Claude 3.7 Sonnet
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
61
133
62−#### Session Management
134+## Usage
63−- `N`: Create new session
135+
64−- `Enter` or `Space`: Select session (in sessions list)
136+```bash
137+# Start OpenCode
138+opencode
139+
140+# Start with debug logging
141+opencode -d
65
142
66−#### Editor Shortcuts (Vim-like)
143+# Start with a specific working directory
67−- `i`: Enter insert mode
144+opencode -c /path/to/project
68−- `Esc`: Enter normal mode
145+```
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)
73
146
74−#### Navigation
147+## Command-line Flags
75−- Arrow keys: Navigate through lists and content
148+
76−- Page Up/Down: Scroll through content
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) |
77
238
78## Architecture
239## Architecture
79
240
80−TermAI is built with a modular architecture:
241+OpenCode is built with a modular architecture:
81
242
82- **cmd**: Command-line interface using Cobra
243- **cmd**: Command-line interface using Cobra
83- **internal/app**: Core application services
244- **internal/app**: Core application services
@@ -88,37 +249,129 @@ TermAI is built with a modular architecture:
88- **internal/logging**: Logging infrastructure
249- **internal/logging**: Logging infrastructure
89- **internal/message**: Message handling
250- **internal/message**: Message handling
90- **internal/session**: Session management
251- **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.
91
333
92## Development
334## Development
93
335
94### Prerequisites
336### Prerequisites
95
337
96−- Go 1.23.5 or higher
338+- Go 1.24.0 or higher
97
339
98### Building from Source
340### Building from Source
99
341
100```bash
342```bash
101# Clone the repository
343# Clone the repository
102−git clone https://github.com/kujtimiihoxha/termai.git
344+git clone https://github.com/kujtimiihoxha/opencode.git
103−cd termai
345+cd opencode
104
346
105# Build
347# Build
106−go build -o termai
348+go build -o opencode
107
349
108# Run
350# Run
109−./termai
351+./opencode
110```
352```
111
353
112## Acknowledgments
354## Acknowledgments
113
355
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
115
360
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.
117
362
118## License
363## License
119
364
120−[License information coming soon]
365+OpenCode is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
121
366
122## Contributing
367## Contributing
123
368
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
2
2
3import (
3import (
4 "context"
4 "context"
5− "log/slog"
5+ "fmt"
6 "os"
6 "os"
7 "sync"
7 "sync"
8+ "time"
8
9
9 tea "github.com/charmbracelet/bubbletea"
10 tea "github.com/charmbracelet/bubbletea"
10− "github.com/kujtimiihoxha/termai/internal/app"
11+ "github.com/kujtimiihoxha/opencode/internal/app"
11− "github.com/kujtimiihoxha/termai/internal/config"
12+ "github.com/kujtimiihoxha/opencode/internal/config"
12− "github.com/kujtimiihoxha/termai/internal/db"
13+ "github.com/kujtimiihoxha/opencode/internal/db"
13− "github.com/kujtimiihoxha/termai/internal/llm/agent"
14+ "github.com/kujtimiihoxha/opencode/internal/llm/agent"
14− "github.com/kujtimiihoxha/termai/internal/logging"
15+ "github.com/kujtimiihoxha/opencode/internal/logging"
15− "github.com/kujtimiihoxha/termai/internal/tui"
16+ "github.com/kujtimiihoxha/opencode/internal/pubsub"
17+ "github.com/kujtimiihoxha/opencode/internal/tui"
16 zone "github.com/lrstanley/bubblezone"
18 zone "github.com/lrstanley/bubblezone"
17 "github.com/spf13/cobra"
19 "github.com/spf13/cobra"
18)
20)
19
21
20var rootCmd = &cobra.Command{
22var rootCmd = &cobra.Command{
21− Use: "termai",
23+ Use: "OpenCode",
22− Short: "A terminal ai assistant",
24+ Short: "A terminal AI assistant for software development",
23− Long: `A terminal ai assistant`,
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.`,
24 RunE: func(cmd *cobra.Command, args []string) error {
28 RunE: func(cmd *cobra.Command, args []string) error {
29+ // If the help flag is set, show the help message
25 if cmd.Flag("help").Changed {
30 if cmd.Flag("help").Changed {
26 cmd.Help()
31 cmd.Help()
27 return nil
32 return nil
28 }
33 }
34+
35+ // Load the config
29 debug, _ := cmd.Flags().GetBool("debug")
36 debug, _ := cmd.Flags().GetBool("debug")
30− err := config.Load(debug)
37+ cwd, _ := cmd.Flags().GetString("cwd")
31− cfg := config.Get()
38+ if cwd != "" {
32− defaultLevel := slog.LevelInfo
39+ err := os.Chdir(cwd)
33− if cfg.Debug {
40+ if err != nil {
34− defaultLevel = slog.LevelDebug
41+ return fmt.Errorf("failed to change directory: %v", err)
42+ }
35 }
43 }
36− logger := slog.New(slog.NewTextHandler(logging.NewWriter(), &slog.HandlerOptions{
44+ if cwd == "" {
37− Level: defaultLevel,
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
38− }))
50+ }
39− slog.SetDefault(logger)
51+ _, err := config.Load(cwd, debug)
40−
41 if err != nil {
52 if err != nil {
42 return err
53 return err
43 }
54 }
55+
56+ // Connect DB, this will also run migrations
44 conn, err := db.Connect()
57 conn, err := db.Connect()
45 if err != nil {
58 if err != nil {
46 return err
59 return err
47 }
60 }
48− ctx := context.Background()
49
61
62+ // Create main context for the application
63+ ctx, cancel := context.WithCancel(context.Background())
64+ defer cancel()
65+
50− app := app.New(ctx, conn)
66+ app, err := app.New(ctx, conn)
51− logging.Info("Starting termai...")
67+ if err != nil {
68+ logging.Error("Failed to create app: %v", err)
69+ return err
70+ }
71+
72+ // Set up the TUI
52 zone.NewGlobal()
73 zone.NewGlobal()
53− tui := tea.NewProgram(
74+ program := tea.NewProgram(
54 tui.New(app),
75 tui.New(app),
55 tea.WithAltScreen(),
76 tea.WithAltScreen(),
56 tea.WithMouseCellMotion(),
77 tea.WithMouseCellMotion(),
57 )
78 )
58− logging.Info("Setting up subscriptions...")
59− ch, unsub := setupSubscriptions(app)
60− defer unsub()
61
79
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
62 go func() {
92 go func() {
63− // Set this up once
93+ defer tuiWg.Done()
64− agent.GetMcpTools(ctx, app.Permissions)
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
65− for msg := range ch {
103+ case msg, ok := <-ch:
104+ if !ok {
105+ logging.Info("TUI message channel closed")
106+ return
107+ }
66− tui.Send(msg)
108+ program.Send(msg)
109+ }
67 }
110 }
68 }()
111 }()
69− if _, err := tui.Run(); err != nil {
112+
70− return err
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")
71 }
128 }
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)
72 return nil
140 return nil
73 },
141 },
74}
142}
75
143
76−func setupSubscriptions(app *app.App) (chan tea.Msg, func()) {
144+// attemptTUIRecovery tries to recover the TUI after a panic
77− ch := make(chan tea.Msg)
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,
78− wg := sync.WaitGroup{}
169+ wg *sync.WaitGroup,
79− ctx, cancel := context.WithCancel(app.Context)
170+ name string,
80− {
171+ subscriber func(context.Context) <-chan pubsub.Event[T],
81− sub := logging.Subscribe(ctx)
172+ outputCh chan<- tea.Msg,
173+) {
82− wg.Add(1)
174+ wg.Add(1)
83− go func() {
175+ go func() {
84− for ev := range sub {
85− ch <- ev
86− }
87− wg.Done()
176+ defer wg.Done()
88− }()
177+ defer logging.RecoverPanic(fmt.Sprintf("subscription-%s", name), nil)
89− }
178+
90− {
179+ subCh := subscriber(ctx)
91− sub := app.Sessions.Subscribe(ctx)
180+
92− wg.Add(1)
181+ for {
93− go func() {
182+ select {
94− for ev := range sub {
183+ case event, ok := <-subCh:
95− ch <- ev
184+ if !ok {
185+ logging.Info("subscription channel closed", "name", name)
186+ return
96− }
187+ }
97− wg.Done()
188+
98− }()
189+ var msg tea.Msg = event
99− }
190+
100− {
191+ select {
101− sub := app.Messages.Subscribe(ctx)
192+ case outputCh <- msg:
102− wg.Add(1)
193+ case <-time.After(2 * time.Second):
103− go func() {
194+ logging.Warn("message dropped due to slow consumer", "name", name)
104− for ev := range sub {
195+ case <-ctx.Done():
105− ch <- ev
196+ logging.Info("subscription cancelled", "name", name)
197+ return
198+ }
199+ case <-ctx.Done():
200+ logging.Info("subscription cancelled", "name", name)
201+ return
106 }
202 }
107− wg.Done()
203+ }
204+ }()
108− }()
205+}
109− }
206+
110− {
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)
111− sub := app.Permissions.Subscribe(ctx)
216+ setupSubscriber(ctx, &wg, "permissions", app.Permissions.Subscribe, ch)
112− wg.Add(1)
217+
218+ cleanupFunc := func() {
219+ logging.Info("Cancelling all subscriptions")
220+ cancel() // Signal all goroutines to stop
221+
222+ waitCh := make(chan struct{})
113 go func() {
223 go func() {
114− for ev := range sub {
224+ defer logging.RecoverPanic("subscription-cleanup", nil)
115− ch <- ev
225+ wg.Wait()
116− }
226+ close(waitCh)
117− wg.Done()
118 }()
227 }()
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+ }
119 }
237 }
120− return ch, func() {
238+ return ch, cleanupFunc
121− cancel()
122− wg.Wait()
123− close(ch)
124− }
125}
239}
126
240
127func Execute() {
241func Execute() {
@@ -133,5 +247,6 @@ func Execute() {
133
247
134func init() {
248func init() {
135 rootCmd.Flags().BoolP("help", "h", false, "Help")
249 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")
137}
252}
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
2
2
3go 1.24.0
3go 1.24.0
4
4
@@ -7,6 +7,7 @@ toolchain go1.24.2
7require (
7require (
8 github.com/JohannesKaufmann/html-to-markdown v1.6.0
8 github.com/JohannesKaufmann/html-to-markdown v1.6.0
9 github.com/PuerkitoBio/goquery v1.9.2
9 github.com/PuerkitoBio/goquery v1.9.2
10+ github.com/alecthomas/chroma/v2 v2.15.0
10 github.com/anthropics/anthropic-sdk-go v0.2.0-beta.2
11 github.com/anthropics/anthropic-sdk-go v0.2.0-beta.2
11 github.com/bmatcuk/doublestar/v4 v4.8.1
12 github.com/bmatcuk/doublestar/v4 v4.8.1
12 github.com/catppuccin/go v0.3.0
13 github.com/catppuccin/go v0.3.0
@@ -15,12 +16,13 @@ require (
15 github.com/charmbracelet/glamour v0.9.1
16 github.com/charmbracelet/glamour v0.9.1
16 github.com/charmbracelet/huh v0.6.0
17 github.com/charmbracelet/huh v0.6.0
17 github.com/charmbracelet/lipgloss v1.1.0
18 github.com/charmbracelet/lipgloss v1.1.0
19+ github.com/charmbracelet/x/ansi v0.8.0
18 github.com/fsnotify/fsnotify v1.8.0
20 github.com/fsnotify/fsnotify v1.8.0
21+ github.com/go-git/go-git/v5 v5.15.0
19 github.com/go-logfmt/logfmt v0.6.0
22 github.com/go-logfmt/logfmt v0.6.0
20 github.com/golang-migrate/migrate/v4 v4.18.2
23 github.com/golang-migrate/migrate/v4 v4.18.2
21 github.com/google/generative-ai-go v0.19.0
24 github.com/google/generative-ai-go v0.19.0
22 github.com/google/uuid v1.6.0
25 github.com/google/uuid v1.6.0
23− github.com/kujtimiihoxha/vimtea v0.0.3-0.20250329221256-a250e98498f9
24 github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231
26 github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231
25 github.com/mark3labs/mcp-go v0.17.0
27 github.com/mark3labs/mcp-go v0.17.0
26 github.com/mattn/go-runewidth v0.0.16
28 github.com/mattn/go-runewidth v0.0.16
@@ -29,11 +31,10 @@ require (
29 github.com/muesli/reflow v0.3.0
31 github.com/muesli/reflow v0.3.0
30 github.com/muesli/termenv v0.16.0
32 github.com/muesli/termenv v0.16.0
31 github.com/openai/openai-go v0.1.0-beta.2
33 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
33 github.com/spf13/cobra v1.9.1
35 github.com/spf13/cobra v1.9.1
34 github.com/spf13/viper v1.20.0
36 github.com/spf13/viper v1.20.0
35 github.com/stretchr/testify v1.10.0
37 github.com/stretchr/testify v1.10.0
36− golang.org/x/net v0.34.0
37 google.golang.org/api v0.215.0
38 google.golang.org/api v0.215.0
38)
39)
39
40
@@ -44,7 +45,9 @@ require (
44 cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
45 cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
45 cloud.google.com/go/compute/metadata v0.6.0 // indirect
46 cloud.google.com/go/compute/metadata v0.6.0 // indirect
46 cloud.google.com/go/longrunning v0.5.7 // indirect
47 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
48 github.com/andybalholm/cascadia v1.3.2 // indirect
51 github.com/andybalholm/cascadia v1.3.2 // indirect
49 github.com/atotto/clipboard v0.1.4 // indirect
52 github.com/atotto/clipboard v0.1.4 // indirect
50 github.com/aws/aws-sdk-go-v2 v1.30.3 // indirect
53 github.com/aws/aws-sdk-go-v2 v1.30.3 // indirect
@@ -64,18 +67,23 @@ require (
64 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
67 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
65 github.com/aymerick/douceur v0.2.0 // indirect
68 github.com/aymerick/douceur v0.2.0 // indirect
66 github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
69 github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
67− github.com/charmbracelet/x/ansi v0.8.0 // indirect
68 github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
70 github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
69 github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
71 github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
70 github.com/charmbracelet/x/term v0.2.1 // indirect
72 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
71 github.com/davecgh/go-spew v1.1.1 // indirect
75 github.com/davecgh/go-spew v1.1.1 // indirect
72 github.com/dlclark/regexp2 v1.11.4 // indirect
76 github.com/dlclark/regexp2 v1.11.4 // indirect
73 github.com/dustin/go-humanize v1.0.1 // indirect
77 github.com/dustin/go-humanize v1.0.1 // indirect
78+ github.com/emirpasic/gods v1.18.1 // indirect
74 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
79 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
75 github.com/felixge/httpsnoop v1.0.4 // indirect
80 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
76 github.com/go-logr/logr v1.4.2 // indirect
83 github.com/go-logr/logr v1.4.2 // indirect
77 github.com/go-logr/stdr v1.2.2 // indirect
84 github.com/go-logr/stdr v1.2.2 // indirect
78 github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
85 github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
86+ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
79 github.com/google/s2a-go v0.1.8 // indirect
87 github.com/google/s2a-go v0.1.8 // indirect
80 github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
88 github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
81 github.com/googleapis/gax-go/v2 v2.14.1 // indirect
89 github.com/googleapis/gax-go/v2 v2.14.1 // indirect
@@ -83,6 +91,8 @@ require (
83 github.com/hashicorp/errwrap v1.1.0 // indirect
91 github.com/hashicorp/errwrap v1.1.0 // indirect
84 github.com/hashicorp/go-multierror v1.1.1 // indirect
92 github.com/hashicorp/go-multierror v1.1.1 // indirect
85 github.com/inconshreveable/mousetrap v1.1.0 // indirect
93 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
86 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
96 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
87 github.com/mattn/go-isatty v0.0.20 // indirect
97 github.com/mattn/go-isatty v0.0.20 // indirect
88 github.com/mattn/go-localereader v0.0.1 // indirect
98 github.com/mattn/go-localereader v0.0.1 // indirect
@@ -90,10 +100,11 @@ require (
90 github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
100 github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
91 github.com/muesli/cancelreader v0.2.2 // indirect
101 github.com/muesli/cancelreader v0.2.2 // indirect
92 github.com/pelletier/go-toml/v2 v2.2.3 // indirect
102 github.com/pelletier/go-toml/v2 v2.2.3 // indirect
103+ github.com/pjbgf/sha1cd v0.3.2 // indirect
93 github.com/pmezard/go-difflib v1.0.0 // indirect
104 github.com/pmezard/go-difflib v1.0.0 // indirect
94 github.com/rivo/uniseg v0.4.7 // indirect
105 github.com/rivo/uniseg v0.4.7 // indirect
95 github.com/sagikazarmark/locafero v0.7.0 // indirect
106 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
97 github.com/sourcegraph/conc v0.3.0 // indirect
108 github.com/sourcegraph/conc v0.3.0 // indirect
98 github.com/spf13/afero v1.12.0 // indirect
109 github.com/spf13/afero v1.12.0 // indirect
99 github.com/spf13/cast v1.7.1 // indirect
110 github.com/spf13/cast v1.7.1 // indirect
@@ -103,6 +114,7 @@ require (
103 github.com/tidwall/match v1.1.1 // indirect
114 github.com/tidwall/match v1.1.1 // indirect
104 github.com/tidwall/pretty v1.2.1 // indirect
115 github.com/tidwall/pretty v1.2.1 // indirect
105 github.com/tidwall/sjson v1.2.5 // indirect
116 github.com/tidwall/sjson v1.2.5 // indirect
117+ github.com/xanzy/ssh-agent v0.3.3 // indirect
106 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
118 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
107 github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
119 github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
108 github.com/yuin/goldmark v1.7.8 // indirect
120 github.com/yuin/goldmark v1.7.8 // indirect
@@ -114,21 +126,18 @@ require (
114 go.opentelemetry.io/otel/trace v1.29.0 // indirect
126 go.opentelemetry.io/otel/trace v1.29.0 // indirect
115 go.uber.org/atomic v1.9.0 // indirect
127 go.uber.org/atomic v1.9.0 // indirect
116 go.uber.org/multierr v1.9.0 // indirect
128 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
129+ golang.org/x/crypto v0.37.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
130+ golang.org/x/net v0.39.0 // indirect
122− golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a // indirect
123 golang.org/x/oauth2 v0.25.0 // indirect
131 golang.org/x/oauth2 v0.25.0 // indirect
124− golang.org/x/sync v0.12.0 // indirect
132+ golang.org/x/sync v0.13.0 // indirect
125− golang.org/x/sys v0.31.0 // indirect
133+ golang.org/x/sys v0.32.0 // indirect
126− golang.org/x/term v0.30.0 // indirect
134+ golang.org/x/term v0.31.0 // indirect
127− golang.org/x/text v0.23.0 // indirect
135+ golang.org/x/text v0.24.0 // indirect
128 golang.org/x/time v0.8.0 // indirect
136 golang.org/x/time v0.8.0 // indirect
129 google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect
137 google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect
130 google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 // indirect
138 google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 // indirect
131 google.golang.org/grpc v1.67.3 // indirect
139 google.golang.org/grpc v1.67.3 // indirect
132 google.golang.org/protobuf v1.36.1 // indirect
140 google.golang.org/protobuf v1.36.1 // indirect
141+ gopkg.in/warnings.v0 v0.1.2 // indirect
133 gopkg.in/yaml.v3 v3.0.1 // indirect
142 gopkg.in/yaml.v3 v3.0.1 // indirect
134)
143)
go.sum+78−34
@@ -10,10 +10,17 @@ cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4
10cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
10cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
11cloud.google.com/go/longrunning v0.5.7 h1:WLbHekDbjK1fVFD3ibpFFVoyizlLRl73I7YKuAKilhU=
11cloud.google.com/go/longrunning v0.5.7 h1:WLbHekDbjK1fVFD3ibpFFVoyizlLRl73I7YKuAKilhU=
12cloud.google.com/go/longrunning v0.5.7/go.mod h1:8GClkudohy1Fxm3owmBGid8W0pSgodEMwEAztp38Xng=
12cloud.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=
13github.com/JohannesKaufmann/html-to-markdown v1.6.0 h1:04VXMiE50YYfCfLboJCLcgqF5x+rHJnb1ssNmqpLH/k=
15github.com/JohannesKaufmann/html-to-markdown v1.6.0 h1:04VXMiE50YYfCfLboJCLcgqF5x+rHJnb1ssNmqpLH/k=
14github.com/JohannesKaufmann/html-to-markdown v1.6.0/go.mod h1:NUI78lGg/a7vpEJTz/0uOcYMaibytE4BUOQS8k78yPQ=
16github.com/JohannesKaufmann/html-to-markdown v1.6.0/go.mod h1:NUI78lGg/a7vpEJTz/0uOcYMaibytE4BUOQS8k78yPQ=
15github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
17github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
16github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
18github.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=
17github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
24github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
18github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
25github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
19github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
26github.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
24github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
31github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
25github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
32github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
26github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
33github.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=
27github.com/anthropics/anthropic-sdk-go v0.2.0-beta.2 h1:h7qxtumNjKPWFv1QM/HJy60MteeW23iKeEtBoY7bYZk=
36github.com/anthropics/anthropic-sdk-go v0.2.0-beta.2 h1:h7qxtumNjKPWFv1QM/HJy60MteeW23iKeEtBoY7bYZk=
28github.com/anthropics/anthropic-sdk-go v0.2.0-beta.2/go.mod h1:AapDW22irxK2PSumZiQXYUFvsdQgkwIWlpESweWZI/c=
37github.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=
29github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
40github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
30github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
41github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
31github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY=
42github.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
88github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
99github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
89github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
100github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
90github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
101github.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=
91github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
104github.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=
92github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
107github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
93github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
108github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
94github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
109github.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
96github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
111github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
97github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
112github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
98github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
113github.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=
99github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
118github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
100github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
119github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
101github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
120github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@@ -104,6 +123,16 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
104github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
123github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
105github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
124github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
106github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
125github.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=
107github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
136github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
108github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
137github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
109github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
138github.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
115github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
144github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
116github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
145github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
117github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
146github.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=
118github.com/google/generative-ai-go v0.19.0 h1:R71szggh8wHMCUlEMsW2A/3T+5LdEIkiaHSYgSpUgdg=
149github.com/google/generative-ai-go v0.19.0 h1:R71szggh8wHMCUlEMsW2A/3T+5LdEIkiaHSYgSpUgdg=
119github.com/google/generative-ai-go v0.19.0/go.mod h1:JYolL13VG7j79kM5BtHz4qwONHkeJQzOCkKXnpqtS/E=
150github.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=
151+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
121−github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
152+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
122github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
153github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
123github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
154github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
124github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
155github.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
138github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
169github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
139github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
170github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
140github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
171github.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=
141github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
176github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
142github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
177github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
143github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
178github.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=
145github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
180github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
146github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
181github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
147github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
182github.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=
152github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
183github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
153github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
184github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
154github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231 h1:9rjt7AfnrXKNSZhp36A3/4QAZAwGGCGD/p8Bse26zms=
185github.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=
178github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
209github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
179github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
210github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
180github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
211github.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=
181github.com/openai/openai-go v0.1.0-beta.2 h1:Ra5nCFkbEl9w+UJwAciC4kqnIBUCcJazhmMA0/YN894=
214github.com/openai/openai-go v0.1.0-beta.2 h1:Ra5nCFkbEl9w+UJwAciC4kqnIBUCcJazhmMA0/YN894=
182github.com/openai/openai-go v0.1.0-beta.2/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
215github.com/openai/openai-go v0.1.0-beta.2/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
183github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
216github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
184github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
217github.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=
185github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
220github.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=
186github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
223github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
187github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
224github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
188github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
225github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
189github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
226github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
190github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
227github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
191github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
228github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
192−github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
229+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
193−github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
230+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
194github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
231github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
195github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
232github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
196github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
233github.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=
199github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y=
234github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y=
200github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
235github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
201github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
236github.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=
203github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
237github.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=
204github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
243github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
205github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
244github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
206github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
245github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
@@ -214,6 +253,7 @@ github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
214github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY=
253github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY=
215github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
254github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
216github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
255github.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=
217github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
257github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
218github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
258github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
219github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
259github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
@@ -230,6 +270,8 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
230github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
270github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
231github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
271github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
232github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
272github.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=
233github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
275github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
234github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
276github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
235github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
277github.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=
254go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
296go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
255go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
297go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
256go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
298go.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=
259golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
299golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
260golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
300golang.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=
261golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
302golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
262golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
303golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
263golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
304golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
264−golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
305+golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
265−golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
306+golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
266−golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw=
307+golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
267−golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
308+golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
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=
274golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
309golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
275golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
310golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
276golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
311golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
277golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
312golang.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=
278golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
314golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
279golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
315golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
280golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
316golang.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=
282golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
318golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
283golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
319golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
284golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
320golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
285−golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
321+golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
286−golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
322+golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
287golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
323golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
288golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
324golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
289golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
325golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
290golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
326golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
291golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
327golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
292−golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
328+golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
293−golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
329+golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
294golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
330golang.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=
295golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
332golang.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=
296golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
335golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
297golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
336golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
298golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
337golang.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=
299golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
339golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
300golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
340golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
301golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
341golang.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=
304golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
344golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
305golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
345golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
306golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
346golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
307−golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
347+golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
308−golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
348+golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
309golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
349golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
310golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
350golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
311golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
351golang.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=
314golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
354golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
315golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
355golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
316golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
356golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
317−golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
357+golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
318−golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
358+golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
319golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
359golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
320golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
360golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
361+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
321golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
362golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
322golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
363golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
323golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
364golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
324golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
365golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
325golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
366golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
326−golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
367+golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
327−golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
368+golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
328golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
369golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
329golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
370golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
330golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
371golang.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
343google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
384google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
344google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
385google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
345gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
386gopkg.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=
347gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
387gopkg.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=
348gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
392gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
349gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
393gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
350gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
394gopkg.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.
1package config
2package config
2
3
3import (
4import (
4 "fmt"
5 "fmt"
6+ "log/slog"
5 "os"
7 "os"
6 "strings"
8 "strings"
7
9
8− "github.com/kujtimiihoxha/termai/internal/llm/models"
10+ "github.com/kujtimiihoxha/opencode/internal/llm/models"
11+ "github.com/kujtimiihoxha/opencode/internal/logging"
9 "github.com/spf13/viper"
12 "github.com/spf13/viper"
10)
13)
11
14
15+// MCPType defines the type of MCP (Model Control Protocol) server.
12type MCPType string
16type MCPType string
13
17
18+// Supported MCP types
14const (
19const (
15 MCPStdio MCPType = "stdio"
20 MCPStdio MCPType = "stdio"
16 MCPSse MCPType = "sse"
21 MCPSse MCPType = "sse"
17)
22)
18
23
24+// MCPServer defines the configuration for a Model Control Protocol server.
19type MCPServer struct {
25type MCPServer struct {
20 Command string `json:"command"`
26 Command string `json:"command"`
21 Env []string `json:"env"`
27 Env []string `json:"env"`
@@ -23,37 +29,35 @@ type MCPServer struct {
23 Type MCPType `json:"type"`
29 Type MCPType `json:"type"`
24 URL string `json:"url"`
30 URL string `json:"url"`
25 Headers map[string]string `json:"headers"`
31 Headers map[string]string `json:"headers"`
26− // TODO: add permissions configuration
27− // TODO: add the ability to specify the tools to import
28}
32}
29
33
30−type Model struct {
34+type AgentName string
31− Coder models.ModelID `json:"coder"`
32− CoderMaxTokens int64 `json:"coderMaxTokens"`
33
35
34− Task models.ModelID `json:"task"`
36+const (
35− TaskMaxTokens int64 `json:"taskMaxTokens"`
37+ AgentCoder AgentName = "coder"
36− // TODO: Maybe support multiple models for different purposes
38+ AgentTask AgentName = "task"
37−}
39+ AgentTitle AgentName = "title"
40+)
38
41
42+// Agent defines configuration for different LLM models and their token limits.
39−type AnthropicConfig struct {
43+type Agent struct {
40− DisableCache bool `json:"disableCache"`
44+ Model models.ModelID `json:"model"`
41− UseBedrock bool `json:"useBedrock"`
45+ MaxTokens int64 `json:"maxTokens"`
46+ ReasoningEffort string `json:"reasoningEffort"` // For openai models low,medium,heigh
42}
47}
43
48
49+// Provider defines configuration for an LLM provider.
44type Provider struct {
50type Provider struct {
45− APIKey string `json:"apiKey"`
51+ APIKey string `json:"apiKey"`
46− Enabled bool `json:"enabled"`
52+ Disabled bool `json:"disabled"`
47}
53}
48
54
55+// Data defines storage configuration.
49type Data struct {
56type Data struct {
50 Directory string `json:"directory"`
57 Directory string `json:"directory"`
51}
58}
52
59
53−type Log struct {
60+// LSPConfig defines configuration for Language Server Protocol integration.
54− Level string `json:"level"`
55−}
56−
57type LSPConfig struct {
61type LSPConfig struct {
58 Disabled bool `json:"enabled"`
62 Disabled bool `json:"enabled"`
59 Command string `json:"command"`
63 Command string `json:"command"`
@@ -61,41 +65,126 @@ type LSPConfig struct {
61 Options any `json:"options"`
65 Options any `json:"options"`
62}
66}
63
67
68+// Config is the main configuration structure for the application.
64type Config struct {
69type Config struct {
65− Data *Data `json:"data,omitempty"`
70+ Data Data `json:"data"`
66− Log *Log `json:"log,omitempty"`
71+ WorkingDir string `json:"wd,omitempty"`
67 MCPServers map[string]MCPServer `json:"mcpServers,omitempty"`
72 MCPServers map[string]MCPServer `json:"mcpServers,omitempty"`
68 Providers map[models.ModelProvider]Provider `json:"providers,omitempty"`
73 Providers map[models.ModelProvider]Provider `json:"providers,omitempty"`
69−
70− LSP map[string]LSPConfig `json:"lsp,omitempty"`
74+ LSP map[string]LSPConfig `json:"lsp,omitempty"`
71−
75+ Agents map[AgentName]Agent `json:"agents"`
72− Model *Model `json:"model,omitempty"`
73−
74− Debug bool `json:"debug,omitempty"`
76+ Debug bool `json:"debug,omitempty"`
77+ DebugLSP bool `json:"debugLSP,omitempty"`
75}
78}
76
79
77−var cfg *Config
80+// Application constants
78−
79const (
81const (
80− defaultDataDirectory = ".termai"
82+ defaultDataDirectory = ".opencode"
81 defaultLogLevel = "info"
83 defaultLogLevel = "info"
82− defaultMaxTokens = int64(5000)
84+ appName = "opencode"
83− termai = "termai"
84)
85)
85
86
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.
86−func Load(debug bool) error {
93+func Load(workingDir string, debug bool) (*Config, error) {
87 if cfg != nil {
94 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
89 }
112 }
90
113
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() {
91− viper.SetConfigName(fmt.Sprintf(".%s", termai))
176+ viper.SetConfigName(fmt.Sprintf(".%s", appName))
92 viper.SetConfigType("json")
177 viper.SetConfigType("json")
93 viper.AddConfigPath("$HOME")
178 viper.AddConfigPath("$HOME")
94− viper.AddConfigPath(fmt.Sprintf("$XDG_CONFIG_HOME/%s", termai))
179+ viper.AddConfigPath(fmt.Sprintf("$XDG_CONFIG_HOME/%s", appName))
95− viper.SetEnvPrefix(strings.ToUpper(termai))
180+ viper.SetEnvPrefix(strings.ToUpper(appName))
181+ viper.AutomaticEnv()
182+}
96
183
97− // Add defaults
184+// setDefaults configures default values for configuration options.
185+func setDefaults(debug bool) {
98 viper.SetDefault("data.directory", defaultDataDirectory)
186 viper.SetDefault("data.directory", defaultDataDirectory)
187+
99 if debug {
188 if debug {
100 viper.SetDefault("debug", true)
189 viper.SetDefault("debug", true)
101 viper.Set("log.level", "debug")
190 viper.Set("log.level", "debug")
@@ -103,98 +192,396 @@ func Load(debug bool) error {
103 viper.SetDefault("debug", false)
192 viper.SetDefault("debug", false)
104 viper.SetDefault("log.level", defaultLogLevel)
193 viper.SetDefault("log.level", defaultLogLevel)
105 }
194 }
195+}
106
196
107− defaultModelSet := false
197+// setProviderDefaults configures LLM provider defaults based on environment variables.
108− if os.Getenv("ANTHROPIC_API_KEY") != "" {
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 != "" {
109− viper.SetDefault("providers.anthropic.apiKey", os.Getenv("ANTHROPIC_API_KEY"))
206+ viper.SetDefault("providers.groq.apiKey", apiKey)
110− viper.SetDefault("providers.anthropic.enabled", true)
207+ viper.SetDefault("agents.coder.model", models.QWENQwq)
111− viper.SetDefault("model.coder", models.Claude37Sonnet)
112− viper.SetDefault("model.task", models.Claude37Sonnet)
208+ viper.SetDefault("agents.task.model", models.QWENQwq)
113− defaultModelSet = true
209+ viper.SetDefault("agents.title.model", models.QWENQwq)
114 }
210 }
115− if os.Getenv("OPENAI_API_KEY") != "" {
211+
212+ // Google Gemini configuration
213+ if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
116− viper.SetDefault("providers.openai.apiKey", os.Getenv("OPENAI_API_KEY"))
214+ viper.SetDefault("providers.gemini.apiKey", apiKey)
117− viper.SetDefault("providers.openai.enabled", true)
215+ viper.SetDefault("agents.coder.model", models.Gemini25)
118− if !defaultModelSet {
119− viper.SetDefault("model.coder", models.GPT41)
120− viper.SetDefault("model.task", models.GPT41)
216+ viper.SetDefault("agents.task.model", models.Gemini25Flash)
121− defaultModelSet = true
217+ viper.SetDefault("agents.title.model", models.Gemini25Flash)
122− }
123 }
218 }
124− if os.Getenv("GEMINI_API_KEY") != "" {
219+
220+ // OpenAI configuration
221+ if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
125− viper.SetDefault("providers.gemini.apiKey", os.Getenv("GEMINI_API_KEY"))
222+ viper.SetDefault("providers.openai.apiKey", apiKey)
126− viper.SetDefault("providers.gemini.enabled", true)
223+ viper.SetDefault("agents.coder.model", models.GPT41)
127− if !defaultModelSet {
128− viper.SetDefault("model.coder", models.GRMINI20Flash)
129− viper.SetDefault("model.task", models.GRMINI20Flash)
224+ viper.SetDefault("agents.task.model", models.GPT41Mini)
130− defaultModelSet = true
225+ viper.SetDefault("agents.title.model", models.GPT41Mini)
131− }
226+
132 }
227 }
133− if os.Getenv("GROQ_API_KEY") != "" {
228+
229+ // Anthropic configuration
230+ if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
134− viper.SetDefault("providers.groq.apiKey", os.Getenv("GROQ_API_KEY"))
231+ viper.SetDefault("providers.anthropic.apiKey", apiKey)
135− viper.SetDefault("providers.groq.enabled", true)
232+ viper.SetDefault("agents.coder.model", models.Claude37Sonnet)
136− if !defaultModelSet {
137− viper.SetDefault("model.coder", models.QWENQwq)
233+ viper.SetDefault("agents.task.model", models.Claude37Sonnet)
138− viper.SetDefault("model.task", models.QWENQwq)
234+ viper.SetDefault("agents.title.model", models.Claude37Sonnet)
139− defaultModelSet = true
140− }
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)
141 }
241 }
242+}
142
243
143− viper.SetDefault("providers.bedrock.enabled", true)
244+// hasAWSCredentials checks if AWS credentials are available in the environment.
144− // TODO: add more providers
245+func hasAWSCredentials() bool {
145− cfg = &Config{}
246+ // Check for explicit AWS credentials
247+ if os.Getenv("AWS_ACCESS_KEY_ID") != "" && os.Getenv("AWS_SECRET_ACCESS_KEY") != "" {
248+ return true
249+ }
146
250
147− err := viper.ReadInConfig()
251+ // Check for AWS profile
148− if err != nil {
252+ if os.Getenv("AWS_PROFILE") != "" || os.Getenv("AWS_DEFAULT_PROFILE") != "" {
149− if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
150− return err
253+ return true
151− }
152 }
254 }
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) {
153 local := viper.New()
286 local := viper.New()
154− local.SetConfigName(fmt.Sprintf(".%s", termai))
287+ local.SetConfigName(fmt.Sprintf(".%s", appName))
155 local.SetConfigType("json")
288 local.SetConfigType("json")
156− local.AddConfigPath(".")
289+ local.AddConfigPath(workingDir)
157− // load local config, this will override the global config
290+
291+ // Merge local config if it exists
158− if err = local.ReadInConfig(); err == nil {
292+ if err := local.ReadInConfig(); err == nil {
159 viper.MergeConfigMap(local.AllSettings())
293 viper.MergeConfigMap(local.AllSettings())
160 }
294 }
161− viper.Unmarshal(cfg)
295+}
162
296
163− if cfg.Model != nil && cfg.Model.CoderMaxTokens <= 0 {
297+// applyDefaultValues sets default values for configuration fields that need processing.
164− cfg.Model.CoderMaxTokens = defaultMaxTokens
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+ }
165 }
305 }
166− if cfg.Model != nil && cfg.Model.TaskMaxTokens <= 0 {
306+}
167− cfg.Model.TaskMaxTokens = defaultMaxTokens
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")
168 }
313 }
169
314
315+ // Validate agent models
170− for _, v := range cfg.MCPServers {
316+ for name, agent := range cfg.Agents {
171− if v.Type == "" {
317+ // Check if model exists
172− v.Type = MCPStdio
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
173 }
441 }
174 }
442 }
175
443
176− workdir, err := os.Getwd()
444+ // Validate providers
177− if err != nil {
445+ for provider, providerCfg := range cfg.Providers {
178− return err
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+ }
179 }
451 }
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+
181 return nil
462 return nil
182}
463}
183
464
184−func Get() *Config {
465+// getProviderAPIKey gets the API key for a provider from environment variables
185− if cfg == nil {
466+func getProviderAPIKey(provider models.ModelProvider) string {
186− err := Load(false)
467+ switch provider {
187− if err != nil {
468+ case models.ProviderAnthropic:
188− panic(err)
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"
189 }
479 }
190 }
480 }
191− return cfg
481+ return ""
192}
482}
193
483
194−func WorkingDirectory() string {
484+// setDefaultModelForAgent sets a default model for an agent based on available providers
195− return viper.GetString("wd")
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
196}
579}
197
580
198−func Write() error {
581+// WorkingDirectory returns the current working directory from the configuration.
199− return viper.WriteConfig()
582+func WorkingDirectory() string {
583+ if cfg == nil {
584+ panic("config not loaded")
585+ }
586+ return cfg.WorkingDir
200}
587}
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 (
12 "github.com/golang-migrate/migrate/v4/database/sqlite3"
12 "github.com/golang-migrate/migrate/v4/database/sqlite3"
13 _ "github.com/mattn/go-sqlite3"
13 _ "github.com/mattn/go-sqlite3"
14
14
15− "github.com/kujtimiihoxha/termai/internal/config"
15+ "github.com/kujtimiihoxha/opencode/internal/config"
16− "github.com/kujtimiihoxha/termai/internal/logging"
16+ "github.com/kujtimiihoxha/opencode/internal/logging"
17)
17)
18
18
19func Connect() (*sql.DB, error) {
19func Connect() (*sql.DB, error) {
@@ -24,7 +24,7 @@ func Connect() (*sql.DB, error) {
24 if err := os.MkdirAll(dataDir, 0o700); err != nil {
24 if err := os.MkdirAll(dataDir, 0o700); err != nil {
25 return nil, fmt.Errorf("failed to create data directory: %w", err)
25 return nil, fmt.Errorf("failed to create data directory: %w", err)
26 }
26 }
27− dbPath := filepath.Join(dataDir, "termai.db")
27+ dbPath := filepath.Join(dataDir, "opencode.db")
28 // Open the SQLite database
28 // Open the SQLite database
29 db, err := sql.Open("sqlite3", dbPath)
29 db, err := sql.Open("sqlite3", dbPath)
30 if err != nil {
30 if err != nil {
@@ -48,9 +48,9 @@ func Connect() (*sql.DB, error) {
48
48
49 for _, pragma := range pragmas {
49 for _, pragma := range pragmas {
50 if _, err = db.Exec(pragma); err != nil {
50 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)
52 } else {
52 } else {
53− logging.Warn("Set pragma", "pragma", pragma)
53+ logging.Debug("Set pragma", "pragma", pragma)
54 }
54 }
55 }
55 }
56
56
internal/db/db.go+126−26
@@ -24,33 +24,63 @@ func New(db DBTX) *Queries {
24func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
24func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
25 q := Queries{db: db}
25 q := Queries{db: db}
26 var err error
26 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+ }
27 if q.createMessageStmt, err = db.PrepareContext(ctx, createMessage); err != nil {
30 if q.createMessageStmt, err = db.PrepareContext(ctx, createMessage); err != nil {
28 return nil, fmt.Errorf("error preparing query CreateMessage: %w", err)
31 return nil, fmt.Errorf("error preparing query CreateMessage: %w", err)
29 }
32 }
30 if q.createSessionStmt, err = db.PrepareContext(ctx, createSession); err != nil {
33 if q.createSessionStmt, err = db.PrepareContext(ctx, createSession); err != nil {
31 return nil, fmt.Errorf("error preparing query CreateSession: %w", err)
34 return nil, fmt.Errorf("error preparing query CreateSession: %w", err)
32 }
35 }
36+ if q.deleteFileStmt, err = db.PrepareContext(ctx, deleteFile); err != nil {
37+ return nil, fmt.Errorf("error preparing query DeleteFile: %w", err)
38+ }
33 if q.deleteMessageStmt, err = db.PrepareContext(ctx, deleteMessage); err != nil {
39 if q.deleteMessageStmt, err = db.PrepareContext(ctx, deleteMessage); err != nil {
34 return nil, fmt.Errorf("error preparing query DeleteMessage: %w", err)
40 return nil, fmt.Errorf("error preparing query DeleteMessage: %w", err)
35 }
41 }
36 if q.deleteSessionStmt, err = db.PrepareContext(ctx, deleteSession); err != nil {
42 if q.deleteSessionStmt, err = db.PrepareContext(ctx, deleteSession); err != nil {
37 return nil, fmt.Errorf("error preparing query DeleteSession: %w", err)
43 return nil, fmt.Errorf("error preparing query DeleteSession: %w", err)
38 }
44 }
45+ if q.deleteSessionFilesStmt, err = db.PrepareContext(ctx, deleteSessionFiles); err != nil {
46+ return nil, fmt.Errorf("error preparing query DeleteSessionFiles: %w", err)
47+ }
39 if q.deleteSessionMessagesStmt, err = db.PrepareContext(ctx, deleteSessionMessages); err != nil {
48 if q.deleteSessionMessagesStmt, err = db.PrepareContext(ctx, deleteSessionMessages); err != nil {
40 return nil, fmt.Errorf("error preparing query DeleteSessionMessages: %w", err)
49 return nil, fmt.Errorf("error preparing query DeleteSessionMessages: %w", err)
41 }
50 }
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+ }
42 if q.getMessageStmt, err = db.PrepareContext(ctx, getMessage); err != nil {
57 if q.getMessageStmt, err = db.PrepareContext(ctx, getMessage); err != nil {
43 return nil, fmt.Errorf("error preparing query GetMessage: %w", err)
58 return nil, fmt.Errorf("error preparing query GetMessage: %w", err)
44 }
59 }
45 if q.getSessionByIDStmt, err = db.PrepareContext(ctx, getSessionByID); err != nil {
60 if q.getSessionByIDStmt, err = db.PrepareContext(ctx, getSessionByID); err != nil {
46 return nil, fmt.Errorf("error preparing query GetSessionByID: %w", err)
61 return nil, fmt.Errorf("error preparing query GetSessionByID: %w", err)
47 }
62 }
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+ }
48 if q.listMessagesBySessionStmt, err = db.PrepareContext(ctx, listMessagesBySession); err != nil {
72 if q.listMessagesBySessionStmt, err = db.PrepareContext(ctx, listMessagesBySession); err != nil {
49 return nil, fmt.Errorf("error preparing query ListMessagesBySession: %w", err)
73 return nil, fmt.Errorf("error preparing query ListMessagesBySession: %w", err)
50 }
74 }
75+ if q.listNewFilesStmt, err = db.PrepareContext(ctx, listNewFiles); err != nil {
76+ return nil, fmt.Errorf("error preparing query ListNewFiles: %w", err)
77+ }
51 if q.listSessionsStmt, err = db.PrepareContext(ctx, listSessions); err != nil {
78 if q.listSessionsStmt, err = db.PrepareContext(ctx, listSessions); err != nil {
52 return nil, fmt.Errorf("error preparing query ListSessions: %w", err)
79 return nil, fmt.Errorf("error preparing query ListSessions: %w", err)
53 }
80 }
81+ if q.updateFileStmt, err = db.PrepareContext(ctx, updateFile); err != nil {
82+ return nil, fmt.Errorf("error preparing query UpdateFile: %w", err)
83+ }
54 if q.updateMessageStmt, err = db.PrepareContext(ctx, updateMessage); err != nil {
84 if q.updateMessageStmt, err = db.PrepareContext(ctx, updateMessage); err != nil {
55 return nil, fmt.Errorf("error preparing query UpdateMessage: %w", err)
85 return nil, fmt.Errorf("error preparing query UpdateMessage: %w", err)
56 }
86 }
@@ -62,6 +92,11 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
62
92
63func (q *Queries) Close() error {
93func (q *Queries) Close() error {
64 var err error
94 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+ }
65 if q.createMessageStmt != nil {
100 if q.createMessageStmt != nil {
66 if cerr := q.createMessageStmt.Close(); cerr != nil {
101 if cerr := q.createMessageStmt.Close(); cerr != nil {
67 err = fmt.Errorf("error closing createMessageStmt: %w", cerr)
102 err = fmt.Errorf("error closing createMessageStmt: %w", cerr)
@@ -72,6 +107,11 @@ func (q *Queries) Close() error {
72 err = fmt.Errorf("error closing createSessionStmt: %w", cerr)
107 err = fmt.Errorf("error closing createSessionStmt: %w", cerr)
73 }
108 }
74 }
109 }
110+ if q.deleteFileStmt != nil {
111+ if cerr := q.deleteFileStmt.Close(); cerr != nil {
112+ err = fmt.Errorf("error closing deleteFileStmt: %w", cerr)
113+ }
114+ }
75 if q.deleteMessageStmt != nil {
115 if q.deleteMessageStmt != nil {
76 if cerr := q.deleteMessageStmt.Close(); cerr != nil {
116 if cerr := q.deleteMessageStmt.Close(); cerr != nil {
77 err = fmt.Errorf("error closing deleteMessageStmt: %w", cerr)
117 err = fmt.Errorf("error closing deleteMessageStmt: %w", cerr)
@@ -82,11 +122,26 @@ func (q *Queries) Close() error {
82 err = fmt.Errorf("error closing deleteSessionStmt: %w", cerr)
122 err = fmt.Errorf("error closing deleteSessionStmt: %w", cerr)
83 }
123 }
84 }
124 }
125+ if q.deleteSessionFilesStmt != nil {
126+ if cerr := q.deleteSessionFilesStmt.Close(); cerr != nil {
127+ err = fmt.Errorf("error closing deleteSessionFilesStmt: %w", cerr)
128+ }
129+ }
85 if q.deleteSessionMessagesStmt != nil {
130 if q.deleteSessionMessagesStmt != nil {
86 if cerr := q.deleteSessionMessagesStmt.Close(); cerr != nil {
131 if cerr := q.deleteSessionMessagesStmt.Close(); cerr != nil {
87 err = fmt.Errorf("error closing deleteSessionMessagesStmt: %w", cerr)
132 err = fmt.Errorf("error closing deleteSessionMessagesStmt: %w", cerr)
88 }
133 }
89 }
134 }
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+ }
90 if q.getMessageStmt != nil {
145 if q.getMessageStmt != nil {
91 if cerr := q.getMessageStmt.Close(); cerr != nil {
146 if cerr := q.getMessageStmt.Close(); cerr != nil {
92 err = fmt.Errorf("error closing getMessageStmt: %w", cerr)
147 err = fmt.Errorf("error closing getMessageStmt: %w", cerr)
@@ -97,16 +152,41 @@ func (q *Queries) Close() error {
97 err = fmt.Errorf("error closing getSessionByIDStmt: %w", cerr)
152 err = fmt.Errorf("error closing getSessionByIDStmt: %w", cerr)
98 }
153 }
99 }
154 }
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+ }
100 if q.listMessagesBySessionStmt != nil {
170 if q.listMessagesBySessionStmt != nil {
101 if cerr := q.listMessagesBySessionStmt.Close(); cerr != nil {
171 if cerr := q.listMessagesBySessionStmt.Close(); cerr != nil {
102 err = fmt.Errorf("error closing listMessagesBySessionStmt: %w", cerr)
172 err = fmt.Errorf("error closing listMessagesBySessionStmt: %w", cerr)
103 }
173 }
104 }
174 }
175+ if q.listNewFilesStmt != nil {
176+ if cerr := q.listNewFilesStmt.Close(); cerr != nil {
177+ err = fmt.Errorf("error closing listNewFilesStmt: %w", cerr)
178+ }
179+ }
105 if q.listSessionsStmt != nil {
180 if q.listSessionsStmt != nil {
106 if cerr := q.listSessionsStmt.Close(); cerr != nil {
181 if cerr := q.listSessionsStmt.Close(); cerr != nil {
107 err = fmt.Errorf("error closing listSessionsStmt: %w", cerr)
182 err = fmt.Errorf("error closing listSessionsStmt: %w", cerr)
108 }
183 }
109 }
184 }
185+ if q.updateFileStmt != nil {
186+ if cerr := q.updateFileStmt.Close(); cerr != nil {
187+ err = fmt.Errorf("error closing updateFileStmt: %w", cerr)
188+ }
189+ }
110 if q.updateMessageStmt != nil {
190 if q.updateMessageStmt != nil {
111 if cerr := q.updateMessageStmt.Close(); cerr != nil {
191 if cerr := q.updateMessageStmt.Close(); cerr != nil {
112 err = fmt.Errorf("error closing updateMessageStmt: %w", cerr)
192 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
154}
234}
155
235
156type Queries struct {
236type Queries struct {
157− db DBTX
237+ db DBTX
158− tx *sql.Tx
238+ tx *sql.Tx
239+ createFileStmt *sql.Stmt
159− createMessageStmt *sql.Stmt
240+ createMessageStmt *sql.Stmt
160− createSessionStmt *sql.Stmt
241+ createSessionStmt *sql.Stmt
242+ deleteFileStmt *sql.Stmt
161− deleteMessageStmt *sql.Stmt
243+ deleteMessageStmt *sql.Stmt
162− deleteSessionStmt *sql.Stmt
244+ deleteSessionStmt *sql.Stmt
245+ deleteSessionFilesStmt *sql.Stmt
163− deleteSessionMessagesStmt *sql.Stmt
246+ deleteSessionMessagesStmt *sql.Stmt
247+ getFileStmt *sql.Stmt
248+ getFileByPathAndSessionStmt *sql.Stmt
164− getMessageStmt *sql.Stmt
249+ getMessageStmt *sql.Stmt
165− getSessionByIDStmt *sql.Stmt
250+ getSessionByIDStmt *sql.Stmt
251+ listFilesByPathStmt *sql.Stmt
252+ listFilesBySessionStmt *sql.Stmt
253+ listLatestSessionFilesStmt *sql.Stmt
166− listMessagesBySessionStmt *sql.Stmt
254+ listMessagesBySessionStmt *sql.Stmt
255+ listNewFilesStmt *sql.Stmt
167− listSessionsStmt *sql.Stmt
256+ listSessionsStmt *sql.Stmt
257+ updateFileStmt *sql.Stmt
168− updateMessageStmt *sql.Stmt
258+ updateMessageStmt *sql.Stmt
169− updateSessionStmt *sql.Stmt
259+ updateSessionStmt *sql.Stmt
170}
260}
171
261
172func (q *Queries) WithTx(tx *sql.Tx) *Queries {
262func (q *Queries) WithTx(tx *sql.Tx) *Queries {
173 return &Queries{
263 return &Queries{
174− db: tx,
264+ db: tx,
175− tx: tx,
265+ tx: tx,
266+ createFileStmt: q.createFileStmt,
176− createMessageStmt: q.createMessageStmt,
267+ createMessageStmt: q.createMessageStmt,
177− createSessionStmt: q.createSessionStmt,
268+ createSessionStmt: q.createSessionStmt,
269+ deleteFileStmt: q.deleteFileStmt,
178− deleteMessageStmt: q.deleteMessageStmt,
270+ deleteMessageStmt: q.deleteMessageStmt,
179− deleteSessionStmt: q.deleteSessionStmt,
271+ deleteSessionStmt: q.deleteSessionStmt,
272+ deleteSessionFilesStmt: q.deleteSessionFilesStmt,
180− deleteSessionMessagesStmt: q.deleteSessionMessagesStmt,
273+ deleteSessionMessagesStmt: q.deleteSessionMessagesStmt,
181− getMessageStmt: q.getMessageStmt,
274+ getFileStmt: q.getFileStmt,
182− getSessionByIDStmt: q.getSessionByIDStmt,
275+ getFileByPathAndSessionStmt: q.getFileByPathAndSessionStmt,
183− listMessagesBySessionStmt: q.listMessagesBySessionStmt,
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,
184− listSessionsStmt: q.listSessionsStmt,
283+ listSessionsStmt: q.listSessionsStmt,
284+ updateFileStmt: q.updateFileStmt,
185− updateMessageStmt: q.updateMessageStmt,
285+ updateMessageStmt: q.updateMessageStmt,
186− updateSessionStmt: q.updateSessionStmt,
286+ updateSessionStmt: q.updateSessionStmt,
187 }
287 }
188}
288}
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
7
7
8import (
8import (
9 "context"
9 "context"
10+ "database/sql"
10)
11)
11
12
12const createMessage = `-- name: CreateMessage :one
13const createMessage = `-- name: CreateMessage :one
@@ -15,19 +16,21 @@ INSERT INTO messages (
15 session_id,
16 session_id,
16 role,
17 role,
17 parts,
18 parts,
19+ model,
18 created_at,
20 created_at,
19 updated_at
21 updated_at
20) VALUES (
22) VALUES (
21− ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
23+ ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
22)
24)
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
24`
26`
25
27
26type CreateMessageParams struct {
28type CreateMessageParams struct {
27− ID string `json:"id"`
29+ ID string `json:"id"`
28− SessionID string `json:"session_id"`
30+ SessionID string `json:"session_id"`
29− Role string `json:"role"`
31+ Role string `json:"role"`
30− Parts string `json:"parts"`
32+ Parts string `json:"parts"`
33+ Model sql.NullString `json:"model"`
31}
34}
32
35
33func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error) {
36func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error) {
@@ -36,6 +39,7 @@ func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (M
36 arg.SessionID,
39 arg.SessionID,
37 arg.Role,
40 arg.Role,
38 arg.Parts,
41 arg.Parts,
42+ arg.Model,
39 )
43 )
40 var i Message
44 var i Message
41 err := row.Scan(
45 err := row.Scan(
@@ -43,8 +47,10 @@ func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (M
43 &i.SessionID,
47 &i.SessionID,
44 &i.Role,
48 &i.Role,
45 &i.Parts,
49 &i.Parts,
50+ &i.Model,
46 &i.CreatedAt,
51 &i.CreatedAt,
47 &i.UpdatedAt,
52 &i.UpdatedAt,
53+ &i.FinishedAt,
48 )
54 )
49 return i, err
55 return i, err
50}
56}
@@ -70,7 +76,7 @@ func (q *Queries) DeleteSessionMessages(ctx context.Context, sessionID string) e
70}
76}
71
77
72const getMessage = `-- name: GetMessage :one
78const 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
74FROM messages
80FROM messages
75WHERE id = ? LIMIT 1
81WHERE id = ? LIMIT 1
76`
82`
@@ -83,14 +89,16 @@ func (q *Queries) GetMessage(ctx context.Context, id string) (Message, error) {
83 &i.SessionID,
89 &i.SessionID,
84 &i.Role,
90 &i.Role,
85 &i.Parts,
91 &i.Parts,
92+ &i.Model,
86 &i.CreatedAt,
93 &i.CreatedAt,
87 &i.UpdatedAt,
94 &i.UpdatedAt,
95+ &i.FinishedAt,
88 )
96 )
89 return i, err
97 return i, err
90}
98}
91
99
92const listMessagesBySession = `-- name: ListMessagesBySession :many
100const 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
94FROM messages
102FROM messages
95WHERE session_id = ?
103WHERE session_id = ?
96ORDER BY created_at ASC
104ORDER BY created_at ASC
@@ -110,8 +118,10 @@ func (q *Queries) ListMessagesBySession(ctx context.Context, sessionID string) (
110 &i.SessionID,
118 &i.SessionID,
111 &i.Role,
119 &i.Role,
112 &i.Parts,
120 &i.Parts,
121+ &i.Model,
113 &i.CreatedAt,
122 &i.CreatedAt,
114 &i.UpdatedAt,
123 &i.UpdatedAt,
124+ &i.FinishedAt,
115 ); err != nil {
125 ); err != nil {
116 return nil, err
126 return nil, err
117 }
127 }
@@ -130,16 +140,18 @@ const updateMessage = `-- name: UpdateMessage :exec
130UPDATE messages
140UPDATE messages
131SET
141SET
132 parts = ?,
142 parts = ?,
143+ finished_at = ?,
133 updated_at = strftime('%s', 'now')
144 updated_at = strftime('%s', 'now')
134WHERE id = ?
145WHERE id = ?
135`
146`
136
147
137type UpdateMessageParams struct {
148type UpdateMessageParams struct {
138− Parts string `json:"parts"`
149+ Parts string `json:"parts"`
150+ FinishedAt sql.NullInt64 `json:"finished_at"`
139− ID string `json:"id"`
151+ ID string `json:"id"`
140}
152}
141
153
142func (q *Queries) UpdateMessage(ctx context.Context, arg UpdateMessageParams) error {
154func (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)
144 return err
156 return err
145}
157}
internal/db/migrations/000001_initial.down.sql+2
@@ -1,8 +1,10 @@
1DROP TRIGGER IF EXISTS update_sessions_updated_at;
1DROP TRIGGER IF EXISTS update_sessions_updated_at;
2DROP TRIGGER IF EXISTS update_messages_updated_at;
2DROP TRIGGER IF EXISTS update_messages_updated_at;
3+DROP TRIGGER IF EXISTS update_files_updated_at;
3
4
4DROP TRIGGER IF EXISTS update_session_message_count_on_delete;
5DROP TRIGGER IF EXISTS update_session_message_count_on_delete;
5DROP TRIGGER IF EXISTS update_session_message_count_on_insert;
6DROP TRIGGER IF EXISTS update_session_message_count_on_insert;
6
7
7DROP TABLE IF EXISTS sessions;
8DROP TABLE IF EXISTS sessions;
8DROP TABLE IF EXISTS messages;
9DROP 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')
18WHERE id = new.id;
18WHERE id = new.id;
19END;
19END;
20
20
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+
21-- Messages
44-- Messages
22CREATE TABLE IF NOT EXISTS messages (
45CREATE TABLE IF NOT EXISTS messages (
23 id TEXT PRIMARY KEY,
46 id TEXT PRIMARY KEY,
24 session_id TEXT NOT NULL,
47 session_id TEXT NOT NULL,
25 role TEXT NOT NULL,
48 role TEXT NOT NULL,
26 parts TEXT NOT NULL default '[]',
49 parts TEXT NOT NULL default '[]',
50+ model TEXT,
27 created_at INTEGER NOT NULL, -- Unix timestamp in milliseconds
51 created_at INTEGER NOT NULL, -- Unix timestamp in milliseconds
28 updated_at INTEGER NOT NULL, -- Unix timestamp in milliseconds
52 updated_at INTEGER NOT NULL, -- Unix timestamp in milliseconds
53+ finished_at INTEGER, -- Unix timestamp in milliseconds
29 FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE
54 FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE
30);
55);
31
56
internal/db/models.go+15−3
@@ -8,15 +8,27 @@ import (
8 "database/sql"
8 "database/sql"
9)
9)
10
10
11−type Message struct {
11+type File struct {
12 ID string `json:"id"`
12 ID string `json:"id"`
13 SessionID string `json:"session_id"`
13 SessionID string `json:"session_id"`
14− Role string `json:"role"`
14+ Path string `json:"path"`
15− Parts string `json:"parts"`
15+ Content string `json:"content"`
16+ Version string `json:"version"`
16 CreatedAt int64 `json:"created_at"`
17 CreatedAt int64 `json:"created_at"`
17 UpdatedAt int64 `json:"updated_at"`
18 UpdatedAt int64 `json:"updated_at"`
18}
19}
19
20
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+
20type Session struct {
32type Session struct {
21 ID string `json:"id"`
33 ID string `json:"id"`
22 ParentSessionID sql.NullString `json:"parent_session_id"`
34 ParentSessionID sql.NullString `json:"parent_session_id"`
internal/db/querier.go+10
@@ -9,15 +9,25 @@ import (
9)
9)
10
10
11type Querier interface {
11type Querier interface {
12+ CreateFile(ctx context.Context, arg CreateFileParams) (File, error)
12 CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error)
13 CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error)
13 CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error)
14 CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error)
15+ DeleteFile(ctx context.Context, id string) error
14 DeleteMessage(ctx context.Context, id string) error
16 DeleteMessage(ctx context.Context, id string) error
15 DeleteSession(ctx context.Context, id string) error
17 DeleteSession(ctx context.Context, id string) error
18+ DeleteSessionFiles(ctx context.Context, sessionID string) error
16 DeleteSessionMessages(ctx context.Context, sessionID string) error
19 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)
17 GetMessage(ctx context.Context, id string) (Message, error)
22 GetMessage(ctx context.Context, id string) (Message, error)
18 GetSessionByID(ctx context.Context, id string) (Session, error)
23 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)
19 ListMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
27 ListMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
28+ ListNewFiles(ctx context.Context) ([]File, error)
20 ListSessions(ctx context.Context) ([]Session, error)
29 ListSessions(ctx context.Context) ([]Session, error)
30+ UpdateFile(ctx context.Context, arg UpdateFileParams) (File, error)
21 UpdateMessage(ctx context.Context, arg UpdateMessageParams) error
31 UpdateMessage(ctx context.Context, arg UpdateMessageParams) error
22 UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error)
32 UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error)
23}
33}
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 (
15 session_id,
15 session_id,
16 role,
16 role,
17 parts,
17 parts,
18+ model,
18 created_at,
19 created_at,
19 updated_at
20 updated_at
20) VALUES (
21) VALUES (
21− ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
22+ ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
22)
23)
23RETURNING *;
24RETURNING *;
24
25
@@ -26,9 +27,11 @@ RETURNING *;
26UPDATE messages
27UPDATE messages
27SET
28SET
28 parts = ?,
29 parts = ?,
30+ finished_at = ?,
29 updated_at = strftime('%s', 'now')
31 updated_at = strftime('%s', 'now')
30WHERE id = ?;
32WHERE id = ?;
31
33
34+
32-- name: DeleteMessage :exec
35-- name: DeleteMessage :exec
33DELETE FROM messages
36DELETE FROM messages
34WHERE id = ?;
37WHERE 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 (
5 "encoding/json"
5 "encoding/json"
6 "fmt"
6 "fmt"
7
7
8− "github.com/kujtimiihoxha/termai/internal/app"
8+ "github.com/kujtimiihoxha/opencode/internal/config"
9− "github.com/kujtimiihoxha/termai/internal/llm/tools"
9+ "github.com/kujtimiihoxha/opencode/internal/llm/tools"
10− "github.com/kujtimiihoxha/termai/internal/message"
10+ "github.com/kujtimiihoxha/opencode/internal/lsp"
11+ "github.com/kujtimiihoxha/opencode/internal/message"
12+ "github.com/kujtimiihoxha/opencode/internal/session"
11)
13)
12
14
13type agentTool struct {
15type agentTool struct {
14− parentSessionID string
16+ sessions session.Service
15− app *app.App
17+ messages message.Service
18+ lspClients map[string]*lsp.Client
16}
19}
17
20
18const (
21const (
@@ -46,57 +49,63 @@ func (b *agentTool) Run(ctx context.Context, call tools.ToolCall) (tools.ToolRes
46 return tools.NewTextErrorResponse("prompt is required"), nil
49 return tools.NewTextErrorResponse("prompt is required"), nil
47 }
50 }
48
51
49− agent, err := NewTaskAgent(b.app)
52+ sessionID, messageID := tools.GetContextValues(ctx)
50− if err != nil {
53+ if sessionID == "" || messageID == "" {
51− return tools.NewTextErrorResponse(fmt.Sprintf("error creating agent: %s", err)), nil
54+ return tools.ToolResponse{}, fmt.Errorf("session_id and message_id are required")
52 }
55 }
53
56
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))
55 if err != nil {
58 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)
57 }
60 }
58
61
59− err = agent.Generate(ctx, session.ID, params.Prompt)
62+ session, err := b.sessions.CreateTaskSession(ctx, call.ID, sessionID, "New Agent Session")
60 if err != nil {
63 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)
62 }
65 }
63
66
64− messages, err := b.app.Messages.List(session.ID)
67+ done, err := agent.Run(ctx, session.ID, params.Prompt)
65 if err != nil {
68 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)
67 }
70 }
68− if len(messages) == 0 {
71+ result := <-done
69− return tools.NewTextErrorResponse("no messages found"), nil
72+ if result.Err() != nil {
73+ return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", result.Err())
70 }
74 }
71
75
72− response := messages[len(messages)-1]
76+ response := result.Response()
73 if response.Role != message.Assistant {
77 if response.Role != message.Assistant {
74− return tools.NewTextErrorResponse("no assistant message found"), nil
78+ return tools.NewTextErrorResponse("no response"), nil
75 }
79 }
76
80
77− updatedSession, err := b.app.Sessions.Get(session.ID)
81+ updatedSession, err := b.sessions.Get(ctx, session.ID)
78 if err != nil {
82 if err != nil {
79− return tools.NewTextErrorResponse(fmt.Sprintf("error: %s", err)), nil
83+ return tools.ToolResponse{}, fmt.Errorf("error getting session: %s", err)
80 }
84 }
81− parentSession, err := b.app.Sessions.Get(b.parentSessionID)
85+ parentSession, err := b.sessions.Get(ctx, sessionID)
82 if err != nil {
86 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)
84 }
88 }
85
89
86 parentSession.Cost += updatedSession.Cost
90 parentSession.Cost += updatedSession.Cost
87 parentSession.PromptTokens += updatedSession.PromptTokens
91 parentSession.PromptTokens += updatedSession.PromptTokens
88 parentSession.CompletionTokens += updatedSession.CompletionTokens
92 parentSession.CompletionTokens += updatedSession.CompletionTokens
89
93
90− _, err = b.app.Sessions.Save(parentSession)
94+ _, err = b.sessions.Save(ctx, parentSession)
91 if err != nil {
95 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)
93 }
97 }
94 return tools.NewTextResponse(response.Content().String()), nil
98 return tools.NewTextResponse(response.Content().String()), nil
95}
99}
96
100
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 {
98 return &agentTool{
106 return &agentTool{
99− parentSessionID: parentSessionID,
107+ sessions: Sessions,
100− app: app,
108+ messages: Messages,
109+ lspClients: LspClients,
101 }
110 }
102}
111}
internal/llm/agent/agent.go+395−421
@@ -7,30 +7,123 @@ import (
7 "strings"
7 "strings"
8 "sync"
8 "sync"
9
9
10− "github.com/kujtimiihoxha/termai/internal/app"
10+ "github.com/kujtimiihoxha/opencode/internal/config"
11− "github.com/kujtimiihoxha/termai/internal/config"
12− "github.com/kujtimiihoxha/termai/internal/llm/models"
11+ "github.com/kujtimiihoxha/opencode/internal/llm/models"
13− "github.com/kujtimiihoxha/termai/internal/llm/prompt"
12+ "github.com/kujtimiihoxha/opencode/internal/llm/prompt"
14− "github.com/kujtimiihoxha/termai/internal/llm/provider"
13+ "github.com/kujtimiihoxha/opencode/internal/llm/provider"
15− "github.com/kujtimiihoxha/termai/internal/llm/tools"
14+ "github.com/kujtimiihoxha/opencode/internal/llm/tools"
16− "github.com/kujtimiihoxha/termai/internal/logging"
15+ "github.com/kujtimiihoxha/opencode/internal/logging"
17− "github.com/kujtimiihoxha/termai/internal/message"
16+ "github.com/kujtimiihoxha/opencode/internal/message"
17+ "github.com/kujtimiihoxha/opencode/internal/permission"
18+ "github.com/kujtimiihoxha/opencode/internal/session"
18)
19)
19
20
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+
20−type Agent interface {
40+type Service interface {
21− Generate(ctx context.Context, sessionID string, content string) error
41+ Run(ctx context.Context, sessionID string, content string) (<-chan AgentEvent, error)
42+ Cancel(sessionID string)
43+ IsSessionBusy(sessionID string) bool
44+ IsBusy() bool
22}
45}
23
46
24type agent struct {
47type agent struct {
25− *app.App
48+ sessions session.Service
26− model models.Model
49+ messages message.Service
50+
27− tools []tools.BaseTool
51+ tools []tools.BaseTool
28− agent provider.Provider
52+ provider provider.Provider
53+
29− titleGenerator provider.Provider
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
30}
116}
31
117
32−func (c *agent) handleTitleGeneration(ctx context.Context, sessionID, content string) {
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+ }
33− response, err := c.titleGenerator.SendMessages(
126+ response, err := a.titleProvider.SendMessages(
34 ctx,
127 ctx,
35 []message.Message{
128 []message.Message{
36 {
129 {
@@ -42,476 +135,357 @@ func (c *agent) handleTitleGeneration(ctx context.Context, sessionID, content st
42 },
135 },
43 },
136 },
44 },
137 },
45− nil,
138+ make([]tools.BaseTool, 0),
46 )
139 )
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)
65 if err != nil {
140 if err != nil {
66 return err
141 return err
67 }
142 }
68
143
69− cost := model.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) +
144+ title := strings.TrimSpace(strings.ReplaceAll(response.Content, "\n", " "))
70− model.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) +
145+ if title == "" {
71− model.CostPer1MIn/1e6*float64(usage.InputTokens) +
146+ return nil
72− model.CostPer1MOut/1e6*float64(usage.OutputTokens)
147+ }
73−
74− session.Cost += cost
75− session.CompletionTokens += usage.OutputTokens
76− session.PromptTokens += usage.InputTokens
77
148
149+ session.Title = title
78− _, err = c.Sessions.Save(session)
150+ _, err = a.sessions.Save(ctx, session)
79 return err
151 return err
80}
152}
81
153
82−func (c *agent) processEvent(
154+func (a *agent) err(err error) AgentEvent {
83− sessionID string,
155+ return AgentEvent{
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
156+ err: err,
111− }
112− return c.TrackUsage(sessionID, c.model, event.Response.Usage)
113 }
157 }
114−
115− return nil
116}
158}
117
159
118−func (c *agent) ExecuteTools(ctx context.Context, toolCalls []message.ToolCall, tls []tools.BaseTool) ([]message.ToolResult, error) {
160+func (a *agent) Run(ctx context.Context, sessionID string, content string) (<-chan AgentEvent, error) {
119− var wg sync.WaitGroup
161+ events := make(chan AgentEvent)
120− toolResults := make([]message.ToolResult, len(toolCalls))
162+ if a.IsSessionBusy(sessionID) {
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
163+ return nil, ErrSessionBusy
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)
201 }
164 }
202
165
203− // Wait for all goroutines to finish or context to be canceled
166+ genCtx, cancel := context.WithCancel(ctx)
204− done := make(chan struct{})
205− go func() {
206− wg.Wait()
207− close(done)
208− }()
209
167
210− select {
168+ a.activeRequests.Store(sessionID, cancel)
211− case <-done:
169+ go func() {
212− // All tools completed successfully
170+ logging.Debug("Request started", "sessionID", sessionID)
213− case err := <-errChan:
171+ defer logging.RecoverPanic("agent.Run", func() {
214− // One of the tools encountered a cancellation
172+ events <- a.err(fmt.Errorf("panic while running the agent"))
215− return toolResults, err
216− case <-ctx.Done():
217− // Context was canceled externally
218− return toolResults, ctx.Err()
219− }
173+ })
220
174
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+ }()
221− return toolResults, nil
185+ return events, nil
222}
186}
223
187
224−func (c *agent) handleToolExecution(
188+func (a *agent) processGeneration(ctx context.Context, sessionID, content string) AgentEvent {
225− ctx context.Context,
189+ // List existing messages; if none, start title generation asynchronously.
226− assistantMsg message.Message,
190+ msgs, err := a.messages.List(ctx, sessionID)
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)
233 if err != nil {
191 if err != nil {
234− return nil, err
192+ return a.err(fmt.Errorf("failed to list messages: %w", err))
235 }
193 }
236− parts := make([]message.ContentPart, 0)
194+ if len(msgs) == 0 {
237− for _, toolResult := range toolResults {
195+ go func() {
238− parts = append(parts, toolResult)
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+ }()
239 }
204 }
240− msg, err := c.Messages.Create(assistantMsg.SessionID, message.CreateMessageParams{
241− Role: message.Tool,
242− Parts: parts,
243− })
244−
245− return &msg, err
246−}
247
205
248−func (c *agent) generate(ctx context.Context, sessionID string, content string) error {
206+ userMsg, err := a.createUserMessage(ctx, sessionID, content)
249− messages, err := c.Messages.List(sessionID)
250 if err != nil {
207 if err != nil {
251− return err
208+ return a.err(fmt.Errorf("failed to create user message: %w", err))
252 }
209 }
253
210
254− if len(messages) == 0 {
211+ // Append the new user message to the conversation history.
255− go c.handleTitleGeneration(ctx, sessionID, content)
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+ }
256 }
239 }
240+}
257
241
242+func (a *agent) createUserMessage(ctx context.Context, sessionID, content string) (message.Message, error) {
258− userMsg, err := c.Messages.Create(sessionID, message.CreateMessageParams{
243+ return a.messages.Create(ctx, sessionID, message.CreateMessageParams{
259 Role: message.User,
244 Role: message.User,
260 Parts: []message.ContentPart{
245 Parts: []message.ContentPart{
261− message.TextContent{
246+ message.TextContent{Text: content},
262− Text: content,
263− },
264 },
247 },
265 })
248 })
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+ })
266 if err != nil {
259 if err != nil {
267− return err
260+ return assistantMsg, nil, fmt.Errorf("failed to create assistant message: %w", err)
268 }
261 }
269
262
270− messages = append(messages, userMsg)
263+ // Add the session and message ID into the context if needed by tools.
271− for {
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 {
272 select {
282 select {
273 case <-ctx.Done():
283 case <-ctx.Done():
274− assistantMsg, err := c.Messages.Create(sessionID, message.CreateMessageParams{
284+ a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled)
275− Role: message.Assistant,
285+ // Make all future tool calls cancelled
276− Parts: []message.ContentPart{},
286+ for j := i; j < len(toolCalls); j++ {
277− })
287+ toolResults[j] = message.ToolResult{
278− if err != nil {
288+ ToolCallID: toolCalls[j].ID,
279− return err
289+ Content: "Tool execution canceled by user",
290+ IsError: true,
291+ }
280 }
292 }
281− assistantMsg.AddFinish("canceled")
293+ goto out
282− c.Messages.Update(assistantMsg)
283− return context.Canceled
284 default:
294 default:
285 // Continue processing
295 // Continue processing
286− }
296+ var tool tools.BaseTool
287−
297+ for _, availableTools := range a.tools {
288− eventChan, err := c.agent.StreamResponse(ctx, messages, c.tools)
298+ if availableTools.Info().Name == toolCall.Name {
289− if err != nil {
299+ tool = availableTools
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
297 }
300 }
298− assistantMsg.AddFinish("canceled")
299− c.Messages.Update(assistantMsg)
300− return context.Canceled
301 }
301 }
302− return err
303− }
304
302
305− assistantMsg, err := c.Messages.Create(sessionID, message.CreateMessageParams{
303+ // Tool not found
306− Role: message.Assistant,
304+ if tool == nil {
307− Parts: []message.ContentPart{},
305+ toolResults[i] = message.ToolResult{
308− })
306+ ToolCallID: toolCall.ID,
309− if err != nil {
307+ Content: fmt.Sprintf("Tool not found: %s", toolCall.Name),
310− return err
308+ IsError: true,
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
319 }
309 }
320− assistantMsg.AddFinish("error:" + err.Error())
310+ continue
321− c.Messages.Update(assistantMsg)
322− return err
323 }
311 }
324
312
325− select {
313+ toolResult, toolErr := tool.Run(ctx, tools.ToolCall{
326− case <-ctx.Done():
314+ ID: toolCall.ID,
327− assistantMsg.AddFinish("canceled")
315+ Name: toolCall.Name,
328− c.Messages.Update(assistantMsg)
316+ Input: toolCall.Input,
329− return context.Canceled
317+ })
330− default:
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+ }
331 }
335 }
332− }
336+ toolResults[i] = message.ToolResult{
333−
337+ ToolCallID: toolCall.ID,
334− // Check for context cancellation before tool execution
338+ Content: toolResult.Content,
335− select {
339+ Metadata: toolResult.Metadata,
336− case <-ctx.Done():
340+ IsError: toolResult.IsError,
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
350 }
341 }
351− return err
352 }
342 }
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+ }
353
359
354− c.Messages.Update(assistantMsg)
360+ return assistantMsg, &msg, err
361+}
355
362
356− if len(assistantMsg.ToolCalls()) == 0 {
363+func (a *agent) finishMessage(ctx context.Context, msg *message.Message, finishReson message.FinishReason) {
357− break
364+ msg.AddFinish(finishReson)
365+ _ = a.messages.Update(ctx, *msg)
358− }
366+}
359
367
360− messages = append(messages, assistantMsg)
368+func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg *message.Message, event provider.ProviderEvent) error {
361− if msg != nil {
369+ select {
362− messages = append(messages, *msg)
370+ case <-ctx.Done():
371+ return ctx.Err()
372+ default:
373+ // Continue processing.
363− }
374+ }
364
375
365− // Check for context cancellation after tool execution
376+ switch event.Type {
366− select {
377+ case provider.EventThinkingDelta:
367− case <-ctx.Done():
378+ assistantMsg.AppendReasoningContent(event.Content)
368− assistantMsg.AddFinish("canceled")
379+ return a.messages.Update(ctx, *assistantMsg)
369− c.Messages.Update(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))
370 return context.Canceled
401 return context.Canceled
371− default:
372− // Continue processing
373 }
402 }
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)
374 }
412 }
413+
375 return nil
414 return nil
376}
415}
377
416
378−func getAgentProviders(ctx context.Context, model models.Model) (provider.Provider, provider.Provider, error) {
417+func (a *agent) TrackUsage(ctx context.Context, sessionID string, model models.Model, usage provider.TokenUsage) error {
379− maxTokens := config.Get().Model.CoderMaxTokens
418+ sess, err := a.sessions.Get(ctx, sessionID)
380−
419+ if err != nil {
381− providerConfig, ok := config.Get().Providers[model.Provider]
420+ return fmt.Errorf("failed to get session: %w", err)
382− if !ok || !providerConfig.Enabled {
383− return nil, nil, errors.New("provider is not enabled")
384 }
421 }
385− var agentProvider provider.Provider
386− var titleGenerator provider.Provider
387
422
388− switch model.Provider {
423+ cost := model.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) +
389− case models.ProviderOpenAI:
424+ model.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) +
390− var err error
425+ model.CostPer1MIn/1e6*float64(usage.InputTokens) +
391− agentProvider, err = provider.NewOpenAIProvider(
426+ model.CostPer1MOut/1e6*float64(usage.OutputTokens)
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− }
437
427
438− case models.ProviderGemini:
428+ sess.Cost += cost
439− var err error
429+ sess.CompletionTokens += usage.OutputTokens
440− agentProvider, err = provider.NewGeminiProvider(
430+ sess.PromptTokens += usage.InputTokens
441− ctx,
431+
442− provider.WithGeminiSystemMessage(
432+ _, err = a.sessions.Save(ctx, sess)
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 {
433+ if err != nil {
462− return nil, nil, err
434+ return fmt.Errorf("failed to save session: %w", err)
463− }
435+ }
464− case models.ProviderGROQ:
436+ return nil
465− var err error
437+}
466− agentProvider, err = provider.NewOpenAIProvider(
438+
467− provider.WithOpenAISystemMessage(
439+func createAgentProvider(agentName config.AgentName) (provider.Provider, error) {
468− prompt.CoderAnthropicSystemPrompt(),
440+ cfg := config.Get()
469− ),
441+ agentConfig, ok := cfg.Agents[agentName]
470− provider.WithOpenAIMaxTokens(maxTokens),
442+ if !ok {
471− provider.WithOpenAIModel(model),
443+ return nil, fmt.Errorf("agent %s not found", agentName)
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− }
444+ }
478− titleGenerator, err = provider.NewOpenAIProvider(
445+ model, ok := models.SupportedModels[agentConfig.Model]
479− provider.WithOpenAISystemMessage(
446+ if !ok {
480− prompt.TitlePrompt(),
447+ return nil, fmt.Errorf("model %s not supported", agentConfig.Model)
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− }
448+ }
490
449
491− case models.ProviderBedrock:
450+ providerCfg, ok := cfg.Providers[model.Provider]
492− var err error
451+ if !ok {
493− agentProvider, err = provider.NewBedrockProvider(
452+ return nil, fmt.Errorf("provider %s not supported", model.Provider)
494− provider.WithBedrockSystemMessage(
453+ }
495− prompt.CoderAnthropicSystemPrompt(),
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),
496 ),
472 ),
497− provider.WithBedrockMaxTokens(maxTokens),
498− provider.WithBedrockModel(model),
499 )
473 )
500− if err != nil {
474+ } else if model.Provider == models.ProviderAnthropic && model.CanReason && agentName == config.AgentCoder {
501− return nil, nil, err
475+ opts = append(
502− }
476+ opts,
503− titleGenerator, err = provider.NewBedrockProvider(
477+ provider.WithAnthropicOptions(
504− provider.WithBedrockSystemMessage(
478+ provider.WithAnthropicShouldThinkFn(provider.DefaultShouldThinkFn),
505− prompt.TitlePrompt(),
506 ),
479 ),
507− provider.WithBedrockMaxTokens(maxTokens),
508− provider.WithBedrockModel(model),
509 )
480 )
481+ }
482+ agentProvider, err := provider.NewProvider(
483+ model.Provider,
484+ opts...,
485+ )
510− if err != nil {
486+ if err != nil {
511− return nil, nil, err
487+ return nil, fmt.Errorf("could not create provider: %v", err)
512− }
513−
514 }
488 }
515
489
516− return agentProvider, titleGenerator, nil
490+ return agentProvider, nil
517}
491}
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 (
5 "encoding/json"
5 "encoding/json"
6 "fmt"
6 "fmt"
7
7
8− "github.com/kujtimiihoxha/termai/internal/config"
8+ "github.com/kujtimiihoxha/opencode/internal/config"
9− "github.com/kujtimiihoxha/termai/internal/llm/tools"
9+ "github.com/kujtimiihoxha/opencode/internal/llm/tools"
10− "github.com/kujtimiihoxha/termai/internal/logging"
10+ "github.com/kujtimiihoxha/opencode/internal/logging"
11− "github.com/kujtimiihoxha/termai/internal/permission"
11+ "github.com/kujtimiihoxha/opencode/internal/permission"
12− "github.com/kujtimiihoxha/termai/internal/version"
12+ "github.com/kujtimiihoxha/opencode/internal/version"
13
13
14 "github.com/mark3labs/mcp-go/client"
14 "github.com/mark3labs/mcp-go/client"
15 "github.com/mark3labs/mcp-go/mcp"
15 "github.com/mark3labs/mcp-go/mcp"
@@ -46,7 +46,7 @@ func runTool(ctx context.Context, c MCPClient, toolName string, input string) (t
46 initRequest := mcp.InitializeRequest{}
46 initRequest := mcp.InitializeRequest{}
47 initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
47 initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
48 initRequest.Params.ClientInfo = mcp.Implementation{
48 initRequest.Params.ClientInfo = mcp.Implementation{
49− Name: "termai",
49+ Name: "OpenCode",
50 Version: version.Version,
50 Version: version.Version,
51 }
51 }
52
52
@@ -80,9 +80,14 @@ func runTool(ctx context.Context, c MCPClient, toolName string, input string) (t
80}
80}
81
81
82func (b *mcpTool) Run(ctx context.Context, params tools.ToolCall) (tools.ToolResponse, error) {
82func (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+ }
83 permissionDescription := fmt.Sprintf("execute %s with the following parameters: %s", b.Info().Name, params.Input)
87 permissionDescription := fmt.Sprintf("execute %s with the following parameters: %s", b.Info().Name, params.Input)
84 p := b.permissions.Request(
88 p := b.permissions.Request(
85 permission.CreatePermissionRequest{
89 permission.CreatePermissionRequest{
90+ SessionID: sessionID,
86 Path: config.WorkingDirectory(),
91 Path: config.WorkingDirectory(),
87 ToolName: b.Info().Name,
92 ToolName: b.Info().Name,
88 Action: "execute",
93 Action: "execute",
@@ -135,7 +140,7 @@ func getTools(ctx context.Context, name string, m config.MCPServer, permissions
135 initRequest := mcp.InitializeRequest{}
140 initRequest := mcp.InitializeRequest{}
136 initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
141 initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
137 initRequest.Params.ClientInfo = mcp.Implementation{
142 initRequest.Params.ClientInfo = mcp.Implementation{
138− Name: "termai",
143+ Name: "OpenCode",
139 Version: version.Version,
144 Version: version.Version,
140 }
145 }
141
146
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 @@
1package models
1package models
2
2
3+import "maps"
4+
3type (
5type (
4 ModelID string
6 ModelID string
5 ModelProvider string
7 ModelProvider string
@@ -14,21 +16,13 @@ type Model struct {
14 CostPer1MOut float64 `json:"cost_per_1m_out"`
16 CostPer1MOut float64 `json:"cost_per_1m_out"`
15 CostPer1MInCached float64 `json:"cost_per_1m_in_cached"`
17 CostPer1MInCached float64 `json:"cost_per_1m_in_cached"`
16 CostPer1MOutCached float64 `json:"cost_per_1m_out_cached"`
18 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"`
17}
22}
18
23
19// Model IDs
24// 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
25+const ( // GEMINI
29− GEMINI25 ModelID = "gemini-2.5"
30− GRMINI20Flash ModelID = "gemini-2.0-flash"
31−
32 // GROQ
26 // GROQ
33 QWENQwq ModelID = "qwen-qwq"
27 QWENQwq ModelID = "qwen-qwq"
34
28
@@ -37,94 +31,51 @@ const (
37)
31)
38
32
39const (
33const (
40− ProviderOpenAI ModelProvider = "openai"
41− ProviderAnthropic ModelProvider = "anthropic"
42− ProviderBedrock ModelProvider = "bedrock"
34+ ProviderBedrock ModelProvider = "bedrock"
43− ProviderGemini ModelProvider = "gemini"
44− ProviderGROQ ModelProvider = "groq"
35+ ProviderGROQ ModelProvider = "groq"
36+
37+ // ForTests
38+ ProviderMock ModelProvider = "__mock"
45)
39)
46
40
47var SupportedModels = map[ModelID]Model{
41var SupportedModels = map[ModelID]Model{
48− // Anthropic
42+ //
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
43+ // // GEMINI
93− GEMINI25: {
44+ // GEMINI25: {
94− ID: GEMINI25,
45+ // ID: GEMINI25,
95− Name: "Gemini 2.5 Pro",
46+ // Name: "Gemini 2.5 Pro",
96− Provider: ProviderGemini,
47+ // Provider: ProviderGemini,
97− APIModel: "gemini-2.5-pro-exp-03-25",
48+ // APIModel: "gemini-2.5-pro-exp-03-25",
98− CostPer1MIn: 0,
49+ // CostPer1MIn: 0,
99− CostPer1MInCached: 0,
50+ // CostPer1MInCached: 0,
100− CostPer1MOutCached: 0,
51+ // CostPer1MOutCached: 0,
101− CostPer1MOut: 0,
52+ // CostPer1MOut: 0,
102− },
53+ // },
103−
54+ //
104− GRMINI20Flash: {
55+ // GRMINI20Flash: {
105− ID: GRMINI20Flash,
56+ // ID: GRMINI20Flash,
106− Name: "Gemini 2.0 Flash",
57+ // Name: "Gemini 2.0 Flash",
107− Provider: ProviderGemini,
58+ // Provider: ProviderGemini,
108− APIModel: "gemini-2.0-flash",
59+ // APIModel: "gemini-2.0-flash",
109− CostPer1MIn: 0.1,
60+ // CostPer1MIn: 0.1,
110− CostPer1MInCached: 0,
61+ // CostPer1MInCached: 0,
111− CostPer1MOutCached: 0.025,
62+ // CostPer1MOutCached: 0.025,
112− CostPer1MOut: 0.4,
63+ // CostPer1MOut: 0.4,
113− },
64+ // },
114−
65+ //
115− // GROQ
66+ // // GROQ
116− QWENQwq: {
67+ // QWENQwq: {
117− ID: QWENQwq,
68+ // ID: QWENQwq,
118− Name: "Qwen Qwq",
69+ // Name: "Qwen Qwq",
119− Provider: ProviderGROQ,
70+ // Provider: ProviderGROQ,
120− APIModel: "qwen-qwq-32b",
71+ // APIModel: "qwen-qwq-32b",
121− CostPer1MIn: 0,
72+ // CostPer1MIn: 0,
122− CostPer1MInCached: 0,
73+ // CostPer1MInCached: 0,
123− CostPer1MOutCached: 0,
74+ // CostPer1MOutCached: 0,
124− CostPer1MOut: 0,
75+ // CostPer1MOut: 0,
125− },
76+ // },
126−
77+ //
127− // Bedrock
78+ // // Bedrock
128 BedrockClaude37Sonnet: {
79 BedrockClaude37Sonnet: {
129 ID: BedrockClaude37Sonnet,
80 ID: BedrockClaude37Sonnet,
130 Name: "Bedrock: Claude 3.7 Sonnet",
81 Name: "Bedrock: Claude 3.7 Sonnet",
@@ -136,3 +87,9 @@ var SupportedModels = map[ModelID]Model{
136 CostPer1MOut: 15.0,
87 CostPer1MOut: 15.0,
137 },
88 },
138}
89}
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 (
8 "runtime"
8 "runtime"
9 "time"
9 "time"
10
10
11− "github.com/kujtimiihoxha/termai/internal/config"
11+ "github.com/kujtimiihoxha/opencode/internal/config"
12− "github.com/kujtimiihoxha/termai/internal/llm/tools"
12+ "github.com/kujtimiihoxha/opencode/internal/llm/models"
13+ "github.com/kujtimiihoxha/opencode/internal/llm/tools"
13)
14)
14
15
15−func CoderOpenAISystemPrompt() string {
16+func CoderPrompt(provider models.ModelProvider) 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+ basePrompt := baseAnthropicCoderPrompt
17−
18+ switch provider {
18−# Your mindset
19+ case models.ProviderOpenAI:
19−Act like a competent, efficient software engineer who is familiar with large codebases. You should:
20+ basePrompt = baseOpenAICoderPrompt
20−- Think critically about user requests.
21+ }
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−
68 envInfo := getEnvironmentInfo()
22 envInfo := getEnvironmentInfo()
69
23
70 return fmt.Sprintf("%s\n\n%s\n%s", basePrompt, envInfo, lspInformation())
24 return fmt.Sprintf("%s\n\n%s\n%s", basePrompt, envInfo, lspInformation())
71}
25}
72
26
73−func CoderAnthropicSystemPrompt() string {
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− 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.
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.
75
75
76IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
76IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
77
77
78# Memory
78# 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:
801. Storing frequently used bash commands (build, test, lint, etc.) so you can use them without searching each time
801. Storing frequently used bash commands (build, test, lint, etc.) so you can use them without searching each time
812. Recording the user's code style preferences (naming conventions, preferred libraries, etc.)
812. Recording the user's code style preferences (naming conventions, preferred libraries, etc.)
823. Maintaining useful information about the codebase structure and organization
823. Maintaining useful information about the codebase structure and organization
83
83
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.
85
85
86# Tone and style
86# Tone and style
87You 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).
87You 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
131
131
132<example>
132<example>
133user: write tests for new feature
133user: 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]
135</example>
135</example>
136
136
137# Proactiveness
137# Proactiveness
@@ -156,21 +156,17 @@ The user will primarily request you perform software engineering tasks. This inc
1561. 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.
1561. 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.
1572. Implement the solution using all tools available to you
1572. Implement the solution using all tools available to you
1583. 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.
1583. 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.
160
160
161NEVER 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.
161NEVER 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.
162
162
163# Tool usage policy
163# Tool usage policy
164- When doing file search, prefer to use the Agent tool in order to reduce context usage.
164- When doing file search, prefer to use the Agent tool in order to reduce context usage.
165- 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.
165- 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.
166
167
167You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.`
168You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.`
168
169
169− envInfo := getEnvironmentInfo()
170−
171− return fmt.Sprintf("%s\n\n%s\n%s", basePrompt, envInfo, lspInformation())
172−}
173−
174func getEnvironmentInfo() string {
170func getEnvironmentInfo() string {
175 cwd := config.WorkingDirectory()
171 cwd := config.WorkingDirectory()
176 isGit := isGitRepo(cwd)
172 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
2
2
3import (
3import (
4 "fmt"
4 "fmt"
5−)
6
5
7−func TaskAgentSystemPrompt() string {
6+ "github.com/kujtimiihoxha/opencode/internal/llm/models"
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.
7+)
9
8
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.
10Notes:
11Notes:
111. 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...".
121. 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...".
122. When relevant, share file names and code snippets relevant to the query
132. When relevant, share file names and code snippets relevant to the query
internal/llm/prompt/title.go+4−1
@@ -1,9 +1,12 @@
1package prompt
1package prompt
2
2
3+import "github.com/kujtimiihoxha/opencode/internal/llm/models"
4+
3−func TitlePrompt() string {
5+func TitlePrompt(_ models.ModelProvider) string {
4 return `you will generate a short title based on the first message a user begins a conversation with
6 return `you will generate a short title based on the first message a user begins a conversation with
5- ensure it is not more than 50 characters long
7- ensure it is not more than 50 characters long
6- the title should be a summary of the user's message
8- the title should be a summary of the user's message
9+- it should be one line long
7- do not use quotes or colons
10- do not use quotes or colons
8- the entire text you return will be used as the title`
11- the entire text you return will be used as the title`
9}
12}
internal/llm/provider/anthropic.go+316−266
@@ -12,192 +12,275 @@ import (
12 "github.com/anthropics/anthropic-sdk-go"
12 "github.com/anthropics/anthropic-sdk-go"
13 "github.com/anthropics/anthropic-sdk-go/bedrock"
13 "github.com/anthropics/anthropic-sdk-go/bedrock"
14 "github.com/anthropics/anthropic-sdk-go/option"
14 "github.com/anthropics/anthropic-sdk-go/option"
15− "github.com/kujtimiihoxha/termai/internal/llm/models"
15+ "github.com/kujtimiihoxha/opencode/internal/config"
16− "github.com/kujtimiihoxha/termai/internal/llm/tools"
16+ "github.com/kujtimiihoxha/opencode/internal/llm/tools"
17− "github.com/kujtimiihoxha/termai/internal/message"
17+ "github.com/kujtimiihoxha/opencode/internal/logging"
18+ "github.com/kujtimiihoxha/opencode/internal/message"
18)
19)
19
20
20−type anthropicProvider struct {
21+type anthropicOptions struct {
21− client anthropic.Client
22− model models.Model
23− maxTokens int64
24− apiKey string
25− systemMessage string
26− useBedrock bool
22+ useBedrock bool
27− disableCache bool
23+ disableCache bool
24+ shouldThink func(userMessage string) bool
28}
25}
29
26
30−type AnthropicOption func(*anthropicProvider)
27+type AnthropicOption func(*anthropicOptions)
31
28
32−func WithAnthropicSystemMessage(message string) AnthropicOption {
29+type anthropicClient struct {
33− return func(a *anthropicProvider) {
30+ providerOptions providerClientOptions
34− a.systemMessage = message
31+ options anthropicOptions
35− }
32+ client anthropic.Client
36}
33}
37
34
38−func WithAnthropicMaxTokens(maxTokens int64) AnthropicOption {
35+type AnthropicClient ProviderClient
39− return func(a *anthropicProvider) {
40− a.maxTokens = maxTokens
41− }
42−}
43
36
44−func WithAnthropicModel(model models.Model) AnthropicOption {
37+func newAnthropicClient(opts providerClientOptions) AnthropicClient {
45− return func(a *anthropicProvider) {
38+ anthropicOpts := anthropicOptions{}
46− a.model = model
39+ for _, o := range opts.anthropicOptions {
40+ o(&anthropicOpts)
47 }
41 }
48−}
49
42
50−func WithAnthropicKey(apiKey string) AnthropicOption {
43+ anthropicClientOptions := []option.RequestOption{}
51− return func(a *anthropicProvider) {
44+ if opts.apiKey != "" {
52− a.apiKey = apiKey
45+ anthropicClientOptions = append(anthropicClientOptions, option.WithAPIKey(opts.apiKey))
53 }
46 }
54−}
47+ if anthropicOpts.useBedrock {
55−
48+ anthropicClientOptions = append(anthropicClientOptions, bedrock.WithLoadDefaultConfig(context.Background()))
56−func WithAnthropicBedrock() AnthropicOption {
57− return func(a *anthropicProvider) {
58− a.useBedrock = true
59 }
49 }
60−}
61
50
62−func WithAnthropicDisableCache() AnthropicOption {
51+ client := anthropic.NewClient(anthropicClientOptions...)
63− return func(a *anthropicProvider) {
52+ return &anthropicClient{
64− a.disableCache = true
53+ providerOptions: opts,
54+ options: anthropicOpts,
55+ client: client,
65 }
56 }
66}
57}
67
58
68−func NewAnthropicProvider(opts ...AnthropicOption) (Provider, error) {
59+func (a *anthropicClient) convertMessages(messages []message.Message) (anthropicMessages []anthropic.MessageParam) {
69− provider := &anthropicProvider{
60+ for i, msg := range messages {
70− maxTokens: 1024,
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+ }
71− }
72+ }
73+ anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(content))
72
74
73− for _, opt := range opts {
75+ case message.Assistant:
74− opt(provider)
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)
75− }
85+ }
76
86
77− if provider.systemMessage == "" {
87+ for _, toolCall := range msg.ToolCalls() {
78− return nil, errors.New("system message is required")
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))
79− }
94+ }
80
95
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...))
82
101
83− if provider.apiKey != "" {
102+ case message.Tool:
84− anthropicOptions = append(anthropicOptions, option.WithAPIKey(provider.apiKey))
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)
85− }
106+ }
86− if provider.useBedrock {
107+ anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(results...))
87− anthropicOptions = append(anthropicOptions, bedrock.WithLoadDefaultConfig(context.Background()))
108+ }
88 }
109 }
89−
90− provider.client = anthropic.NewClient(anthropicOptions...)
91− return provider, nil
110+ return
92}
111}
93
112
94−func (a *anthropicProvider) SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
113+func (a *anthropicClient) convertTools(tools []tools.BaseTool) []anthropic.ToolUnionParam {
95− messages = cleanupMessages(messages)
114+ anthropicTools := make([]anthropic.ToolUnionParam, len(tools))
96− anthropicMessages := a.convertToAnthropicMessages(messages)
115+
97− anthropicTools := a.convertToAnthropicTools(tools)
116+ for i, tool := range tools {
98−
117+ info := tool.Info()
99− response, err := a.client.Messages.New(
118+ toolParam := anthropic.ToolParam{
100− ctx,
119+ Name: info.Name,
101− anthropic.MessageNewParams{
120+ Description: anthropic.String(info.Description),
102− Model: anthropic.Model(a.model.APIModel),
121+ InputSchema: anthropic.ToolInputSchemaParam{
103− MaxTokens: a.maxTokens,
122+ Properties: info.Parameters,
104− Temperature: anthropic.Float(0),
123+ // TODO: figure out how we can tell claude the required fields?
105− Messages: anthropicMessages,
106− Tools: anthropicTools,
107− System: []anthropic.TextBlockParam{
108− {
109− Text: a.systemMessage,
110− CacheControl: anthropic.CacheControlEphemeralParam{
111− Type: "ephemeral",
112− },
113− },
114 },
124 },
115− },
125+ }
116− )
117− if err != nil {
118− return nil, err
119− }
120
126
121− content := ""
127+ if i == len(tools)-1 && !a.options.disableCache {
122− for _, block := range response.Content {
128+ toolParam.CacheControl = anthropic.CacheControlEphemeralParam{
123− if text, ok := block.AsAny().(anthropic.TextBlock); ok {
129+ Type: "ephemeral",
124− content += text.Text
130+ }
125 }
131 }
126− }
127
132
128− toolCalls := a.extractToolCalls(response.Content)
133+ anthropicTools[i] = anthropic.ToolUnionParam{OfTool: &toolParam}
129− tokenUsage := a.extractTokenUsage(response.Usage)
134+ }
130
135
131− return &ProviderResponse{
136+ return anthropicTools
132− Content: content,
133− ToolCalls: toolCalls,
134− Usage: tokenUsage,
135− }, nil
136}
137}
137
138
138−func (a *anthropicProvider) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (<-chan ProviderEvent, error) {
139+func (a *anthropicClient) finishReason(reason string) message.FinishReason {
139− messages = cleanupMessages(messages)
140+ switch reason {
140− anthropicMessages := a.convertToAnthropicMessages(messages)
141+ case "end_turn":
141− anthropicTools := a.convertToAnthropicTools(tools)
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+}
142
153
154+func (a *anthropicClient) preparedMessages(messages []anthropic.MessageParam, tools []anthropic.ToolUnionParam) anthropic.MessageNewParams {
143 var thinkingParam anthropic.ThinkingConfigParamUnion
155 var thinkingParam anthropic.ThinkingConfigParamUnion
144 lastMessage := messages[len(messages)-1]
156 lastMessage := messages[len(messages)-1]
157+ isUser := lastMessage.Role == anthropic.MessageParamRoleUser
158+ messageContent := ""
145 temperature := anthropic.Float(0)
159 temperature := anthropic.Float(0)
146− if lastMessage.Role == message.User && strings.Contains(strings.ToLower(lastMessage.Content().String()), "think") {
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) {
147− thinkingParam = anthropic.ThinkingConfigParamUnion{
167+ thinkingParam = anthropic.ThinkingConfigParamUnion{
148− OfThinkingConfigEnabled: &anthropic.ThinkingConfigEnabledParam{
168+ OfThinkingConfigEnabled: &anthropic.ThinkingConfigEnabledParam{
149− BudgetTokens: int64(float64(a.maxTokens) * 0.8),
169+ BudgetTokens: int64(float64(a.providerOptions.maxTokens) * 0.8),
150− Type: "enabled",
170+ Type: "enabled",
151− },
171+ },
172+ }
173+ temperature = anthropic.Float(1)
152 }
174 }
153− temperature = anthropic.Float(1)
154 }
175 }
155
176
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+}
157
194
158− go func() {
195+func (a *anthropicClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (resposne *ProviderResponse, err error) {
159− defer close(eventChan)
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+ }
160
226
161− const maxRetries = 8
227+ content := ""
162− attempts := 0
228+ for _, block := range anthropicResponse.Content {
229+ if text, ok := block.AsAny().(anthropic.TextBlock); ok {
230+ content += text.Text
231+ }
232+ }
163
233
164− for {
234+ return &ProviderResponse{
235+ Content: content,
236+ ToolCalls: a.toolCalls(*anthropicResponse),
237+ Usage: a.usage(*anthropicResponse),
238+ }, nil
239+ }
240+}
165
241
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 {
166 attempts++
253 attempts++
167−
168− stream := a.client.Messages.NewStreaming(
254+ anthropicStream := a.client.Messages.NewStreaming(
169 ctx,
255 ctx,
170− anthropic.MessageNewParams{
256+ preparedMessages,
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− },
186 )
257 )
187−
188 accumulatedMessage := anthropic.Message{}
258 accumulatedMessage := anthropic.Message{}
189
259
260+ currentToolCallID := ""
190− for stream.Next() {
261+ for anthropicStream.Next() {
191− event := stream.Current()
262+ event := anthropicStream.Current()
192 err := accumulatedMessage.Accumulate(event)
263 err := accumulatedMessage.Accumulate(event)
193 if err != nil {
264 if err != nil {
194 eventChan <- ProviderEvent{Type: EventError, Error: err}
265 eventChan <- ProviderEvent{Type: EventError, Error: err}
195− return // Don't retry on accumulation errors
266+ continue
196 }
267 }
197
268
198 switch event := event.AsAny().(type) {
269 switch event := event.AsAny().(type) {
199 case anthropic.ContentBlockStartEvent:
270 case anthropic.ContentBlockStartEvent:
271+ if event.ContentBlock.Type == "text" {
200− eventChan <- ProviderEvent{Type: EventContentStart}
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+ }
201
284
202 case anthropic.ContentBlockDeltaEvent:
285 case anthropic.ContentBlockDeltaEvent:
203 if event.Delta.Type == "thinking_delta" && event.Delta.Thinking != "" {
286 if event.Delta.Type == "thinking_delta" && event.Delta.Thinking != "" {
@@ -210,10 +293,30 @@ func (a *anthropicProvider) StreamResponse(ctx context.Context, messages []messa
210 Type: EventContentDelta,
293 Type: EventContentDelta,
211 Content: event.Delta.Text,
294 Content: event.Delta.Text,
212 }
295 }
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+ }
213 }
307 }
214−
215 case anthropic.ContentBlockStopEvent:
308 case anthropic.ContentBlockStopEvent:
309+ if currentToolCallID != "" {
310+ eventChan <- ProviderEvent{
311+ Type: EventToolUseStop,
312+ ToolCall: &message.ToolCall{
313+ ID: currentToolCallID,
314+ },
315+ }
316+ currentToolCallID = ""
317+ } else {
216− eventChan <- ProviderEvent{Type: EventContentStop}
318+ eventChan <- ProviderEvent{Type: EventContentStop}
319+ }
217
320
218 case anthropic.MessageStopEvent:
321 case anthropic.MessageStopEvent:
219 content := ""
322 content := ""
@@ -223,91 +326,95 @@ func (a *anthropicProvider) StreamResponse(ctx context.Context, messages []messa
223 }
326 }
224 }
327 }
225
328
226− toolCalls := a.extractToolCalls(accumulatedMessage.Content)
227− tokenUsage := a.extractTokenUsage(accumulatedMessage.Usage)
228−
229 eventChan <- ProviderEvent{
329 eventChan <- ProviderEvent{
230 Type: EventComplete,
330 Type: EventComplete,
231 Response: &ProviderResponse{
331 Response: &ProviderResponse{
232 Content: content,
332 Content: content,
233− ToolCalls: toolCalls,
333+ ToolCalls: a.toolCalls(accumulatedMessage),
234− Usage: tokenUsage,
334+ Usage: a.usage(accumulatedMessage),
235− FinishReason: string(accumulatedMessage.StopReason),
335+ FinishReason: a.finishReason(string(accumulatedMessage.StopReason)),
236 },
336 },
237 }
337 }
238 }
338 }
239 }
339 }
240
340
241− err := stream.Err()
341+ err := anthropicStream.Err()
242 if err == nil || errors.Is(err, io.EOF) {
342 if err == nil || errors.Is(err, io.EOF) {
343+ close(eventChan)
243 return
344 return
244 }
345 }
245−
346+ // If there is an error we are going to see if we can retry the call
246− var apierr *anthropic.Error
347+ retry, after, retryErr := a.shouldRetry(attempts, err)
247− if !errors.As(err, &apierr) {
348+ if retryErr != nil {
248− eventChan <- ProviderEvent{Type: EventError, Error: err}
349+ eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
350+ close(eventChan)
249 return
351 return
250 }
352 }
251−
353+ if retry {
252− if apierr.StatusCode != 429 && apierr.StatusCode != 529 {
354+ logging.WarnPersist("Retrying due to rate limit... attempt %d of %d", logging.PersistTimeArg, time.Millisecond*time.Duration(after+100))
253− eventChan <- ProviderEvent{Type: EventError, Error: err}
355+ select {
254− return
356+ case <-ctx.Done():
255− }
357+ // context cancelled
256−
358+ if ctx.Err() != nil {
257− if attempts > maxRetries {
359+ eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
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),
274 }
360 }
361+ close(eventChan)
362+ return
363+ case <-time.After(time.Duration(after) * time.Millisecond):
364+ continue
275 }
365 }
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
285 }
366 }
286− select {
367+ if ctx.Err() != nil {
287− case <-ctx.Done():
288 eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
368 eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
289− return
290− case <-time.After(time.Duration(retryMs) * time.Millisecond):
291− continue
292 }
369 }
293
370
371+ close(eventChan)
372+ return
294 }
373 }
295 }()
374 }()
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+ }
296
383
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
298}
404}
299
405
300−func (a *anthropicProvider) extractToolCalls(content []anthropic.ContentBlockUnion) []message.ToolCall {
406+func (a *anthropicClient) toolCalls(msg anthropic.Message) []message.ToolCall {
301 var toolCalls []message.ToolCall
407 var toolCalls []message.ToolCall
302
408
303− for _, block := range content {
409+ for _, block := range msg.Content {
304 switch variant := block.AsAny().(type) {
410 switch variant := block.AsAny().(type) {
305 case anthropic.ToolUseBlock:
411 case anthropic.ToolUseBlock:
306 toolCall := message.ToolCall{
412 toolCall := message.ToolCall{
307− ID: variant.ID,
413+ ID: variant.ID,
308− Name: variant.Name,
414+ Name: variant.Name,
309− Input: string(variant.Input),
415+ Input: string(variant.Input),
310− Type: string(variant.Type),
416+ Type: string(variant.Type),
417+ Finished: true,
311 }
418 }
312 toolCalls = append(toolCalls, toolCall)
419 toolCalls = append(toolCalls, toolCall)
313 }
420 }
@@ -316,90 +423,33 @@ func (a *anthropicProvider) extractToolCalls(content []anthropic.ContentBlockUni
316 return toolCalls
423 return toolCalls
317}
424}
318
425
319−func (a *anthropicProvider) extractTokenUsage(usage anthropic.Usage) TokenUsage {
426+func (a *anthropicClient) usage(msg anthropic.Message) TokenUsage {
320 return TokenUsage{
427 return TokenUsage{
321− InputTokens: usage.InputTokens,
428+ InputTokens: msg.Usage.InputTokens,
322− OutputTokens: usage.OutputTokens,
429+ OutputTokens: msg.Usage.OutputTokens,
323− CacheCreationTokens: usage.CacheCreationInputTokens,
430+ CacheCreationTokens: msg.Usage.CacheCreationInputTokens,
324− CacheReadTokens: usage.CacheReadInputTokens,
431+ CacheReadTokens: msg.Usage.CacheReadInputTokens,
325 }
432 }
326}
433}
327
434
328−func (a *anthropicProvider) convertToAnthropicTools(tools []tools.BaseTool) []anthropic.ToolUnionParam {
435+func WithAnthropicBedrock(useBedrock bool) AnthropicOption {
329− anthropicTools := make([]anthropic.ToolUnionParam, len(tools))
436+ return func(options *anthropicOptions) {
330−
437+ options.useBedrock = useBedrock
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}
348 }
438 }
349−
350− return anthropicTools
351}
439}
352
440
353−func (a *anthropicProvider) convertToAnthropicMessages(messages []message.Message) []anthropic.MessageParam {
441+func WithAnthropicDisableCache() AnthropicOption {
354− anthropicMessages := make([]anthropic.MessageParam, 0, len(messages))
442+ return func(options *anthropicOptions) {
355− cachedBlocks := 0
443+ options.disableCache = true
356−
444+ }
357− for _, msg := range messages {
445+}
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− }
390
446
391− if len(blocks) > 0 {
447+func DefaultShouldThinkFn(s string) bool {
392− anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...))
448+ return strings.Contains(strings.ToLower(s), "think")
393− }
449+}
394
450
395− case message.Tool:
451+func WithAnthropicShouldThinkFn(fn func(string) bool) AnthropicOption {
396− results := make([]anthropic.ContentBlockParamUnion, len(msg.ToolResults()))
452+ return func(options *anthropicOptions) {
397− for i, toolResult := range msg.ToolResults() {
453+ options.shouldThink = fn
398− results[i] = anthropic.NewToolResultBlock(toolResult.ToolCallID, toolResult.Content, toolResult.IsError)
399− }
400− anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(results...))
401− }
402 }
454 }
403−
404− return anthropicMessages
405}
455}
internal/llm/provider/bedrock.go+59−46
@@ -7,33 +7,29 @@ import (
7 "os"
7 "os"
8 "strings"
8 "strings"
9
9
10− "github.com/kujtimiihoxha/termai/internal/llm/models"
10+ "github.com/kujtimiihoxha/opencode/internal/llm/tools"
11− "github.com/kujtimiihoxha/termai/internal/llm/tools"
11+ "github.com/kujtimiihoxha/opencode/internal/message"
12− "github.com/kujtimiihoxha/termai/internal/message"
13)
12)
14
13
15−type bedrockProvider struct {
14+type bedrockOptions struct {
16− childProvider Provider
15+ // Bedrock specific options can be added here
17− model models.Model
18− maxTokens int64
19− systemMessage string
20}
16}
21
17
22−func (b *bedrockProvider) SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
18+type BedrockOption func(*bedrockOptions)
23− return b.childProvider.SendMessages(ctx, messages, tools)
24−}
25
19
26−func (b *bedrockProvider) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (<-chan ProviderEvent, error) {
20+type bedrockClient struct {
27− return b.childProvider.StreamResponse(ctx, messages, tools)
21+ providerOptions providerClientOptions
22+ options bedrockOptions
23+ childProvider ProviderClient
28}
24}
29
25
30−func NewBedrockProvider(opts ...BedrockOption) (Provider, error) {
26+type BedrockClient ProviderClient
31− provider := &bedrockProvider{}
27+
32− for _, opt := range opts {
28+func newBedrockClient(opts providerClientOptions) BedrockClient {
33− opt(provider)
29+ bedrockOpts := bedrockOptions{}
34− }
30+ // Apply bedrock specific options if they are added in the future
35
31
36− // based on the AWS region prefix the model name with, us, eu, ap, sa, etc.
32+ // Get AWS region from environment
37 region := os.Getenv("AWS_REGION")
33 region := os.Getenv("AWS_REGION")
38 if region == "" {
34 if region == "" {
39 region = os.Getenv("AWS_DEFAULT_REGION")
35 region = os.Getenv("AWS_DEFAULT_REGION")
@@ -43,45 +39,62 @@ func NewBedrockProvider(opts ...BedrockOption) (Provider, error) {
43 region = "us-east-1" // default region
39 region = "us-east-1" // default region
44 }
40 }
45 if len(region) < 2 {
41 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+ }
47 }
47 }
48+
49+ // Prefix the model name with region
48 regionPrefix := region[:2]
50 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)
50
53
51− if strings.Contains(string(provider.model.APIModel), "anthropic") {
54+ // Determine which provider to use based on the model
52− anthropic, err := NewAnthropicProvider(
55+ if strings.Contains(string(opts.model.APIModel), "anthropic") {
53− WithAnthropicModel(provider.model),
56+ // Create Anthropic client with Bedrock configuration
54− WithAnthropicMaxTokens(provider.maxTokens),
57+ anthropicOpts := opts
55− WithAnthropicSystemMessage(provider.systemMessage),
58+ anthropicOpts.anthropicOptions = append(anthropicOpts.anthropicOptions,
56− WithAnthropicBedrock(),
59+ WithAnthropicBedrock(true),
57 WithAnthropicDisableCache(),
60 WithAnthropicDisableCache(),
58 )
61 )
59− provider.childProvider = anthropic
62+ return &bedrockClient{
60− if err != nil {
63+ providerOptions: opts,
61− return nil, err
64+ options: bedrockOpts,
65+ childProvider: newAnthropicClient(anthropicOpts),
62 }
66 }
63− } else {
64− return nil, errors.New("unsupported model for bedrock provider")
65 }
67 }
66− return provider, nil
67−}
68−
69−type BedrockOption func(*bedrockProvider)
70
68
71−func WithBedrockSystemMessage(message string) BedrockOption {
69+ // Return client with nil childProvider if model is not supported
72− return func(a *bedrockProvider) {
70+ // This will cause an error when used
73− a.systemMessage = message
71+ return &bedrockClient{
72+ providerOptions: opts,
73+ options: bedrockOpts,
74+ childProvider: nil,
74 }
75 }
75}
76}
76
77
77−func WithBedrockMaxTokens(maxTokens int64) BedrockOption {
78+func (b *bedrockClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
78− return func(a *bedrockProvider) {
79+ if b.childProvider == nil {
79− a.maxTokens = maxTokens
80+ return nil, errors.New("unsupported model for bedrock provider")
80 }
81 }
82+ return b.childProvider.send(ctx, messages, tools)
81}
83}
82
84
83−func WithBedrockModel(model models.Model) BedrockOption {
85+func (b *bedrockClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
84− return func(a *bedrockProvider) {
86+ eventChan := make(chan ProviderEvent)
85− a.model = model
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
86 }
97 }
87−}
98+
99+ return b.childProvider.stream(ctx, messages, tools)
100+}
internal/llm/provider/gemini.go+351−185
@@ -4,80 +4,68 @@ import (
4 "context"
4 "context"
5 "encoding/json"
5 "encoding/json"
6 "errors"
6 "errors"
7+ "fmt"
8+ "io"
9+ "strings"
10+ "time"
7
11
8 "github.com/google/generative-ai-go/genai"
12 "github.com/google/generative-ai-go/genai"
9 "github.com/google/uuid"
13 "github.com/google/uuid"
10− "github.com/kujtimiihoxha/termai/internal/llm/models"
14+ "github.com/kujtimiihoxha/opencode/internal/config"
11− "github.com/kujtimiihoxha/termai/internal/llm/tools"
15+ "github.com/kujtimiihoxha/opencode/internal/llm/tools"
12− "github.com/kujtimiihoxha/termai/internal/message"
16+ "github.com/kujtimiihoxha/opencode/internal/logging"
17+ "github.com/kujtimiihoxha/opencode/internal/message"
13 "google.golang.org/api/iterator"
18 "google.golang.org/api/iterator"
14 "google.golang.org/api/option"
19 "google.golang.org/api/option"
15)
20)
16
21
17−type geminiProvider struct {
22+type geminiOptions struct {
18− client *genai.Client
23+ disableCache bool
19− model models.Model
20− maxTokens int32
21− apiKey string
22− systemMessage string
23}
24}
24
25
25−type GeminiOption func(*geminiProvider)
26+type GeminiOption func(*geminiOptions)
26
27
27−func NewGeminiProvider(ctx context.Context, opts ...GeminiOption) (Provider, error) {
28+type geminiClient struct {
28− provider := &geminiProvider{
29+ providerOptions providerClientOptions
29− maxTokens: 5000,
30+ options geminiOptions
30− }
31+ client *genai.Client
32+}
31
33
32− for _, opt := range opts {
34+type GeminiClient ProviderClient
33− opt(provider)
34− }
35
35
36− if provider.systemMessage == "" {
36+func newGeminiClient(opts providerClientOptions) GeminiClient {
37− return nil, errors.New("system message is required")
37+ geminiOpts := geminiOptions{}
38+ for _, o := range opts.geminiOptions {
39+ o(&geminiOpts)
38 }
40 }
39
41
40− client, err := genai.NewClient(ctx, option.WithAPIKey(provider.apiKey))
42+ client, err := genai.NewClient(context.Background(), option.WithAPIKey(opts.apiKey))
41 if err != nil {
43 if err != nil {
42− return nil, err
44+ logging.Error("Failed to create Gemini client", "error", err)
43− }
45+ return nil
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
52 }
46 }
53−}
54
47
55−func WithGeminiMaxTokens(maxTokens int32) GeminiOption {
48+ return &geminiClient{
56− return func(p *geminiProvider) {
49+ providerOptions: opts,
57− p.maxTokens = maxTokens
50+ options: geminiOpts,
51+ client: client,
58 }
52 }
59}
53}
60
54
61−func WithGeminiModel(model models.Model) GeminiOption {
55+func (g *geminiClient) convertMessages(messages []message.Message) []*genai.Content {
62− return func(p *geminiProvider) {
56+ var history []*genai.Content
63− p.model = model
64− }
65−}
66−
67−func WithGeminiKey(apiKey string) GeminiOption {
68− return func(p *geminiProvider) {
69− p.apiKey = apiKey
70− }
71−}
72
57
73−func (p *geminiProvider) Close() {
58+ // Add system message first
74− if p.client != nil {
59+ history = append(history, &genai.Content{
75− p.client.Close()
60+ Parts: []genai.Part{genai.Text(g.providerOptions.systemMessage)},
76− }
61+ Role: "user",
77−}
62+ })
78
63
79−func (p *geminiProvider) convertToGeminiHistory(messages []message.Message) []*genai.Content {
64+ // Add a system response to acknowledge the system message
80− var history []*genai.Content
65+ history = append(history, &genai.Content{
66+ Parts: []genai.Part{genai.Text("I'll help you with that.")},
67+ Role: "model",
68+ })
81
69
82 for _, msg := range messages {
70 for _, msg := range messages {
83 switch msg.Role {
71 switch msg.Role {
@@ -86,6 +74,7 @@ func (p *geminiProvider) convertToGeminiHistory(messages []message.Message) []*g
86 Parts: []genai.Part{genai.Text(msg.Content().String())},
74 Parts: []genai.Part{genai.Text(msg.Content().String())},
87 Role: "user",
75 Role: "user",
88 })
76 })
77+
89 case message.Assistant:
78 case message.Assistant:
90 content := &genai.Content{
79 content := &genai.Content{
91 Role: "model",
80 Role: "model",
@@ -107,6 +96,7 @@ func (p *geminiProvider) convertToGeminiHistory(messages []message.Message) []*g
107 }
96 }
108
97
109 history = append(history, content)
98 history = append(history, content)
99+
110 case message.Tool:
100 case message.Tool:
111 for _, result := range msg.ToolResults() {
101 for _, result := range msg.ToolResults() {
112 response := map[string]interface{}{"result": result.Content}
102 response := map[string]interface{}{"result": result.Content}
@@ -114,10 +104,11 @@ func (p *geminiProvider) convertToGeminiHistory(messages []message.Message) []*g
114 if err == nil {
104 if err == nil {
115 response = parsed
105 response = parsed
116 }
106 }
107+
117 var toolCall message.ToolCall
108 var toolCall message.ToolCall
118− for _, msg := range messages {
109+ for _, m := range messages {
119− if msg.Role == message.Assistant {
110+ if m.Role == message.Assistant {
120− for _, call := range msg.ToolCalls() {
111+ for _, call := range m.ToolCalls() {
121 if call.ID == result.ToolCallID {
112 if call.ID == result.ToolCallID {
122 toolCall = call
113 toolCall = call
123 break
114 break
@@ -140,186 +131,358 @@ func (p *geminiProvider) convertToGeminiHistory(messages []message.Message) []*g
140 return history
131 return history
141}
132}
142
133
143−func (p *geminiProvider) extractTokenUsage(resp *genai.GenerateContentResponse) TokenUsage {
134+func (g *geminiClient) convertTools(tools []tools.BaseTool) []*genai.Tool {
144− if resp == nil || resp.UsageMetadata == nil {
135+ geminiTools := make([]*genai.Tool, 0, len(tools))
145− return TokenUsage{}
146− }
147
136
148− return TokenUsage{
137+ for _, tool := range tools {
149− InputTokens: int64(resp.UsageMetadata.PromptTokenCount),
138+ info := tool.Info()
150− OutputTokens: int64(resp.UsageMetadata.CandidatesTokenCount),
139+ declaration := &genai.FunctionDeclaration{
151− CacheCreationTokens: 0, // Not directly provided by Gemini
140+ Name: info.Name,
152− CacheReadTokens: int64(resp.UsageMetadata.CachedContentTokenCount),
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+ })
153 }
152 }
153+
154+ return geminiTools
154}
155}
155
156
156−func (p *geminiProvider) SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
157+func (g *geminiClient) finishReason(reason genai.FinishReason) message.FinishReason {
157− messages = cleanupMessages(messages)
158+ reasonStr := reason.String()
158− model := p.client.GenerativeModel(p.model.APIModel)
159+ switch {
159− model.SetMaxOutputTokens(p.maxTokens)
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+}
160
170
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))
162
174
175+ // Convert tools
163 if len(tools) > 0 {
176 if len(tools) > 0 {
164− declarations := p.convertToolsToGeminiFunctionDeclarations(tools)
177+ model.Tools = g.convertTools(tools)
165− for _, declaration := range declarations {
166− model.Tools = append(model.Tools, &genai.Tool{FunctionDeclarations: []*genai.FunctionDeclaration{declaration}})
167− }
168 }
178 }
169
179
170− chat := model.StartChat()
180+ // Convert messages
171− chat.History = p.convertToGeminiHistory(messages[:len(messages)-1]) // Exclude last message
181+ geminiMessages := g.convertMessages(messages)
172
182
173− lastUserMsg := messages[len(messages)-1]
183+ cfg := config.Get()
174− resp, err := chat.SendMessage(ctx, genai.Text(lastUserMsg.Content().String()))
184+ if cfg.Debug {
175− if err != nil {
185+ jsonData, _ := json.Marshal(geminiMessages)
176− return nil, err
186+ logging.Debug("Prepared messages", "messages", string(jsonData))
177 }
187 }
178
188
179− var content string
189+ attempts := 0
180− var toolCalls []message.ToolCall
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+ }
181
203
182− if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
204+ resp, err := chat.SendMessage(ctx, genai.Text(lastText))
183− for _, part := range resp.Candidates[0].Content.Parts {
205+ // If there is an error we are going to see if we can retry the call
184− switch p := part.(type) {
206+ if err != nil {
185− case genai.Text:
207+ retry, after, retryErr := g.shouldRetry(attempts, err)
186− content = string(p)
208+ if retryErr != nil {
187− case genai.FunctionCall:
209+ return nil, retryErr
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− })
196 }
210 }
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
197 }
221 }
198− }
199
222
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+ }
201
243
202− return &ProviderResponse{
244+ return &ProviderResponse{
203− Content: content,
245+ Content: content,
204− ToolCalls: toolCalls,
246+ ToolCalls: toolCalls,
205− Usage: tokenUsage,
247+ Usage: g.usage(resp),
206− }, nil
248+ FinishReason: g.finishReason(resp.Candidates[0].FinishReason),
249+ }, nil
250+ }
207}
251}
208
252
209−func (p *geminiProvider) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (<-chan ProviderEvent, error) {
253+func (g *geminiClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
210− messages = cleanupMessages(messages)
254+ model := g.client.GenerativeModel(g.providerOptions.model.APIModel)
211− model := p.client.GenerativeModel(p.model.APIModel)
255+ model.SetMaxOutputTokens(int32(g.providerOptions.maxTokens))
212− model.SetMaxOutputTokens(p.maxTokens)
213−
214− model.SystemInstruction = genai.NewUserContent(genai.Text(p.systemMessage))
215
256
257+ // Convert tools
216 if len(tools) > 0 {
258 if len(tools) > 0 {
217− declarations := p.convertToolsToGeminiFunctionDeclarations(tools)
259+ model.Tools = g.convertTools(tools)
218− for _, declaration := range declarations {
219− model.Tools = append(model.Tools, &genai.Tool{FunctionDeclarations: []*genai.FunctionDeclaration{declaration}})
220− }
221 }
260 }
222
261
223− chat := model.StartChat()
262+ // Convert messages
224− chat.History = p.convertToGeminiHistory(messages[:len(messages)-1]) // Exclude last message
263+ geminiMessages := g.convertMessages(messages)
225
264
226− lastUserMsg := messages[len(messages)-1]
265+ cfg := config.Get()
227−
266+ if cfg.Debug {
228− iter := chat.SendMessageStream(ctx, genai.Text(lastUserMsg.Content().String()))
267+ jsonData, _ := json.Marshal(geminiMessages)
268+ logging.Debug("Prepared messages", "messages", string(jsonData))
269+ }
229
270
271+ attempts := 0
230 eventChan := make(chan ProviderEvent)
272 eventChan := make(chan ProviderEvent)
231
273
232 go func() {
274 go func() {
233 defer close(eventChan)
275 defer close(eventChan)
234
276
235− var finalResp *genai.GenerateContentResponse
236− currentContent := ""
237− toolCalls := []message.ToolCall{}
238−
239 for {
277 for {
240− resp, err := iter.Next()
278+ attempts++
241− if err == iterator.Done {
279+ chat := model.StartChat()
242− break
280+ chat.History = geminiMessages[:len(geminiMessages)-1] // All but last message
243− }
281+
244− if err != nil {
282+ lastMsg := geminiMessages[len(geminiMessages)-1]
245− eventChan <- ProviderEvent{
283+ var lastText string
246− Type: EventError,
284+ for _, part := range lastMsg.Parts {
247− Error: err,
285+ if text, ok := part.(genai.Text); ok {
286+ lastText = string(text)
287+ break
248 }
288 }
249− return
250 }
289 }
251
290
252− finalResp = resp
291+ iter := chat.SendMessageStream(ctx, genai.Text(lastText))
253
292
254− if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
293+ currentContent := ""
255− for _, part := range resp.Candidates[0].Content.Parts {
294+ toolCalls := []message.ToolCall{}
256− switch p := part.(type) {
295+ var finalResp *genai.GenerateContentResponse
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− }
273
296
274− isNew := true
297+ eventChan <- ProviderEvent{Type: EventContentStart}
275− for _, existing := range toolCalls {
298+
276− if existing.Name == newCall.Name && existing.Input == newCall.Input {
299+ for {
277− isNew = false
300+ resp, err := iter.Next()
278− break
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()}
279 }
316 }
317+
318+ return
319+ case <-time.After(time.Duration(after) * time.Millisecond):
320+ break
280 }
321 }
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+ }
281
352
282− if isNew {
353+ isNew := true
283− toolCalls = append(toolCalls, newCall)
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+ }
284 }
364 }
285 }
365 }
286 }
366 }
287 }
367 }
288− }
289
368
290− tokenUsage := p.extractTokenUsage(finalResp)
369+ eventChan <- ProviderEvent{Type: EventContentStop}
291
370
292− eventChan <- ProviderEvent{
371+ if finalResp != nil {
293− Type: EventComplete,
372+ eventChan <- ProviderEvent{
294− Response: &ProviderResponse{
373+ Type: EventComplete,
295− Content: currentContent,
374+ Response: &ProviderResponse{
296− ToolCalls: toolCalls,
375+ Content: currentContent,
297− Usage: tokenUsage,
376+ ToolCalls: toolCalls,
298− FinishReason: string(finalResp.Candidates[0].FinishReason.String()),
377+ Usage: g.usage(finalResp),
299− },
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+ }
300 }
403 }
301 }()
404 }()
302
405
303− return eventChan, nil
406+ return eventChan
304}
407}
305
408
306−func (p *geminiProvider) convertToolsToGeminiFunctionDeclarations(tools []tools.BaseTool) []*genai.FunctionDeclaration {
409+func (g *geminiClient) shouldRetry(attempts int, err error) (bool, int64, error) {
307− declarations := make([]*genai.FunctionDeclaration, len(tools))
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+ }
308
414
309− for i, tool := range tools {
415+ // Gemini doesn't have a standard error type we can check against
310− info := tool.Info()
416+ // So we'll check the error message for rate limit indicators
311− declarations[i] = &genai.FunctionDeclaration{
417+ if errors.Is(err, io.EOF) {
312− Name: info.Name,
418+ return false, 0, err
313− Description: info.Description,
419+ }
314− Parameters: &genai.Schema{
420+
315− Type: genai.TypeObject,
421+ errMsg := err.Error()
316− Properties: convertSchemaProperties(info.Parameters),
422+ isRateLimit := false
317− Required: info.Required,
423+
318− },
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+ }
319 }
456 }
320 }
457 }
321
458
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
323}
486}
324
487
325func convertSchemaProperties(parameters map[string]interface{}) map[string]*genai.Schema {
488func convertSchemaProperties(parameters map[string]interface{}) map[string]*genai.Schema {
@@ -396,8 +559,11 @@ func mapJSONTypeToGenAI(jsonType string) genai.Type {
396 }
559 }
397}
560}
398
561
399−func parseJsonToMap(jsonStr string) (map[string]interface{}, error) {
562+func contains(s string, substrs ...string) bool {
400− var result map[string]interface{}
563+ for _, substr := range substrs {
401− err := json.Unmarshal([]byte(jsonStr), &result)
564+ if strings.Contains(strings.ToLower(s), strings.ToLower(substr)) {
402− return result, err
565+ return true
566+ }
567+ }
568+ return false
403}
569}
internal/llm/provider/openai.go+275−160
@@ -2,89 +2,69 @@ package provider
2
2
3import (
3import (
4 "context"
4 "context"
5+ "encoding/json"
5 "errors"
6 "errors"
6−
7+ "fmt"
7− "github.com/kujtimiihoxha/termai/internal/llm/models"
8+ "io"
8− "github.com/kujtimiihoxha/termai/internal/llm/tools"
9+ "time"
9− "github.com/kujtimiihoxha/termai/internal/message"
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"
10 "github.com/openai/openai-go"
15 "github.com/openai/openai-go"
11 "github.com/openai/openai-go/option"
16 "github.com/openai/openai-go/option"
17+ "github.com/openai/openai-go/shared"
12)
18)
13
19
14−type openaiProvider struct {
20+type openaiOptions struct {
15− client openai.Client
21+ baseURL string
16− model models.Model
22+ disableCache bool
17− maxTokens int64
23+ reasoningEffort string
18− baseURL string
19− apiKey string
20− systemMessage string
21}
24}
22
25
23−type OpenAIOption func(*openaiProvider)
26+type OpenAIOption func(*openaiOptions)
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− }
33
27
34− clientOpts := []option.RequestOption{
28+type openaiClient struct {
35− option.WithAPIKey(provider.apiKey),
29+ providerOptions providerClientOptions
36− }
30+ options openaiOptions
37− if provider.baseURL != "" {
31+ client openai.Client
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
47}
32}
48
33
49−func WithOpenAISystemMessage(message string) OpenAIOption {
34+type OpenAIClient ProviderClient
50− return func(p *openaiProvider) {
51− p.systemMessage = message
52− }
53−}
54
35
55−func WithOpenAIMaxTokens(maxTokens int64) OpenAIOption {
36+func newOpenAIClient(opts providerClientOptions) OpenAIClient {
56− return func(p *openaiProvider) {
37+ openaiOpts := openaiOptions{
57− p.maxTokens = maxTokens
38+ reasoningEffort: "medium",
58 }
39 }
59−}
40+ for _, o := range opts.openaiOptions {
60−
41+ o(&openaiOpts)
61−func WithOpenAIModel(model models.Model) OpenAIOption {
62− return func(p *openaiProvider) {
63− p.model = model
64 }
42 }
65−}
66
43
67−func WithOpenAIBaseURL(baseURL string) OpenAIOption {
44+ openaiClientOptions := []option.RequestOption{}
68− return func(p *openaiProvider) {
45+ if opts.apiKey != "" {
69− p.baseURL = baseURL
46+ openaiClientOptions = append(openaiClientOptions, option.WithAPIKey(opts.apiKey))
47+ }
48+ if openaiOpts.baseURL != "" {
49+ openaiClientOptions = append(openaiClientOptions, option.WithBaseURL(openaiOpts.baseURL))
70 }
50 }
71−}
72
51
73−func WithOpenAIKey(apiKey string) OpenAIOption {
52+ client := openai.NewClient(openaiClientOptions...)
74− return func(p *openaiProvider) {
53+ return &openaiClient{
75− p.apiKey = apiKey
54+ providerOptions: opts,
55+ options: openaiOpts,
56+ client: client,
76 }
57 }
77}
58}
78
59
79−func (p *openaiProvider) convertToOpenAIMessages(messages []message.Message) []openai.ChatCompletionMessageParamUnion {
60+func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessages []openai.ChatCompletionMessageParamUnion) {
80− var chatMessages []openai.ChatCompletionMessageParamUnion
61+ // Add system message first
81−
62+ openaiMessages = append(openaiMessages, openai.SystemMessage(o.providerOptions.systemMessage))
82− chatMessages = append(chatMessages, openai.SystemMessage(p.systemMessage))
83
63
84 for _, msg := range messages {
64 for _, msg := range messages {
85 switch msg.Role {
65 switch msg.Role {
86 case message.User:
66 case message.User:
87− chatMessages = append(chatMessages, openai.UserMessage(msg.Content().String()))
67+ openaiMessages = append(openaiMessages, openai.UserMessage(msg.Content().String()))
88
68
89 case message.Assistant:
69 case message.Assistant:
90 assistantMsg := openai.ChatCompletionAssistantMessageParam{
70 assistantMsg := openai.ChatCompletionAssistantMessageParam{
@@ -111,23 +91,23 @@ func (p *openaiProvider) convertToOpenAIMessages(messages []message.Message) []o
111 }
91 }
112 }
92 }
113
93
114− chatMessages = append(chatMessages, openai.ChatCompletionMessageParamUnion{
94+ openaiMessages = append(openaiMessages, openai.ChatCompletionMessageParamUnion{
115 OfAssistant: &assistantMsg,
95 OfAssistant: &assistantMsg,
116 })
96 })
117
97
118 case message.Tool:
98 case message.Tool:
119 for _, result := range msg.ToolResults() {
99 for _, result := range msg.ToolResults() {
120− chatMessages = append(chatMessages,
100+ openaiMessages = append(openaiMessages,
121 openai.ToolMessage(result.Content, result.ToolCallID),
101 openai.ToolMessage(result.Content, result.ToolCallID),
122 )
102 )
123 }
103 }
124 }
104 }
125 }
105 }
126
106
127− return chatMessages
107+ return
128}
108}
129
109
130−func (p *openaiProvider) convertToOpenAITools(tools []tools.BaseTool) []openai.ChatCompletionToolParam {
110+func (o *openaiClient) convertTools(tools []tools.BaseTool) []openai.ChatCompletionToolParam {
131 openaiTools := make([]openai.ChatCompletionToolParam, len(tools))
111 openaiTools := make([]openai.ChatCompletionToolParam, len(tools))
132
112
133 for i, tool := range tools {
113 for i, tool := range tools {
@@ -148,133 +128,268 @@ func (p *openaiProvider) convertToOpenAITools(tools []tools.BaseTool) []openai.C
148 return openaiTools
128 return openaiTools
149}
129}
150
130
151−func (p *openaiProvider) extractTokenUsage(usage openai.CompletionUsage) TokenUsage {
131+func (o *openaiClient) finishReason(reason string) message.FinishReason {
152− cachedTokens := int64(0)
132+ switch reason {
153−
133+ case "stop":
154− cachedTokens = usage.PromptTokensDetails.CachedTokens
134+ return message.FinishReasonEndTurn
155− inputTokens := usage.PromptTokens - cachedTokens
135+ case "length":
156−
136+ return message.FinishReasonMaxTokens
157− return TokenUsage{
137+ case "tool_calls":
158− InputTokens: inputTokens,
138+ return message.FinishReasonToolUse
159− OutputTokens: usage.CompletionTokens,
139+ default:
160− CacheCreationTokens: 0, // OpenAI doesn't provide this directly
140+ return message.FinishReasonUnknown
161− CacheReadTokens: cachedTokens,
162 }
141 }
163}
142}
164
143
165−func (p *openaiProvider) SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
144+func (o *openaiClient) preparedParams(messages []openai.ChatCompletionMessageParamUnion, tools []openai.ChatCompletionToolParam) openai.ChatCompletionNewParams {
166− messages = cleanupMessages(messages)
167− chatMessages := p.convertToOpenAIMessages(messages)
168− openaiTools := p.convertToOpenAITools(tools)
169−
170 params := openai.ChatCompletionNewParams{
145 params := openai.ChatCompletionNewParams{
171− Model: openai.ChatModel(p.model.APIModel),
146+ Model: openai.ChatModel(o.providerOptions.model.APIModel),
172− Messages: chatMessages,
147+ Messages: messages,
173− MaxTokens: openai.Int(p.maxTokens),
148+ Tools: tools,
174− Tools: openaiTools,
175 }
149 }
176
150
177− response, err := p.client.Chat.Completions.New(ctx, params)
151+ if o.providerOptions.model.CanReason == true {
178− if err != nil {
152+ params.MaxCompletionTokens = openai.Int(o.providerOptions.maxTokens)
179− return nil, err
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)
180 }
165 }
181
166
182− content := ""
167+ return params
183− if response.Choices[0].Message.Content != "" {
168+}
184− content = response.Choices[0].Message.Content
185− }
186
169
187− var toolCalls []message.ToolCall
170+func (o *openaiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) {
188− if len(response.Choices[0].Message.ToolCalls) > 0 {
171+ params := o.preparedParams(o.convertMessages(messages), o.convertTools(tools))
189− toolCalls = make([]message.ToolCall, len(response.Choices[0].Message.ToolCalls))
172+ cfg := config.Get()
190− for i, call := range response.Choices[0].Message.ToolCalls {
173+ if cfg.Debug {
191− toolCalls[i] = message.ToolCall{
174+ jsonData, _ := json.Marshal(params)
192− ID: call.ID,
175+ logging.Debug("Prepared messages", "messages", string(jsonData))
193− Name: call.Function.Name,
176+ }
194− Input: call.Function.Arguments,
177+ attempts := 0
195− Type: "function",
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+ }
196 }
198 }
199+ return nil, retryErr
197 }
200 }
198− }
199
201
200− tokenUsage := p.extractTokenUsage(response.Usage)
202+ content := ""
203+ if openaiResponse.Choices[0].Message.Content != "" {
204+ content = openaiResponse.Choices[0].Message.Content
205+ }
201
206
202− return &ProviderResponse{
207+ return &ProviderResponse{
203− Content: content,
208+ Content: content,
204− ToolCalls: toolCalls,
209+ ToolCalls: o.toolCalls(*openaiResponse),
205− Usage: tokenUsage,
210+ Usage: o.usage(*openaiResponse),
206− }, nil
211+ FinishReason: o.finishReason(string(openaiResponse.Choices[0].FinishReason)),
212+ }, nil
213+ }
207}
214}
208
215
209−func (p *openaiProvider) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (<-chan ProviderEvent, error) {
216+func (o *openaiClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
210− messages = cleanupMessages(messages)
217+ params := o.preparedParams(o.convertMessages(messages), o.convertTools(tools))
211− chatMessages := p.convertToOpenAIMessages(messages)
218+ params.StreamOptions = openai.ChatCompletionStreamOptionsParam{
212− openaiTools := p.convertToOpenAITools(tools)
219+ IncludeUsage: openai.Bool(true),
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− },
222 }
220 }
223
221
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+ }
225
227
228+ attempts := 0
226 eventChan := make(chan ProviderEvent)
229 eventChan := make(chan ProviderEvent)
227
230
228− toolCalls := make([]message.ToolCall, 0)
229 go func() {
231 go func() {
230− defer close(eventChan)
232+ for {
231−
233+ attempts++
232− acc := openai.ChatCompletionAccumulator{}
234+ openaiStream := o.client.Chat.Completions.NewStreaming(
233− currentContent := ""
235+ ctx,
234−
236+ params,
235− for stream.Next() {
237+ )
236− chunk := stream.Current()
238+
237− acc.AddChunk(chunk)
239+ acc := openai.ChatCompletionAccumulator{}
238−
240+ currentContent := ""
239− if tool, ok := acc.JustFinishedToolCall(); ok {
241+ toolCalls := make([]message.ToolCall, 0)
240− toolCalls = append(toolCalls, message.ToolCall{
242+
241− ID: tool.Id,
243+ for openaiStream.Next() {
242− Name: tool.Name,
244+ chunk := openaiStream.Current()
243− Input: tool.Arguments,
245+ acc.AddChunk(chunk)
244− Type: "function",
246+
245− })
247+ if tool, ok := acc.JustFinishedToolCall(); ok {
246− }
248+ toolCalls = append(toolCalls, message.ToolCall{
249+ ID: tool.Id,
250+ Name: tool.Name,
251+ Input: tool.Arguments,
252+ Type: "function",
253+ })
254+ }
247
255
248− for _, choice := range chunk.Choices {
256+ for _, choice := range chunk.Choices {
249− if choice.Delta.Content != "" {
257+ if choice.Delta.Content != "" {
250− eventChan <- ProviderEvent{
258+ eventChan <- ProviderEvent{
251− Type: EventContentDelta,
259+ Type: EventContentDelta,
252− Content: choice.Delta.Content,
260+ Content: choice.Delta.Content,
261+ }
262+ currentContent += choice.Delta.Content
253 }
263 }
254− currentContent += choice.Delta.Content
255 }
264 }
256 }
265 }
257− }
258
266
259− if err := stream.Err(); err != nil {
267+ err := openaiStream.Err()
260− eventChan <- ProviderEvent{
268+ if err == nil || errors.Is(err, io.EOF) {
261− Type: EventError,
269+ // Stream completed successfully
262− Error: err,
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+ }
263 }
303 }
304+ eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
305+ close(eventChan)
264 return
306 return
265 }
307 }
308+ }()
266
309
267− tokenUsage := p.extractTokenUsage(acc.Usage)
310+ return eventChan
311+}
268
312
269− eventChan <- ProviderEvent{
313+func (o *openaiClient) shouldRetry(attempts int, err error) (bool, int64, error) {
270− Type: EventComplete,
314+ var apierr *openai.Error
271− Response: &ProviderResponse{
315+ if !errors.As(err, &apierr) {
272− Content: currentContent,
316+ return false, 0, err
273− ToolCalls: toolCalls,
317+ }
274− Usage: tokenUsage,
318+
275− },
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
276 }
336 }
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
278
343
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+ }
280}
395}
internal/llm/provider/provider.go+136−38
@@ -2,23 +2,28 @@ package provider
2
2
3import (
3import (
4 "context"
4 "context"
5+ "fmt"
5
6
6− "github.com/kujtimiihoxha/termai/internal/llm/tools"
7+ "github.com/kujtimiihoxha/opencode/internal/llm/models"
7− "github.com/kujtimiihoxha/termai/internal/message"
8+ "github.com/kujtimiihoxha/opencode/internal/llm/tools"
9+ "github.com/kujtimiihoxha/opencode/internal/message"
8)
10)
9
11
10−// EventType represents the type of streaming event
11type EventType string
12type EventType string
12
13
14+const maxRetries = 8
15+
13const (
16const (
14 EventContentStart EventType = "content_start"
17 EventContentStart EventType = "content_start"
18+ EventToolUseStart EventType = "tool_use_start"
19+ EventToolUseDelta EventType = "tool_use_delta"
20+ EventToolUseStop EventType = "tool_use_stop"
15 EventContentDelta EventType = "content_delta"
21 EventContentDelta EventType = "content_delta"
16 EventThinkingDelta EventType = "thinking_delta"
22 EventThinkingDelta EventType = "thinking_delta"
17 EventContentStop EventType = "content_stop"
23 EventContentStop EventType = "content_stop"
18 EventComplete EventType = "complete"
24 EventComplete EventType = "complete"
19 EventError EventType = "error"
25 EventError EventType = "error"
20 EventWarning EventType = "warning"
26 EventWarning EventType = "warning"
21− EventInfo EventType = "info"
22)
27)
23
28
24type TokenUsage struct {
29type TokenUsage struct {
@@ -32,59 +37,152 @@ type ProviderResponse struct {
32 Content string
37 Content string
33 ToolCalls []message.ToolCall
38 ToolCalls []message.ToolCall
34 Usage TokenUsage
39 Usage TokenUsage
35− FinishReason string
40+ FinishReason message.FinishReason
36}
41}
37
42
38type ProviderEvent struct {
43type ProviderEvent struct {
39− Type EventType
44+ Type EventType
45+
40 Content string
46 Content string
41 Thinking string
47 Thinking string
48+ Response *ProviderResponse
42 ToolCall *message.ToolCall
49 ToolCall *message.ToolCall
43 Error error
50 Error error
44− Response *ProviderResponse
45−
46− // Used for giving users info on e.x retry
47− Info string
48}
51}
49−
50type Provider interface {
52type Provider interface {
51 SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error)
53 SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error)
52
54
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
54}
70}
55
71
56−func cleanupMessages(messages []message.Message) []message.Message {
72+type ProviderClientOption func(*providerClientOptions)
57− // First pass: filter out canceled messages
73+
58− var cleanedMessages []message.Message
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) {
59 for _, msg := range messages {
118 for _, msg := range messages {
60− if msg.FinishReason() != "canceled" {
119+ // The message has no content
61− cleanedMessages = append(cleanedMessages, msg)
120+ if len(msg.Parts) == 0 {
121+ continue
62 }
122 }
123+ cleaned = append(cleaned, msg)
63 }
124 }
125+ return
126+}
64
127
65− // Second pass: filter out tool messages without a corresponding tool call
128+func (p *baseProvider[C]) SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
66− var result []message.Message
129+ messages = p.cleanMessages(messages)
67− toolMessageIDs := make(map[string]bool)
130+ return p.client.send(ctx, messages, tools)
131+}
68
132
69− for _, msg := range cleanedMessages {
133+func (p *baseProvider[C]) Model() models.Model {
70− if msg.Role == message.Assistant {
134+ return p.options.model
71− for _, toolCall := range msg.ToolCalls() {
135+}
72− toolMessageIDs[toolCall.ID] = true // Mark as referenced
136+
73− }
137+func (p *baseProvider[C]) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
74− }
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
75 }
145 }
146+}
76
147
77− // Keep only messages that aren't unreferenced tool messages
148+func WithModel(model models.Model) ProviderClientOption {
78− for _, msg := range cleanedMessages {
149+ return func(options *providerClientOptions) {
79− if msg.Role == message.Tool {
150+ options.model = model
80− for _, toolCall := range msg.ToolResults() {
151+ }
81− if referenced, exists := toolMessageIDs[toolCall.ToolCallID]; exists && referenced {
152+}
82− result = append(result, msg)
153+
83− }
154+func WithMaxTokens(maxTokens int64) ProviderClientOption {
84− }
155+ return func(options *providerClientOptions) {
85− } else {
156+ options.maxTokens = maxTokens
86− result = append(result, msg)
157+ }
87− }
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
88 }
187 }
89− return result
90}
188}
internal/llm/tools/bash.go+29−13
@@ -5,10 +5,11 @@ import (
5 "encoding/json"
5 "encoding/json"
6 "fmt"
6 "fmt"
7 "strings"
7 "strings"
8+ "time"
8
9
9− "github.com/kujtimiihoxha/termai/internal/config"
10+ "github.com/kujtimiihoxha/opencode/internal/config"
10− "github.com/kujtimiihoxha/termai/internal/llm/tools/shell"
11+ "github.com/kujtimiihoxha/opencode/internal/llm/tools/shell"
11− "github.com/kujtimiihoxha/termai/internal/permission"
12+ "github.com/kujtimiihoxha/opencode/internal/permission"
12)
13)
13
14
14type BashParams struct {
15type BashParams struct {
@@ -21,6 +22,10 @@ type BashPermissionsParams struct {
21 Timeout int `json:"timeout"`
22 Timeout int `json:"timeout"`
22}
23}
23
24
25+type BashResponseMetadata struct {
26+ StartTime int64 `json:"start_time"`
27+ EndTime int64 `json:"end_time"`
28+}
24type bashTool struct {
29type bashTool struct {
25 permissions permission.Service
30 permissions permission.Service
26}
31}
@@ -46,7 +51,7 @@ var safeReadOnlyCommands = []string{
46 "git status", "git log", "git diff", "git show", "git branch", "git tag", "git remote", "git ls-files", "git ls-remote",
51 "git status", "git log", "git diff", "git show", "git branch", "git tag", "git remote", "git ls-files", "git ls-remote",
47 "git rev-parse", "git config --get", "git config --list", "git describe", "git blame", "git grep", "git shortlog",
52 "git rev-parse", "git config --get", "git config --list", "git describe", "git blame", "git grep", "git shortlog",
48
53
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",
50}
55}
51
56
52func bashDescription() string {
57func bashDescription() string {
@@ -117,16 +122,16 @@ When the user asks you to create a new git commit, follow these steps carefully:
117</commit_analysis>
122</commit_analysis>
118
123
1194. Create the commit with a message ending with:
1244. Create the commit with a message ending with:
120−🤖 Generated with termai
125+🤖 Generated with opencode
121−Co-Authored-By: termai <noreply@termai.io>
126+Co-Authored-By: opencode <noreply@opencode.ai>
122
127
123- In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example:
128- In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example:
124<example>
129<example>
125git commit -m "$(cat <<'EOF'
130git commit -m "$(cat <<'EOF'
126 Commit message here.
131 Commit message here.
127
132
128− 🤖 Generated with termai
133+ 🤖 Generated with opencode
129− Co-Authored-By: termai <noreply@termai.io>
134+ Co-Authored-By: opencode <noreply@opencode.ai>
130 EOF
135 EOF
131 )"
136 )"
132</example>
137</example>
@@ -188,7 +193,7 @@ gh pr create --title "the pr title" --body "$(cat <<'EOF'
188## Test plan
193## Test plan
189[Checklist of TODOs for testing the pull request...]
194[Checklist of TODOs for testing the pull request...]
190
195
191−🤖 Generated with termai
196+🤖 Generated with opencode
192EOF
197EOF
193)"
198)"
194</example>
199</example>
@@ -256,9 +261,15 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
256 }
261 }
257 }
262 }
258 }
263 }
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+ }
259 if !isSafeReadOnly {
269 if !isSafeReadOnly {
260 p := b.permissions.Request(
270 p := b.permissions.Request(
261 permission.CreatePermissionRequest{
271 permission.CreatePermissionRequest{
272+ SessionID: sessionID,
262 Path: config.WorkingDirectory(),
273 Path: config.WorkingDirectory(),
263 ToolName: BashToolName,
274 ToolName: BashToolName,
264 Action: "execute",
275 Action: "execute",
@@ -269,13 +280,14 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
269 },
280 },
270 )
281 )
271 if !p {
282 if !p {
272− return NewTextErrorResponse("permission denied"), nil
283+ return ToolResponse{}, permission.ErrorPermissionDenied
273 }
284 }
274 }
285 }
286+ startTime := time.Now()
275 shell := shell.GetPersistentShell(config.WorkingDirectory())
287 shell := shell.GetPersistentShell(config.WorkingDirectory())
276 stdout, stderr, exitCode, interrupted, err := shell.Exec(ctx, params.Command, params.Timeout)
288 stdout, stderr, exitCode, interrupted, err := shell.Exec(ctx, params.Command, params.Timeout)
277 if err != nil {
289 if err != nil {
278− return NewTextErrorResponse(fmt.Sprintf("error executing command: %s", err)), nil
290+ return ToolResponse{}, fmt.Errorf("error executing command: %w", err)
279 }
291 }
280
292
281 stdout = truncateOutput(stdout)
293 stdout = truncateOutput(stdout)
@@ -304,10 +316,14 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
304 stdout += "\n" + errorMessage
316 stdout += "\n" + errorMessage
305 }
317 }
306
318
319+ metadata := BashResponseMetadata{
320+ StartTime: startTime.UnixMilli(),
321+ EndTime: time.Now().UnixMilli(),
322+ }
307 if stdout == "" {
323 if stdout == "" {
308− return NewTextResponse("no output"), nil
324+ return WithResponseMetadata(NewTextResponse("no output"), metadata), nil
309 }
325 }
310− return NewTextResponse(stdout), nil
326+ return WithResponseMetadata(NewTextResponse(stdout), metadata), nil
311}
327}
312
328
313func truncateOutput(content string) string {
329func 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 (
9 "strings"
9 "strings"
10 "time"
10 "time"
11
11
12− "github.com/kujtimiihoxha/termai/internal/lsp"
12+ "github.com/kujtimiihoxha/opencode/internal/lsp"
13− "github.com/kujtimiihoxha/termai/internal/lsp/protocol"
13+ "github.com/kujtimiihoxha/opencode/internal/lsp/protocol"
14)
14)
15
15
16type DiagnosticsParams struct {
16type DiagnosticsParams struct {
@@ -82,7 +82,7 @@ func (b *diagnosticsTool) Run(ctx context.Context, call ToolCall) (ToolResponse,
82 waitForLspDiagnostics(ctx, params.FilePath, lsps)
82 waitForLspDiagnostics(ctx, params.FilePath, lsps)
83 }
83 }
84
84
85− output := appendDiagnostics(params.FilePath, lsps)
85+ output := getDiagnostics(params.FilePath, lsps)
86
86
87 return NewTextResponse(output), nil
87 return NewTextResponse(output), nil
88}
88}
@@ -154,7 +154,7 @@ func hasDiagnosticsChanged(current, original map[protocol.DocumentUri][]protocol
154 return false
154 return false
155}
155}
156
156
157−func appendDiagnostics(filePath string, lsps map[string]*lsp.Client) string {
157+func getDiagnostics(filePath string, lsps map[string]*lsp.Client) string {
158 fileDiagnostics := []string{}
158 fileDiagnostics := []string{}
159 projectDiagnostics := []string{}
159 projectDiagnostics := []string{}
160
160
internal/llm/tools/edit.go+214−127
@@ -9,10 +9,12 @@ import (
9 "strings"
9 "strings"
10 "time"
10 "time"
11
11
12− "github.com/kujtimiihoxha/termai/internal/config"
12+ "github.com/kujtimiihoxha/opencode/internal/config"
13− "github.com/kujtimiihoxha/termai/internal/lsp"
13+ "github.com/kujtimiihoxha/opencode/internal/diff"
14− "github.com/kujtimiihoxha/termai/internal/permission"
14+ "github.com/kujtimiihoxha/opencode/internal/history"
15− "github.com/sergi/go-diff/diffmatchpatch"
15+ "github.com/kujtimiihoxha/opencode/internal/logging"
16+ "github.com/kujtimiihoxha/opencode/internal/lsp"
17+ "github.com/kujtimiihoxha/opencode/internal/permission"
16)
18)
17
19
18type EditParams struct {
20type EditParams struct {
@@ -22,15 +24,20 @@ type EditParams struct {
22}
24}
23
25
24type EditPermissionsParams struct {
26type EditPermissionsParams struct {
25− FilePath string `json:"file_path"`
27+ FilePath string `json:"file_path"`
26− OldString string `json:"old_string"`
28+ Diff string `json:"diff"`
27− NewString string `json:"new_string"`
29+}
30+
31+type EditResponseMetadata struct {
28 Diff string `json:"diff"`
32 Diff string `json:"diff"`
33+ Additions int `json:"additions"`
34+ Removals int `json:"removals"`
29}
35}
30
36
31type editTool struct {
37type editTool struct {
32 lspClients map[string]*lsp.Client
38 lspClients map[string]*lsp.Client
33 permissions permission.Service
39 permissions permission.Service
40+ files history.Service
34}
41}
35
42
36const (
43const (
@@ -84,10 +91,11 @@ When making edits:
84Remember: 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.`
91Remember: 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.`
85)
92)
86
93
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 {
88 return &editTool{
95 return &editTool{
89 lspClients: lspClients,
96 lspClients: lspClients,
90 permissions: permissions,
97 permissions: permissions,
98+ files: files,
91 }
99 }
92}
100}
93
101
@@ -128,275 +136,354 @@ func (e *editTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
128 params.FilePath = filepath.Join(wd, params.FilePath)
136 params.FilePath = filepath.Join(wd, params.FilePath)
129 }
137 }
130
138
139+ var response ToolResponse
140+ var err error
141+
131 if params.OldString == "" {
142 if params.OldString == "" {
132− result, err := e.createNewFile(params.FilePath, params.NewString)
143+ response, err = e.createNewFile(ctx, params.FilePath, params.NewString)
133 if err != nil {
144 if err != nil {
134− return NewTextErrorResponse(fmt.Sprintf("error creating file: %s", err)), nil
145+ return response, err
135 }
146 }
136− return NewTextResponse(result), nil
137 }
147 }
138
148
139 if params.NewString == "" {
149 if params.NewString == "" {
140− result, err := e.deleteContent(params.FilePath, params.OldString)
150+ response, err = e.deleteContent(ctx, params.FilePath, params.OldString)
141 if err != nil {
151 if err != nil {
142− return NewTextErrorResponse(fmt.Sprintf("error deleting content: %s", err)), nil
152+ return response, err
143 }
153 }
144− return NewTextResponse(result), nil
145 }
154 }
146
155
147− result, err := e.replaceContent(params.FilePath, params.OldString, params.NewString)
156+ response, err = e.replaceContent(ctx, params.FilePath, params.OldString, params.NewString)
148 if err != nil {
157 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
150 }
164 }
151
165
152 waitForLspDiagnostics(ctx, params.FilePath, e.lspClients)
166 waitForLspDiagnostics(ctx, params.FilePath, e.lspClients)
153− result = fmt.Sprintf("<result>\n%s\n</result>\n", result)
167+ text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
154− result += appendDiagnostics(params.FilePath, e.lspClients)
168+ text += getDiagnostics(params.FilePath, e.lspClients)
155− return NewTextResponse(result), nil
169+ response.Content = text
170+ return response, nil
156}
171}
157
172
158−func (e *editTool) createNewFile(filePath, content string) (string, error) {
173+func (e *editTool) createNewFile(ctx context.Context, filePath, content string) (ToolResponse, error) {
159 fileInfo, err := os.Stat(filePath)
174 fileInfo, err := os.Stat(filePath)
160 if err == nil {
175 if err == nil {
161 if fileInfo.IsDir() {
176 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
163 }
178 }
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
165 } else if !os.IsNotExist(err) {
180 } 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)
167 }
182 }
168
183
169 dir := filepath.Dir(filePath)
184 dir := filepath.Dir(filePath)
170 if err = os.MkdirAll(dir, 0o755); err != nil {
185 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)
172 }
187 }
173
188
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+ }
174 p := e.permissions.Request(
204 p := e.permissions.Request(
175 permission.CreatePermissionRequest{
205 permission.CreatePermissionRequest{
176− Path: filepath.Dir(filePath),
206+ SessionID: sessionID,
207+ Path: permissionPath,
177 ToolName: EditToolName,
208 ToolName: EditToolName,
178− Action: "create",
209+ Action: "write",
179 Description: fmt.Sprintf("Create file %s", filePath),
210 Description: fmt.Sprintf("Create file %s", filePath),
180 Params: EditPermissionsParams{
211 Params: EditPermissionsParams{
181− FilePath: filePath,
212+ FilePath: filePath,
182− OldString: "",
213+ Diff: diff,
183− NewString: content,
184− Diff: GenerateDiff("", content),
185 },
214 },
186 },
215 },
187 )
216 )
188 if !p {
217 if !p {
189− return "", fmt.Errorf("permission denied")
218+ return ToolResponse{}, permission.ErrorPermissionDenied
190 }
219 }
191
220
192 err = os.WriteFile(filePath, []byte(content), 0o644)
221 err = os.WriteFile(filePath, []byte(content), 0o644)
193 if err != nil {
222 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)
195 }
238 }
196
239
197 recordFileWrite(filePath)
240 recordFileWrite(filePath)
198 recordFileRead(filePath)
241 recordFileRead(filePath)
199
242
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
201}
251}
202
252
203−func (e *editTool) deleteContent(filePath, oldString string) (string, error) {
253+func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string) (ToolResponse, error) {
204 fileInfo, err := os.Stat(filePath)
254 fileInfo, err := os.Stat(filePath)
205 if err != nil {
255 if err != nil {
206 if os.IsNotExist(err) {
256 if os.IsNotExist(err) {
207− return "", fmt.Errorf("file not found: %s", filePath)
257+ return NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
208 }
258 }
209− return "", fmt.Errorf("failed to access file: %w", err)
259+ return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
210 }
260 }
211
261
212 if fileInfo.IsDir() {
262 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
214 }
264 }
215
265
216 if getLastReadTime(filePath).IsZero() {
266 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
218 }
268 }
219
269
220 modTime := fileInfo.ModTime()
270 modTime := fileInfo.ModTime()
221 lastRead := getLastReadTime(filePath)
271 lastRead := getLastReadTime(filePath)
222 if modTime.After(lastRead) {
272 if modTime.After(lastRead) {
223− return "", fmt.Errorf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
273+ return NewTextErrorResponse(
224− filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))
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
225 }
277 }
226
278
227 content, err := os.ReadFile(filePath)
279 content, err := os.ReadFile(filePath)
228 if err != nil {
280 if err != nil {
229− return "", fmt.Errorf("failed to read file: %w", err)
281+ return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
230 }
282 }
231
283
232 oldContent := string(content)
284 oldContent := string(content)
233
285
234 index := strings.Index(oldContent, oldString)
286 index := strings.Index(oldContent, oldString)
235 if index == -1 {
287 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
237 }
289 }
238
290
239 lastIndex := strings.LastIndex(oldContent, oldString)
291 lastIndex := strings.LastIndex(oldContent, oldString)
240 if index != lastIndex {
292 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
242 }
294 }
243
295
244 newContent := oldContent[:index] + oldContent[index+len(oldString):]
296 newContent := oldContent[:index] + oldContent[index+len(oldString):]
245
297
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+ }
246 p := e.permissions.Request(
315 p := e.permissions.Request(
247 permission.CreatePermissionRequest{
316 permission.CreatePermissionRequest{
248− Path: filepath.Dir(filePath),
317+ SessionID: sessionID,
318+ Path: permissionPath,
249 ToolName: EditToolName,
319 ToolName: EditToolName,
250− Action: "delete",
320+ Action: "write",
251 Description: fmt.Sprintf("Delete content from file %s", filePath),
321 Description: fmt.Sprintf("Delete content from file %s", filePath),
252 Params: EditPermissionsParams{
322 Params: EditPermissionsParams{
253− FilePath: filePath,
323+ FilePath: filePath,
254− OldString: oldString,
324+ Diff: diff,
255− NewString: "",
256− Diff: GenerateDiff(oldContent, newContent),
257 },
325 },
258 },
326 },
259 )
327 )
260 if !p {
328 if !p {
261− return "", fmt.Errorf("permission denied")
329+ return ToolResponse{}, permission.ErrorPermissionDenied
262 }
330 }
263
331
264 err = os.WriteFile(filePath, []byte(newContent), 0o644)
332 err = os.WriteFile(filePath, []byte(newContent), 0o644)
265 if err != nil {
333 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)
267 }
357 }
268
358
269 recordFileWrite(filePath)
359 recordFileWrite(filePath)
270 recordFileRead(filePath)
360 recordFileRead(filePath)
271
361
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
273}
370}
274
371
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) {
276 fileInfo, err := os.Stat(filePath)
373 fileInfo, err := os.Stat(filePath)
277 if err != nil {
374 if err != nil {
278 if os.IsNotExist(err) {
375 if os.IsNotExist(err) {
279− return "", fmt.Errorf("file not found: %s", filePath)
376+ return NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
280 }
377 }
281− return "", fmt.Errorf("failed to access file: %w", err)
378+ return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
282 }
379 }
283
380
284 if fileInfo.IsDir() {
381 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
286 }
383 }
287
384
288 if getLastReadTime(filePath).IsZero() {
385 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
290 }
387 }
291
388
292 modTime := fileInfo.ModTime()
389 modTime := fileInfo.ModTime()
293 lastRead := getLastReadTime(filePath)
390 lastRead := getLastReadTime(filePath)
294 if modTime.After(lastRead) {
391 if modTime.After(lastRead) {
295− return "", fmt.Errorf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
392+ return NewTextErrorResponse(
296− filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))
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
297 }
396 }
298
397
299 content, err := os.ReadFile(filePath)
398 content, err := os.ReadFile(filePath)
300 if err != nil {
399 if err != nil {
301− return "", fmt.Errorf("failed to read file: %w", err)
400+ return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
302 }
401 }
303
402
304 oldContent := string(content)
403 oldContent := string(content)
305
404
306 index := strings.Index(oldContent, oldString)
405 index := strings.Index(oldContent, oldString)
307 if index == -1 {
406 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
309 }
408 }
310
409
311 lastIndex := strings.LastIndex(oldContent, oldString)
410 lastIndex := strings.LastIndex(oldContent, oldString)
312 if index != lastIndex {
411 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
314 }
413 }
315
414
316 newContent := oldContent[:index] + newString + oldContent[index+len(oldString):]
415 newContent := oldContent[:index] + newString + oldContent[index+len(oldString):]
317
416
318− startIndex := max(0, index-3)
417+ if oldContent == newContent {
319− oldEndIndex := min(len(oldContent), index+len(oldString)+3)
418+ return NewTextErrorResponse("new content is the same as old content. No changes made."), nil
320− newEndIndex := min(len(newContent), index+len(newString)+3)
419+ }
321−
420+ sessionID, messageID := GetContextValues(ctx)
322− diff := GenerateDiff(oldContent[startIndex:oldEndIndex], newContent[startIndex:newEndIndex])
323
421
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+ }
324 p := e.permissions.Request(
435 p := e.permissions.Request(
325 permission.CreatePermissionRequest{
436 permission.CreatePermissionRequest{
326− Path: filepath.Dir(filePath),
437+ SessionID: sessionID,
438+ Path: permissionPath,
327 ToolName: EditToolName,
439 ToolName: EditToolName,
328− Action: "replace",
440+ Action: "write",
329 Description: fmt.Sprintf("Replace content in file %s", filePath),
441 Description: fmt.Sprintf("Replace content in file %s", filePath),
330 Params: EditPermissionsParams{
442 Params: EditPermissionsParams{
331− FilePath: filePath,
443+ FilePath: filePath,
332− OldString: oldString,
444+ Diff: diff,
333− NewString: newString,
334− Diff: diff,
335 },
445 },
336 },
446 },
337 )
447 )
338 if !p {
448 if !p {
339− return "", fmt.Errorf("permission denied")
449+ return ToolResponse{}, permission.ErrorPermissionDenied
340 }
450 }
341
451
342 err = os.WriteFile(filePath, []byte(newContent), 0o644)
452 err = os.WriteFile(filePath, []byte(newContent), 0o644)
343 if err != nil {
453 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)
345 }
477 }
346
478
347 recordFileWrite(filePath)
479 recordFileWrite(filePath)
348 recordFileRead(filePath)
480 recordFileRead(filePath)
349
481
350− return "Content replaced in file: " + filePath, nil
482+ return WithResponseMetadata(
351−}
483+ NewTextResponse("Content replaced in file: "+filePath),
352−
484+ EditResponseMetadata{
353−func GenerateDiff(oldContent, newContent string) string {
485+ Diff: diff,
354− dmp := diffmatchpatch.New()
486+ Additions: additions,
355− fileAdmp, fileBdmp, dmpStrings := dmp.DiffLinesToChars(oldContent, newContent)
487+ Removals: removals,
356− diffs := dmp.DiffMain(fileAdmp, fileBdmp, false)
488+ }), nil
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()
402}
489}
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 (
11
11
12 md "github.com/JohannesKaufmann/html-to-markdown"
12 md "github.com/JohannesKaufmann/html-to-markdown"
13 "github.com/PuerkitoBio/goquery"
13 "github.com/PuerkitoBio/goquery"
14− "github.com/kujtimiihoxha/termai/internal/config"
14+ "github.com/kujtimiihoxha/opencode/internal/config"
15− "github.com/kujtimiihoxha/termai/internal/permission"
15+ "github.com/kujtimiihoxha/opencode/internal/permission"
16)
16)
17
17
18type FetchParams struct {
18type FetchParams struct {
@@ -86,6 +86,7 @@ func (t *fetchTool) Info() ToolInfo {
86 "format": map[string]any{
86 "format": map[string]any{
87 "type": "string",
87 "type": "string",
88 "description": "The format to return the content in (text, markdown, or html)",
88 "description": "The format to return the content in (text, markdown, or html)",
89+ "enum": []string{"text", "markdown", "html"},
89 },
90 },
90 "timeout": map[string]any{
91 "timeout": map[string]any{
91 "type": "number",
92 "type": "number",
@@ -115,8 +116,14 @@ func (t *fetchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
115 return NewTextErrorResponse("URL must start with http:// or https://"), nil
116 return NewTextErrorResponse("URL must start with http:// or https://"), nil
116 }
117 }
117
118
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+
118 p := t.permissions.Request(
124 p := t.permissions.Request(
119 permission.CreatePermissionRequest{
125 permission.CreatePermissionRequest{
126+ SessionID: sessionID,
120 Path: config.WorkingDirectory(),
127 Path: config.WorkingDirectory(),
121 ToolName: FetchToolName,
128 ToolName: FetchToolName,
122 Action: "fetch",
129 Action: "fetch",
@@ -126,7 +133,7 @@ func (t *fetchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
126 )
133 )
127
134
128 if !p {
135 if !p {
129− return NewTextErrorResponse("Permission denied to fetch from URL: " + params.URL), nil
136+ return ToolResponse{}, permission.ErrorPermissionDenied
130 }
137 }
131
138
132 client := t.client
139 client := t.client
@@ -142,14 +149,14 @@ func (t *fetchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
142
149
143 req, err := http.NewRequestWithContext(ctx, "GET", params.URL, nil)
150 req, err := http.NewRequestWithContext(ctx, "GET", params.URL, nil)
144 if err != nil {
151 if err != nil {
145− return NewTextErrorResponse("Failed to create request: " + err.Error()), nil
152+ return ToolResponse{}, fmt.Errorf("failed to create request: %w", err)
146 }
153 }
147
154
148− req.Header.Set("User-Agent", "termai/1.0")
155+ req.Header.Set("User-Agent", "opencode/1.0")
149
156
150 resp, err := client.Do(req)
157 resp, err := client.Do(req)
151 if err != nil {
158 if err != nil {
152− return NewTextErrorResponse("Failed to execute request: " + err.Error()), nil
159+ return ToolResponse{}, fmt.Errorf("failed to fetch URL: %w", err)
153 }
160 }
154 defer resp.Body.Close()
161 defer resp.Body.Close()
155
162
internal/llm/tools/glob.go+51−4
@@ -12,7 +12,7 @@ import (
12 "time"
12 "time"
13
13
14 "github.com/bmatcuk/doublestar/v4"
14 "github.com/bmatcuk/doublestar/v4"
15− "github.com/kujtimiihoxha/termai/internal/config"
15+ "github.com/kujtimiihoxha/opencode/internal/config"
16)
16)
17
17
18const (
18const (
@@ -63,6 +63,11 @@ type GlobParams struct {
63 Path string `json:"path"`
63 Path string `json:"path"`
64}
64}
65
65
66+type GlobResponseMetadata struct {
67+ NumberOfFiles int `json:"number_of_files"`
68+ Truncated bool `json:"truncated"`
69+}
70+
66type globTool struct{}
71type globTool struct{}
67
72
68func NewGlobTool() BaseTool {
73func NewGlobTool() BaseTool {
@@ -104,7 +109,7 @@ func (g *globTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
104
109
105 files, truncated, err := globFiles(params.Pattern, searchPath, 100)
110 files, truncated, err := globFiles(params.Pattern, searchPath, 100)
106 if err != nil {
111 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)
108 }
113 }
109
114
110 var output string
115 var output string
@@ -117,7 +122,13 @@ func (g *globTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
117 }
122 }
118 }
123 }
119
124
120− return NewTextResponse(output), nil
125+ return WithResponseMetadata(
126+ NewTextResponse(output),
127+ GlobResponseMetadata{
128+ NumberOfFiles: len(files),
129+ Truncated: truncated,
130+ },
131+ ), nil
121}
132}
122
133
123func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) {
134func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) {
@@ -181,6 +192,42 @@ func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) {
181}
192}
182
193
183func skipHidden(path string) bool {
194func skipHidden(path string) bool {
195+ // Check for hidden files (starting with a dot)
184 base := filepath.Base(path)
196 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
186}
233}
internal/llm/tools/grep.go+103−42
@@ -10,21 +10,30 @@ import (
10 "path/filepath"
10 "path/filepath"
11 "regexp"
11 "regexp"
12 "sort"
12 "sort"
13+ "strconv"
13 "strings"
14 "strings"
14 "time"
15 "time"
15
16
16− "github.com/kujtimiihoxha/termai/internal/config"
17+ "github.com/kujtimiihoxha/opencode/internal/config"
17)
18)
18
19
19type GrepParams struct {
20type GrepParams struct {
20− Pattern string `json:"pattern"`
21+ Pattern string `json:"pattern"`
21− Path string `json:"path"`
22+ Path string `json:"path"`
22− Include string `json:"include"`
23+ Include string `json:"include"`
24+ LiteralText bool `json:"literal_text"`
23}
25}
24
26
25type grepMatch struct {
27type grepMatch struct {
26− path string
28+ path string
27− modTime time.Time
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"`
28}
37}
29
38
30type grepTool struct{}
39type grepTool struct{}
@@ -40,11 +49,12 @@ WHEN TO USE THIS TOOL:
40
49
41HOW TO USE:
50HOW TO USE:
42- Provide a regex pattern to search for within file contents
51- 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)
43- Optionally specify a starting directory (defaults to current working directory)
53- Optionally specify a starting directory (defaults to current working directory)
44- Optionally provide an include pattern to filter which files to search
54- Optionally provide an include pattern to filter which files to search
45- Results are sorted with most recently modified files first
55- Results are sorted with most recently modified files first
46
56
47−REGEX PATTERN SYNTAX:
57+REGEX PATTERN SYNTAX (when literal_text=false):
48- Supports standard regular expression syntax
58- Supports standard regular expression syntax
49- 'function' searches for the literal text "function"
59- 'function' searches for the literal text "function"
50- 'log\..*Error' finds text starting with "log." and ending with "Error"
60- 'log\..*Error' finds text starting with "log." and ending with "Error"
@@ -64,7 +74,8 @@ LIMITATIONS:
64TIPS:
74TIPS:
65- For faster, more targeted searches, first use Glob to find relevant files, then use Grep
75- For faster, more targeted searches, first use Glob to find relevant files, then use Grep
66- When doing iterative exploration that may require multiple rounds of searching, consider using the Agent tool instead
76- 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.`
68)
79)
69
80
70func NewGrepTool() BaseTool {
81func NewGrepTool() BaseTool {
@@ -88,11 +99,27 @@ func (g *grepTool) Info() ToolInfo {
88 "type": "string",
99 "type": "string",
89 "description": "File pattern to include in the search (e.g. \"*.js\", \"*.{ts,tsx}\")",
100 "description": "File pattern to include in the search (e.g. \"*.js\", \"*.{ts,tsx}\")",
90 },
101 },
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+ },
91 },
106 },
92 Required: []string{"pattern"},
107 Required: []string{"pattern"},
93 }
108 }
94}
109}
95
110
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+
96func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
123func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
97 var params GrepParams
124 var params GrepParams
98 if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
125 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)
103 return NewTextErrorResponse("pattern is required"), nil
130 return NewTextErrorResponse("pattern is required"), nil
104 }
131 }
105
132
133+ // If literal_text is true, escape the pattern
134+ searchPattern := params.Pattern
135+ if params.LiteralText {
136+ searchPattern = escapeRegexPattern(params.Pattern)
137+ }
138+
106 searchPath := params.Path
139 searchPath := params.Path
107 if searchPath == "" {
140 if searchPath == "" {
108 searchPath = config.WorkingDirectory()
141 searchPath = config.WorkingDirectory()
109 }
142 }
110
143
111− matches, truncated, err := searchFiles(params.Pattern, searchPath, params.Include, 100)
144+ matches, truncated, err := searchFiles(searchPattern, searchPath, params.Include, 100)
112 if err != nil {
145 if err != nil {
113− return NewTextErrorResponse(fmt.Sprintf("error searching files: %s", err)), nil
146+ return ToolResponse{}, fmt.Errorf("error searching files: %w", err)
114 }
147 }
115
148
116 var output string
149 var output string
117 if len(matches) == 0 {
150 if len(matches) == 0 {
118 output = "No files found"
151 output = "No files found"
119 } else {
152 } else {
120− output = fmt.Sprintf("Found %d file%s\n%s",
153+ output = fmt.Sprintf("Found %d matches\n", len(matches))
121− len(matches),
154+
122− pluralize(len(matches)),
155+ currentFile := ""
123− strings.Join(matches, "\n"))
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+ }
124
170
125 if truncated {
171 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.)"
127 }
173 }
128 }
174 }
129
175
130− return NewTextResponse(output), nil
176+ return WithResponseMetadata(
131−}
177+ NewTextResponse(output),
132−
178+ GrepResponseMetadata{
133−func pluralize(count int) string {
179+ NumberOfMatches: len(matches),
134− if count == 1 {
180+ Truncated: truncated,
135− return ""
181+ },
136− }
182+ ), nil
137− return "s"
138}
183}
139
184
140−func searchFiles(pattern, rootPath, include string, limit int) ([]string, bool, error) {
185+func searchFiles(pattern, rootPath, include string, limit int) ([]grepMatch, bool, error) {
141 matches, err := searchWithRipgrep(pattern, rootPath, include)
186 matches, err := searchWithRipgrep(pattern, rootPath, include)
142 if err != nil {
187 if err != nil {
143 matches, err = searchFilesWithRegex(pattern, rootPath, include)
188 matches, err = searchFilesWithRegex(pattern, rootPath, include)
@@ -155,12 +200,7 @@ func searchFiles(pattern, rootPath, include string, limit int) ([]string, bool,
155 matches = matches[:limit]
200 matches = matches[:limit]
156 }
201 }
157
202
158− results := make([]string, len(matches))
203+ return matches, truncated, nil
159− for i, m := range matches {
160− results[i] = m.path
161− }
162−
163− return results, truncated, nil
164}
204}
165
205
166func searchWithRipgrep(pattern, path, include string) ([]grepMatch, error) {
206func searchWithRipgrep(pattern, path, include string) ([]grepMatch, error) {
@@ -169,7 +209,8 @@ func searchWithRipgrep(pattern, path, include string) ([]grepMatch, error) {
169 return nil, fmt.Errorf("ripgrep not found: %w", err)
209 return nil, fmt.Errorf("ripgrep not found: %w", err)
170 }
210 }
171
211
172− args := []string{"-l", pattern}
212+ // Use -n to show line numbers and include the matched line
213+ args := []string{"-n", pattern}
173 if include != "" {
214 if include != "" {
174 args = append(args, "--glob", include)
215 args = append(args, "--glob", include)
175 }
216 }
@@ -192,14 +233,29 @@ func searchWithRipgrep(pattern, path, include string) ([]grepMatch, error) {
192 continue
233 continue
193 }
234 }
194
235
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)
196 if err != nil {
250 if err != nil {
197 continue // Skip files we can't access
251 continue // Skip files we can't access
198 }
252 }
199
253
200 matches = append(matches, grepMatch{
254 matches = append(matches, grepMatch{
201− path: line,
255+ path: filePath,
202− modTime: fileInfo.ModTime(),
256+ modTime: fileInfo.ModTime(),
257+ lineNum: lineNum,
258+ lineText: lineText,
203 })
259 })
204 }
260 }
205
261
@@ -240,15 +296,17 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
240 return nil
296 return nil
241 }
297 }
242
298
243− match, err := fileContainsPattern(path, regex)
299+ match, lineNum, lineText, err := fileContainsPattern(path, regex)
244 if err != nil {
300 if err != nil {
245 return nil // Skip files we can't read
301 return nil // Skip files we can't read
246 }
302 }
247
303
248 if match {
304 if match {
249 matches = append(matches, grepMatch{
305 matches = append(matches, grepMatch{
250− path: path,
306+ path: path,
251− modTime: info.ModTime(),
307+ modTime: info.ModTime(),
308+ lineNum: lineNum,
309+ lineText: lineText,
252 })
310 })
253
311
254 if len(matches) >= 200 {
312 if len(matches) >= 200 {
@@ -265,21 +323,24 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
265 return matches, nil
323 return matches, nil
266}
324}
267
325
268−func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, error) {
326+func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, string, error) {
269 file, err := os.Open(filePath)
327 file, err := os.Open(filePath)
270 if err != nil {
328 if err != nil {
271− return false, err
329+ return false, 0, "", err
272 }
330 }
273 defer file.Close()
331 defer file.Close()
274
332
275 scanner := bufio.NewScanner(file)
333 scanner := bufio.NewScanner(file)
334+ lineNum := 0
276 for scanner.Scan() {
335 for scanner.Scan() {
277− if pattern.MatchString(scanner.Text()) {
336+ lineNum++
278− return true, nil
337+ line := scanner.Text()
338+ if pattern.MatchString(line) {
339+ return true, lineNum, line, nil
279 }
340 }
280 }
341 }
281
342
282− return false, scanner.Err()
343+ return false, 0, "", scanner.Err()
283}
344}
284
345
285func globToRegex(glob string) string {
346func globToRegex(glob string) string {
internal/llm/tools/ls.go+14−3
@@ -8,7 +8,7 @@ import (
8 "path/filepath"
8 "path/filepath"
9 "strings"
9 "strings"
10
10
11− "github.com/kujtimiihoxha/termai/internal/config"
11+ "github.com/kujtimiihoxha/opencode/internal/config"
12)
12)
13
13
14type LSParams struct {
14type LSParams struct {
@@ -23,6 +23,11 @@ type TreeNode struct {
23 Children []*TreeNode `json:"children,omitempty"`
23 Children []*TreeNode `json:"children,omitempty"`
24}
24}
25
25
26+type LSResponseMetadata struct {
27+ NumberOfFiles int `json:"number_of_files"`
28+ Truncated bool `json:"truncated"`
29+}
30+
26type lsTool struct{}
31type lsTool struct{}
27
32
28const (
33const (
@@ -104,7 +109,7 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
104
109
105 files, truncated, err := listDirectory(searchPath, params.Ignore, MaxLSFiles)
110 files, truncated, err := listDirectory(searchPath, params.Ignore, MaxLSFiles)
106 if err != nil {
111 if err != nil {
107− return NewTextErrorResponse(fmt.Sprintf("error listing directory: %s", err)), nil
112+ return ToolResponse{}, fmt.Errorf("error listing directory: %w", err)
108 }
113 }
109
114
110 tree := createFileTree(files)
115 tree := createFileTree(files)
@@ -114,7 +119,13 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
114 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)
119 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)
115 }
120 }
116
121
117− return NewTextResponse(output), nil
122+ return WithResponseMetadata(
123+ NewTextResponse(output),
124+ LSResponseMetadata{
125+ NumberOfFiles: len(files),
126+ Truncated: truncated,
127+ },
128+ ), nil
118}
129}
119
130
120func listDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) {
131func 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 {
83 commandQueue: make(chan *commandExecution, 10),
83 commandQueue: make(chan *commandExecution, 10),
84 }
84 }
85
85
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+ }()
87
96
88 go func() {
97 go func() {
89 err := cmd.Wait()
98 err := cmd.Wait()
90 if err != nil {
99 if err != nil {
100+ // Log the error if needed
91 }
101 }
92 shell.isAlive = false
102 shell.isAlive = false
93 close(shell.commandQueue)
103 close(shell.commandQueue)
@@ -116,10 +126,10 @@ func (s *PersistentShell) execCommand(command string, timeout time.Duration, ctx
116 }
126 }
117
127
118 tempDir := os.TempDir()
128 tempDir := os.TempDir()
119− stdoutFile := filepath.Join(tempDir, fmt.Sprintf("termai-stdout-%d", time.Now().UnixNano()))
129+ stdoutFile := filepath.Join(tempDir, fmt.Sprintf("opencode-stdout-%d", time.Now().UnixNano()))
120− stderrFile := filepath.Join(tempDir, fmt.Sprintf("termai-stderr-%d", time.Now().UnixNano()))
130+ stderrFile := filepath.Join(tempDir, fmt.Sprintf("opencode-stderr-%d", time.Now().UnixNano()))
121− statusFile := filepath.Join(tempDir, fmt.Sprintf("termai-status-%d", time.Now().UnixNano()))
131+ statusFile := filepath.Join(tempDir, fmt.Sprintf("opencode-status-%d", time.Now().UnixNano()))
122− cwdFile := filepath.Join(tempDir, fmt.Sprintf("termai-cwd-%d", time.Now().UnixNano()))
132+ cwdFile := filepath.Join(tempDir, fmt.Sprintf("opencode-cwd-%d", time.Now().UnixNano()))
123
133
124 defer func() {
134 defer func() {
125 os.Remove(stdoutFile)
135 os.Remove(stdoutFile)
internal/llm/tools/sourcegraph.go+11−6
@@ -18,6 +18,11 @@ type SourcegraphParams struct {
18 Timeout int `json:"timeout,omitempty"`
18 Timeout int `json:"timeout,omitempty"`
19}
19}
20
20
21+type SourcegraphResponseMetadata struct {
22+ NumberOfMatches int `json:"number_of_matches"`
23+ Truncated bool `json:"truncated"`
24+}
25+
21type sourcegraphTool struct {
26type sourcegraphTool struct {
22 client *http.Client
27 client *http.Client
23}
28}
@@ -198,7 +203,7 @@ func (t *sourcegraphTool) Run(ctx context.Context, call ToolCall) (ToolResponse,
198
203
199 graphqlQueryBytes, err := json.Marshal(request)
204 graphqlQueryBytes, err := json.Marshal(request)
200 if err != nil {
205 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)
202 }
207 }
203 graphqlQuery := string(graphqlQueryBytes)
208 graphqlQuery := string(graphqlQueryBytes)
204
209
@@ -209,15 +214,15 @@ func (t *sourcegraphTool) Run(ctx context.Context, call ToolCall) (ToolResponse,
209 bytes.NewBuffer([]byte(graphqlQuery)),
214 bytes.NewBuffer([]byte(graphqlQuery)),
210 )
215 )
211 if err != nil {
216 if err != nil {
212− return NewTextErrorResponse("Failed to create request: " + err.Error()), nil
217+ return ToolResponse{}, fmt.Errorf("failed to create request: %w", err)
213 }
218 }
214
219
215 req.Header.Set("Content-Type", "application/json")
220 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")
217
222
218 resp, err := client.Do(req)
223 resp, err := client.Do(req)
219 if err != nil {
224 if err != nil {
220− return NewTextErrorResponse("Failed to execute request: " + err.Error()), nil
225+ return ToolResponse{}, fmt.Errorf("failed to fetch URL: %w", err)
221 }
226 }
222 defer resp.Body.Close()
227 defer resp.Body.Close()
223
228
@@ -231,12 +236,12 @@ func (t *sourcegraphTool) Run(ctx context.Context, call ToolCall) (ToolResponse,
231 }
236 }
232 body, err := io.ReadAll(resp.Body)
237 body, err := io.ReadAll(resp.Body)
233 if err != nil {
238 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)
235 }
240 }
236
241
237 var result map[string]any
242 var result map[string]any
238 if err = json.Unmarshal(body, &result); err != nil {
243 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)
240 }
245 }
241
246
242 formattedResults, err := formatSourcegraphResults(result, params.ContextWindow)
247 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 @@
1package tools
1package tools
2
2
3−import "context"
3+import (
4+ "context"
5+ "encoding/json"
6+)
4
7
5type ToolInfo struct {
8type ToolInfo struct {
6 Name string
9 Name string
@@ -11,15 +14,24 @@ type ToolInfo struct {
11
14
12type toolResponseType string
15type toolResponseType string
13
16
17+type (
18+ sessionIDContextKey string
19+ messageIDContextKey string
20+)
21+
14const (
22const (
15 ToolResponseTypeText toolResponseType = "text"
23 ToolResponseTypeText toolResponseType = "text"
16 ToolResponseTypeImage toolResponseType = "image"
24 ToolResponseTypeImage toolResponseType = "image"
25+
26+ SessionIDContextKey sessionIDContextKey = "session_id"
27+ MessageIDContextKey messageIDContextKey = "message_id"
17)
28)
18
29
19type ToolResponse struct {
30type ToolResponse struct {
20− Type toolResponseType `json:"type"`
31+ Type toolResponseType `json:"type"`
21− Content string `json:"content"`
32+ Content string `json:"content"`
22− IsError bool `json:"is_error"`
33+ Metadata string `json:"metadata,omitempty"`
34+ IsError bool `json:"is_error"`
23}
35}
24
36
25func NewTextResponse(content string) ToolResponse {
37func NewTextResponse(content string) ToolResponse {
@@ -29,6 +41,17 @@ func NewTextResponse(content string) ToolResponse {
29 }
41 }
30}
42}
31
43
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+
32func NewTextErrorResponse(content string) ToolResponse {
55func NewTextErrorResponse(content string) ToolResponse {
33 return ToolResponse{
56 return ToolResponse{
34 Type: ToolResponseTypeText,
57 Type: ToolResponseTypeText,
@@ -47,3 +70,15 @@ type BaseTool interface {
47 Info() ToolInfo
70 Info() ToolInfo
48 Run(ctx context.Context, params ToolCall) (ToolResponse, error)
71 Run(ctx context.Context, params ToolCall) (ToolResponse, error)
49}
72}
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 (
10 "path/filepath"
10 "path/filepath"
11 "strings"
11 "strings"
12
12
13− "github.com/kujtimiihoxha/termai/internal/config"
13+ "github.com/kujtimiihoxha/opencode/internal/config"
14− "github.com/kujtimiihoxha/termai/internal/lsp"
14+ "github.com/kujtimiihoxha/opencode/internal/lsp"
15)
15)
16
16
17type ViewParams struct {
17type ViewParams struct {
@@ -24,6 +24,11 @@ type viewTool struct {
24 lspClients map[string]*lsp.Client
24 lspClients map[string]*lsp.Client
25}
25}
26
26
27+type ViewResponseMetadata struct {
28+ FilePath string `json:"file_path"`
29+ Content string `json:"content"`
30+}
31+
27const (
32const (
28 ViewToolName = "view"
33 ViewToolName = "view"
29 MaxReadSize = 250 * 1024
34 MaxReadSize = 250 * 1024
@@ -135,7 +140,7 @@ func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
135
140
136 return NewTextErrorResponse(fmt.Sprintf("File not found: %s", filePath)), nil
141 return NewTextErrorResponse(fmt.Sprintf("File not found: %s", filePath)), nil
137 }
142 }
138− return NewTextErrorResponse(fmt.Sprintf("Failed to access file: %s", err)), nil
143+ return ToolResponse{}, fmt.Errorf("error accessing file: %w", err)
139 }
144 }
140
145
141 // Check if it's a directory
146 // Check if it's a directory
@@ -156,6 +161,7 @@ func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
156
161
157 // Check if it's an image file
162 // Check if it's an image file
158 isImage, imageType := isImageFile(filePath)
163 isImage, imageType := isImageFile(filePath)
164+ // TODO: handle images
159 if isImage {
165 if isImage {
160 return NewTextErrorResponse(fmt.Sprintf("This is an image file of type: %s\nUse a different tool to process images", imageType)), nil
166 return NewTextErrorResponse(fmt.Sprintf("This is an image file of type: %s\nUse a different tool to process images", imageType)), nil
161 }
167 }
@@ -163,7 +169,7 @@ func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
163 // Read the file content
169 // Read the file content
164 content, lineCount, err := readTextFile(filePath, params.Offset, params.Limit)
170 content, lineCount, err := readTextFile(filePath, params.Offset, params.Limit)
165 if err != nil {
171 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)
167 }
173 }
168
174
169 notifyLspOpenFile(ctx, filePath, v.lspClients)
175 notifyLspOpenFile(ctx, filePath, v.lspClients)
@@ -177,9 +183,15 @@ func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
177 params.Offset+len(strings.Split(content, "\n")))
183 params.Offset+len(strings.Split(content, "\n")))
178 }
184 }
179 output += "\n</file>\n"
185 output += "\n</file>\n"
180− output += appendDiagnostics(filePath, v.lspClients)
186+ output += getDiagnostics(filePath, v.lspClients)
181 recordFileRead(filePath)
187 recordFileRead(filePath)
182− return NewTextResponse(output), nil
188+ return WithResponseMetadata(
189+ NewTextResponse(output),
190+ ViewResponseMetadata{
191+ FilePath: filePath,
192+ Content: content,
193+ },
194+ ), nil
183}
195}
184
196
185func addLineNumbers(content string, startLine int) string {
197func addLineNumbers(content string, startLine int) string {
internal/llm/tools/write.go+71−14
@@ -6,11 +6,15 @@ import (
6 "fmt"
6 "fmt"
7 "os"
7 "os"
8 "path/filepath"
8 "path/filepath"
9+ "strings"
9 "time"
10 "time"
10
11
11− "github.com/kujtimiihoxha/termai/internal/config"
12+ "github.com/kujtimiihoxha/opencode/internal/config"
12− "github.com/kujtimiihoxha/termai/internal/lsp"
13+ "github.com/kujtimiihoxha/opencode/internal/diff"
13− "github.com/kujtimiihoxha/termai/internal/permission"
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"
14)
18)
15
19
16type WriteParams struct {
20type WriteParams struct {
@@ -20,12 +24,19 @@ type WriteParams struct {
20
24
21type WritePermissionsParams struct {
25type WritePermissionsParams struct {
22 FilePath string `json:"file_path"`
26 FilePath string `json:"file_path"`
23− Content string `json:"content"`
27+ Diff string `json:"diff"`
24}
28}
25
29
26type writeTool struct {
30type writeTool struct {
27 lspClients map[string]*lsp.Client
31 lspClients map[string]*lsp.Client
28 permissions permission.Service
32 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"`
29}
40}
30
41
31const (
42const (
@@ -60,10 +71,11 @@ TIPS:
60- Always include descriptive comments when making changes to existing code`
71- Always include descriptive comments when making changes to existing code`
61)
72)
62
73
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 {
64 return &writeTool{
75 return &writeTool{
65 lspClients: lspClients,
76 lspClients: lspClients,
66 permissions: permissions,
77 permissions: permissions,
78+ files: files,
67 }
79 }
68}
80}
69
81
@@ -122,12 +134,12 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
122 return NewTextErrorResponse(fmt.Sprintf("File %s already contains the exact content. No changes made.", filePath)), nil
134 return NewTextErrorResponse(fmt.Sprintf("File %s already contains the exact content. No changes made.", filePath)), nil
123 }
135 }
124 } else if !os.IsNotExist(err) {
136 } 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)
126 }
138 }
127
139
128 dir := filepath.Dir(filePath)
140 dir := filepath.Dir(filePath)
129 if err = os.MkdirAll(dir, 0o755); err != nil {
141 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)
131 }
143 }
132
144
133 oldContent := ""
145 oldContent := ""
@@ -138,25 +150,64 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
138 }
150 }
139 }
151 }
140
152
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+ }
141 p := w.permissions.Request(
169 p := w.permissions.Request(
142 permission.CreatePermissionRequest{
170 permission.CreatePermissionRequest{
143− Path: filePath,
171+ SessionID: sessionID,
172+ Path: permissionPath,
144 ToolName: WriteToolName,
173 ToolName: WriteToolName,
145− Action: "create",
174+ Action: "write",
146 Description: fmt.Sprintf("Create file %s", filePath),
175 Description: fmt.Sprintf("Create file %s", filePath),
147 Params: WritePermissionsParams{
176 Params: WritePermissionsParams{
148 FilePath: filePath,
177 FilePath: filePath,
149− Content: GenerateDiff(oldContent, params.Content),
178+ Diff: diff,
150 },
179 },
151 },
180 },
152 )
181 )
153 if !p {
182 if !p {
154− return NewTextErrorResponse(fmt.Sprintf("Permission denied to create file: %s", filePath)), nil
183+ return ToolResponse{}, permission.ErrorPermissionDenied
155 }
184 }
156
185
157 err = os.WriteFile(filePath, []byte(params.Content), 0o644)
186 err = os.WriteFile(filePath, []byte(params.Content), 0o644)
158 if err != nil {
187 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)
160 }
211 }
161
212
162 recordFileWrite(filePath)
213 recordFileWrite(filePath)
@@ -165,6 +216,12 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
165
216
166 result := fmt.Sprintf("File successfully written: %s", filePath)
217 result := fmt.Sprintf("File successfully written: %s", filePath)
167 result = fmt.Sprintf("<result>\n%s\n</result>", result)
218 result = fmt.Sprintf("<result>\n%s\n</result>", result)
168− result += appendDiagnostics(filePath, w.lspClients)
219+ result += getDiagnostics(filePath, w.lspClients)
169− return NewTextResponse(result), nil
220+ return WithResponseMetadata(NewTextResponse(result),
221+ WriteResponseMetadata{
222+ Diff: diff,
223+ Additions: additions,
224+ Removals: removals,
225+ },
226+ ), nil
170}
227}
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 @@
1package logging
1package logging
2
2
3−import "log/slog"
3+import (
4+ "fmt"
5+ "log/slog"
6+ "os"
7+ "runtime/debug"
8+ "time"
9+)
4
10
5func Info(msg string, args ...any) {
11func Info(msg string, args ...any) {
6 slog.Info(msg, args...)
12 slog.Info(msg, args...)
@@ -37,3 +43,36 @@ func ErrorPersist(msg string, args ...any) {
37 args = append(args, persistKeyArg, true)
43 args = append(args, persistKeyArg, true)
38 slog.Error(msg, args...)
44 slog.Error(msg, args...)
39}
45}
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 (
9 "time"
9 "time"
10
10
11 "github.com/go-logfmt/logfmt"
11 "github.com/go-logfmt/logfmt"
12− "github.com/kujtimiihoxha/termai/internal/pubsub"
12+ "github.com/kujtimiihoxha/opencode/internal/pubsub"
13)
13)
14
14
15const (
15const (
internal/lsp/client.go+363−20
@@ -8,14 +8,15 @@ import (
8 "io"
8 "io"
9 "os"
9 "os"
10 "os/exec"
10 "os/exec"
11+ "path/filepath"
11 "strings"
12 "strings"
12 "sync"
13 "sync"
13 "sync/atomic"
14 "sync/atomic"
14 "time"
15 "time"
15
16
16− "github.com/kujtimiihoxha/termai/internal/config"
17+ "github.com/kujtimiihoxha/opencode/internal/config"
17− "github.com/kujtimiihoxha/termai/internal/logging"
18+ "github.com/kujtimiihoxha/opencode/internal/logging"
18− "github.com/kujtimiihoxha/termai/internal/lsp/protocol"
19+ "github.com/kujtimiihoxha/opencode/internal/lsp/protocol"
19)
20)
20
21
21type Client struct {
22type Client struct {
@@ -46,6 +47,9 @@ type Client struct {
46 // Files are currently opened by the LSP
47 // Files are currently opened by the LSP
47 openFiles map[string]*OpenFileInfo
48 openFiles map[string]*OpenFileInfo
48 openFilesMu sync.RWMutex
49 openFilesMu sync.RWMutex
50+
51+ // Server state
52+ serverState atomic.Value
49}
53}
50
54
51func NewClient(ctx context.Context, command string, args ...string) (*Client, error) {
55func 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
80 openFiles: make(map[string]*OpenFileInfo),
84 openFiles: make(map[string]*OpenFileInfo),
81 }
85 }
82
86
87+ // Initialize server state
88+ client.serverState.Store(StateStarting)
89+
83 // Start the LSP server process
90 // Start the LSP server process
84 if err := cmd.Start(); err != nil {
91 if err := cmd.Start(); err != nil {
85 return nil, fmt.Errorf("failed to start LSP server: %w", err)
92 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
97 }()
104 }()
98
105
99 // Start message handling loop
106 // 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+ }()
101
113
102 return client, nil
114 return client, nil
103}
115}
@@ -215,16 +227,6 @@ func (c *Client) InitializeLSPClient(ctx context.Context, workspaceDir string) (
215 return nil, fmt.Errorf("initialization failed: %w", err)
227 return nil, fmt.Errorf("initialization failed: %w", err)
216 }
228 }
217
229
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−
228 return &result, nil
230 return &result, nil
229}
231}
230
232
@@ -268,10 +270,314 @@ const (
268 StateError
270 StateError
269)
271)
270
272
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
271func (c *Client) WaitForServerReady(ctx context.Context) error {
288func (c *Client) WaitForServerReady(ctx context.Context) error {
272− // TODO: wait for specific messages or poll workspace/symbol
289+ cnf := config.Get()
273− time.Sleep(time.Second * 1)
290+
274− return nil
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})
275}
581}
276
582
277type OpenFileInfo struct {
583type OpenFileInfo struct {
@@ -374,7 +680,7 @@ func (c *Client) CloseFile(ctx context.Context, filepath string) error {
374 },
680 },
375 }
681 }
376
682
377− if cnf.Debug {
683+ if cnf.DebugLSP {
378 logging.Debug("Closing file", "file", filepath)
684 logging.Debug("Closing file", "file", filepath)
379 }
685 }
380 if err := c.Notify(ctx, "textDocument/didClose", params); err != nil {
686 if err := c.Notify(ctx, "textDocument/didClose", params); err != nil {
@@ -413,12 +719,12 @@ func (c *Client) CloseAllFiles(ctx context.Context) {
413 // Then close them all
719 // Then close them all
414 for _, filePath := range filesToClose {
720 for _, filePath := range filesToClose {
415 err := c.CloseFile(ctx, filePath)
721 err := c.CloseFile(ctx, filePath)
416− if err != nil && cnf.Debug {
722+ if err != nil && cnf.DebugLSP {
417 logging.Warn("Error closing file", "file", filePath, "error", err)
723 logging.Warn("Error closing file", "file", filePath, "error", err)
418 }
724 }
419 }
725 }
420
726
421− if cnf.Debug {
727+ if cnf.DebugLSP {
422 logging.Debug("Closed all files", "files", filesToClose)
728 logging.Debug("Closed all files", "files", filesToClose)
423 }
729 }
424}
730}
@@ -430,6 +736,43 @@ func (c *Client) GetFileDiagnostics(uri protocol.DocumentUri) []protocol.Diagnos
430 return c.diagnostics[uri]
736 return c.diagnostics[uri]
431}
737}
432
738
739+// GetDiagnostics returns all diagnostics for all files
433func (c *Client) GetDiagnostics() map[protocol.DocumentUri][]protocol.Diagnostic {
740func (c *Client) GetDiagnostics() map[protocol.DocumentUri][]protocol.Diagnostic {
434 return c.diagnostics
741 return c.diagnostics
435}
742}
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
3import (
3import (
4 "encoding/json"
4 "encoding/json"
5
5
6− "github.com/kujtimiihoxha/termai/internal/config"
6+ "github.com/kujtimiihoxha/opencode/internal/config"
7− "github.com/kujtimiihoxha/termai/internal/logging"
7+ "github.com/kujtimiihoxha/opencode/internal/logging"
8− "github.com/kujtimiihoxha/termai/internal/lsp/protocol"
8+ "github.com/kujtimiihoxha/opencode/internal/lsp/protocol"
9− "github.com/kujtimiihoxha/termai/internal/lsp/util"
9+ "github.com/kujtimiihoxha/opencode/internal/lsp/util"
10)
10)
11
11
12// Requests
12// Requests
@@ -88,7 +88,7 @@ func HandleServerMessage(params json.RawMessage) {
88 Message string `json:"message"`
88 Message string `json:"message"`
89 }
89 }
90 if err := json.Unmarshal(params, &msg); err == nil {
90 if err := json.Unmarshal(params, &msg); err == nil {
91− if cnf.Debug {
91+ if cnf.DebugLSP {
92 logging.Debug("Server message", "type", msg.Type, "message", msg.Message)
92 logging.Debug("Server message", "type", msg.Type, "message", msg.Message)
93 }
93 }
94 }
94 }
internal/lsp/language.go+1−1
@@ -4,7 +4,7 @@ import (
4 "path/filepath"
4 "path/filepath"
5 "strings"
5 "strings"
6
6
7− "github.com/kujtimiihoxha/termai/internal/lsp/protocol"
7+ "github.com/kujtimiihoxha/opencode/internal/lsp/protocol"
8)
8)
9
9
10func DetectLanguageID(uri string) protocol.LanguageKind {
10func DetectLanguageID(uri string) protocol.LanguageKind {
internal/lsp/methods.go+1−1
@@ -4,7 +4,7 @@ package lsp
4import (
4import (
5 "context"
5 "context"
6
6
7− "github.com/kujtimiihoxha/termai/internal/lsp/protocol"
7+ "github.com/kujtimiihoxha/opencode/internal/lsp/protocol"
8)
8)
9
9
10// Implementation sends a textDocument/implementation request to the LSP server.
10// Implementation sends a textDocument/implementation request to the LSP server.
internal/lsp/transport.go+16−16
@@ -8,8 +8,8 @@ import (
8 "io"
8 "io"
9 "strings"
9 "strings"
10
10
11− "github.com/kujtimiihoxha/termai/internal/config"
11+ "github.com/kujtimiihoxha/opencode/internal/config"
12− "github.com/kujtimiihoxha/termai/internal/logging"
12+ "github.com/kujtimiihoxha/opencode/internal/logging"
13)
13)
14
14
15// Write writes an LSP message to the given writer
15// Write writes an LSP message to the given writer
@@ -20,7 +20,7 @@ func WriteMessage(w io.Writer, msg *Message) error {
20 }
20 }
21 cnf := config.Get()
21 cnf := config.Get()
22
22
23− if cnf.Debug {
23+ if cnf.DebugLSP {
24 logging.Debug("Sending message to server", "method", msg.Method, "id", msg.ID)
24 logging.Debug("Sending message to server", "method", msg.Method, "id", msg.ID)
25 }
25 }
26
26
@@ -49,7 +49,7 @@ func ReadMessage(r *bufio.Reader) (*Message, error) {
49 }
49 }
50 line = strings.TrimSpace(line)
50 line = strings.TrimSpace(line)
51
51
52− if cnf.Debug {
52+ if cnf.DebugLSP {
53 logging.Debug("Received header", "line", line)
53 logging.Debug("Received header", "line", line)
54 }
54 }
55
55
@@ -65,7 +65,7 @@ func ReadMessage(r *bufio.Reader) (*Message, error) {
65 }
65 }
66 }
66 }
67
67
68− if cnf.Debug {
68+ if cnf.DebugLSP {
69 logging.Debug("Content-Length", "length", contentLength)
69 logging.Debug("Content-Length", "length", contentLength)
70 }
70 }
71
71
@@ -76,7 +76,7 @@ func ReadMessage(r *bufio.Reader) (*Message, error) {
76 return nil, fmt.Errorf("failed to read content: %w", err)
76 return nil, fmt.Errorf("failed to read content: %w", err)
77 }
77 }
78
78
79− if cnf.Debug {
79+ if cnf.DebugLSP {
80 logging.Debug("Received content", "content", string(content))
80 logging.Debug("Received content", "content", string(content))
81 }
81 }
82
82
@@ -95,7 +95,7 @@ func (c *Client) handleMessages() {
95 for {
95 for {
96 msg, err := ReadMessage(c.stdout)
96 msg, err := ReadMessage(c.stdout)
97 if err != nil {
97 if err != nil {
98− if cnf.Debug {
98+ if cnf.DebugLSP {
99 logging.Error("Error reading message", "error", err)
99 logging.Error("Error reading message", "error", err)
100 }
100 }
101 return
101 return
@@ -103,7 +103,7 @@ func (c *Client) handleMessages() {
103
103
104 // Handle server->client request (has both Method and ID)
104 // Handle server->client request (has both Method and ID)
105 if msg.Method != "" && msg.ID != 0 {
105 if msg.Method != "" && msg.ID != 0 {
106− if cnf.Debug {
106+ if cnf.DebugLSP {
107 logging.Debug("Received request from server", "method", msg.Method, "id", msg.ID)
107 logging.Debug("Received request from server", "method", msg.Method, "id", msg.ID)
108 }
108 }
109
109
@@ -157,11 +157,11 @@ func (c *Client) handleMessages() {
157 c.notificationMu.RUnlock()
157 c.notificationMu.RUnlock()
158
158
159 if ok {
159 if ok {
160− if cnf.Debug {
160+ if cnf.DebugLSP {
161 logging.Debug("Handling notification", "method", msg.Method)
161 logging.Debug("Handling notification", "method", msg.Method)
162 }
162 }
163 go handler(msg.Params)
163 go handler(msg.Params)
164− } else if cnf.Debug {
164+ } else if cnf.DebugLSP {
165 logging.Debug("No handler for notification", "method", msg.Method)
165 logging.Debug("No handler for notification", "method", msg.Method)
166 }
166 }
167 continue
167 continue
@@ -174,12 +174,12 @@ func (c *Client) handleMessages() {
174 c.handlersMu.RUnlock()
174 c.handlersMu.RUnlock()
175
175
176 if ok {
176 if ok {
177− if cnf.Debug {
177+ if cnf.DebugLSP {
178 logging.Debug("Received response for request", "id", msg.ID)
178 logging.Debug("Received response for request", "id", msg.ID)
179 }
179 }
180 ch <- msg
180 ch <- msg
181 close(ch)
181 close(ch)
182− } else if cnf.Debug {
182+ } else if cnf.DebugLSP {
183 logging.Debug("No handler for response", "id", msg.ID)
183 logging.Debug("No handler for response", "id", msg.ID)
184 }
184 }
185 }
185 }
@@ -191,7 +191,7 @@ func (c *Client) Call(ctx context.Context, method string, params any, result any
191 cnf := config.Get()
191 cnf := config.Get()
192 id := c.nextID.Add(1)
192 id := c.nextID.Add(1)
193
193
194− if cnf.Debug {
194+ if cnf.DebugLSP {
195 logging.Debug("Making call", "method", method, "id", id)
195 logging.Debug("Making call", "method", method, "id", id)
196 }
196 }
197
197
@@ -217,14 +217,14 @@ func (c *Client) Call(ctx context.Context, method string, params any, result any
217 return fmt.Errorf("failed to send request: %w", err)
217 return fmt.Errorf("failed to send request: %w", err)
218 }
218 }
219
219
220− if cnf.Debug {
220+ if cnf.DebugLSP {
221 logging.Debug("Request sent", "method", method, "id", id)
221 logging.Debug("Request sent", "method", method, "id", id)
222 }
222 }
223
223
224 // Wait for response
224 // Wait for response
225 resp := <-ch
225 resp := <-ch
226
226
227− if cnf.Debug {
227+ if cnf.DebugLSP {
228 logging.Debug("Received response", "id", id)
228 logging.Debug("Received response", "id", id)
229 }
229 }
230
230
@@ -250,7 +250,7 @@ func (c *Client) Call(ctx context.Context, method string, params any, result any
250// Notify sends a notification (a request without an ID that doesn't expect a response)
250// Notify sends a notification (a request without an ID that doesn't expect a response)
251func (c *Client) Notify(ctx context.Context, method string, params any) error {
251func (c *Client) Notify(ctx context.Context, method string, params any) error {
252 cnf := config.Get()
252 cnf := config.Get()
253− if cnf.Debug {
253+ if cnf.DebugLSP {
254 logging.Debug("Sending notification", "method", method)
254 logging.Debug("Sending notification", "method", method)
255 }
255 }
256
256
internal/lsp/util/edit.go+1−1
@@ -7,7 +7,7 @@ import (
7 "sort"
7 "sort"
8 "strings"
8 "strings"
9
9
10− "github.com/kujtimiihoxha/termai/internal/lsp/protocol"
10+ "github.com/kujtimiihoxha/opencode/internal/lsp/protocol"
11)
11)
12
12
13func applyTextEdits(uri protocol.DocumentUri, edits []protocol.TextEdit) error {
13func applyTextEdits(uri protocol.DocumentUri, edits []protocol.TextEdit) error {
internal/lsp/watcher/watcher.go+386−60
@@ -9,11 +9,12 @@ import (
9 "sync"
9 "sync"
10 "time"
10 "time"
11
11
12+ "github.com/bmatcuk/doublestar/v4"
12 "github.com/fsnotify/fsnotify"
13 "github.com/fsnotify/fsnotify"
13− "github.com/kujtimiihoxha/termai/internal/config"
14+ "github.com/kujtimiihoxha/opencode/internal/config"
14− "github.com/kujtimiihoxha/termai/internal/logging"
15+ "github.com/kujtimiihoxha/opencode/internal/logging"
15− "github.com/kujtimiihoxha/termai/internal/lsp"
16+ "github.com/kujtimiihoxha/opencode/internal/lsp"
16− "github.com/kujtimiihoxha/termai/internal/lsp/protocol"
17+ "github.com/kujtimiihoxha/opencode/internal/lsp/protocol"
17)
18)
18
19
19// WorkspaceWatcher manages LSP file watching
20// WorkspaceWatcher manages LSP file watching
@@ -43,6 +44,8 @@ func NewWorkspaceWatcher(client *lsp.Client) *WorkspaceWatcher {
43// AddRegistrations adds file watchers to track
44// AddRegistrations adds file watchers to track
44func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watchers []protocol.FileSystemWatcher) {
45func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watchers []protocol.FileSystemWatcher) {
45 cnf := config.Get()
46 cnf := config.Get()
47+
48+ logging.Debug("Adding file watcher registrations")
46 w.registrationMu.Lock()
49 w.registrationMu.Lock()
47 defer w.registrationMu.Unlock()
50 defer w.registrationMu.Unlock()
48
51
@@ -50,12 +53,11 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
50 w.registrations = append(w.registrations, watchers...)
53 w.registrations = append(w.registrations, watchers...)
51
54
52 // Print detailed registration information for debugging
55 // Print detailed registration information for debugging
53− if cnf.Debug {
56+ if cnf.DebugLSP {
54 logging.Debug("Adding file watcher registrations",
57 logging.Debug("Adding file watcher registrations",
55 "id", id,
58 "id", id,
56 "watchers", len(watchers),
59 "watchers", len(watchers),
57 "total", len(w.registrations),
60 "total", len(w.registrations),
58− "watchers", watchers,
59 )
61 )
60
62
61 for i, watcher := range watchers {
63 for i, watcher := range watchers {
@@ -88,66 +90,217 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
88 }
90 }
89
91
90 logging.Debug("WatchKind", "kind", watchKind)
92 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− }
102 }
93 }
103 }
94 }
104
95
105− // Find and open all existing files that match the newly registered patterns
96+ // Determine server type for specialized handling
106− // TODO: not all language servers require this, but typescript does. Make this configurable
97+ serverName := getServerNameFromContext(ctx)
107− go func() {
98+ logging.Debug("Server type detected", "serverName", serverName)
108− startTime := time.Now()
99+
109− filesOpened := 0
100+ // Check if this server has sent file watchers
110−
101+ hasFileWatchers := len(watchers) > 0
111− err := filepath.WalkDir(w.workspacePath, func(path string, d os.DirEntry, err error) error {
102+
112− if err != nil {
103+ // For servers that need file preloading, we'll use a smart approach
113− return err
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
114 }
139 }
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+ }
115
147
116− // Skip directories that should be excluded
148+ // Skip directories that should be excluded
117− if d.IsDir() {
149+ if d.IsDir() {
118− if path != w.workspacePath && shouldExcludeDir(path) {
150+ if path != w.workspacePath && shouldExcludeDir(path) {
119− if cnf.Debug {
151+ if cnf.DebugLSP {
120− logging.Debug("Skipping excluded directory", "path", path)
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
121 }
172 }
122− return filepath.SkipDir
123 }
173 }
124− } else {
125− // Process files
126− w.openMatchingFile(ctx, path)
127− filesOpened++
128
174
129− // Add a small delay after every 100 files to prevent overwhelming the server
175+ return nil
130− if filesOpened%100 == 0 {
176+ })
131− time.Sleep(10 * time.Millisecond)
177+
132− }
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+ )
133 }
186 }
134
187
135− return nil
188+ if err != nil && cnf.DebugLSP {
136− })
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+}
137
196
138− elapsedTime := time.Since(startTime)
197+// openHighPriorityFiles opens important files for the server type
139− if cnf.Debug {
198+// Returns the number of files opened
140− logging.Debug("Workspace scan complete",
199+func (w *WorkspaceWatcher) openHighPriorityFiles(ctx context.Context, serverName string) int {
141− "filesOpened", filesOpened,
200+ cnf := config.Get()
142− "elapsedTime", elapsedTime.Seconds(),
201+ filesOpened := 0
143− "workspacePath", w.workspacePath,
202+
144− )
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",
145 }
216 }
146−
217+ case "gopls":
147− if err != nil && cnf.Debug {
218+ patterns = []string{
148− logging.Debug("Error scanning workspace for files to open", "error", err)
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+ }
149 }
300 }
150− }()
301+ }
302+
303+ return filesOpened
151}
304}
152
305
153// WatchWorkspace sets up file watching for a workspace
306// WatchWorkspace sets up file watching for a workspace
@@ -155,6 +308,18 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
155 cnf := config.Get()
308 cnf := config.Get()
156 w.workspacePath = workspacePath
309 w.workspacePath = workspacePath
157
310
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+
158 // Register handler for file watcher registrations from the server
323 // Register handler for file watcher registrations from the server
159 lsp.RegisterFileWatchHandler(func(id string, watchers []protocol.FileSystemWatcher) {
324 lsp.RegisterFileWatchHandler(func(id string, watchers []protocol.FileSystemWatcher) {
160 w.AddRegistrations(ctx, id, watchers)
325 w.AddRegistrations(ctx, id, watchers)
@@ -175,7 +340,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
175 // Skip excluded directories (except workspace root)
340 // Skip excluded directories (except workspace root)
176 if d.IsDir() && path != workspacePath {
341 if d.IsDir() && path != workspacePath {
177 if shouldExcludeDir(path) {
342 if shouldExcludeDir(path) {
178− if cnf.Debug {
343+ if cnf.DebugLSP {
179 logging.Debug("Skipping excluded directory", "path", path)
344 logging.Debug("Skipping excluded directory", "path", path)
180 }
345 }
181 return filepath.SkipDir
346 return filepath.SkipDir
@@ -228,7 +393,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
228 }
393 }
229
394
230 // Debug logging
395 // Debug logging
231− if cnf.Debug {
396+ if cnf.DebugLSP {
232 matched, kind := w.isPathWatched(event.Name)
397 matched, kind := w.isPathWatched(event.Name)
233 logging.Debug("File event",
398 logging.Debug("File event",
234 "path", event.Name,
399 "path", event.Name,
@@ -491,7 +656,7 @@ func (w *WorkspaceWatcher) handleFileEvent(ctx context.Context, uri string, chan
491// notifyFileEvent sends a didChangeWatchedFiles notification for a file event
656// notifyFileEvent sends a didChangeWatchedFiles notification for a file event
492func (w *WorkspaceWatcher) notifyFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) error {
657func (w *WorkspaceWatcher) notifyFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) error {
493 cnf := config.Get()
658 cnf := config.Get()
494− if cnf.Debug {
659+ if cnf.DebugLSP {
495 logging.Debug("Notifying file event",
660 logging.Debug("Notifying file event",
496 "uri", uri,
661 "uri", uri,
497 "changeType", changeType,
662 "changeType", changeType,
@@ -510,6 +675,57 @@ func (w *WorkspaceWatcher) notifyFileEvent(ctx context.Context, uri string, chan
510 return w.client.DidChangeWatchedFiles(ctx, params)
675 return w.client.DidChangeWatchedFiles(ctx, params)
511}
676}
512
677
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+
513// Common patterns for directories and files to exclude
729// Common patterns for directories and files to exclude
514// TODO: make configurable
730// TODO: make configurable
515var (
731var (
@@ -615,7 +831,7 @@ func shouldExcludeFile(filePath string) bool {
615
831
616 // Skip large files
832 // Skip large files
617 if info.Size() > maxFileSize {
833 if info.Size() > maxFileSize {
618− if cnf.Debug {
834+ if cnf.DebugLSP {
619 logging.Debug("Skipping large file",
835 logging.Debug("Skipping large file",
620 "path", filePath,
836 "path", filePath,
621 "size", info.Size(),
837 "size", info.Size(),
@@ -647,9 +863,119 @@ func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
647
863
648 // Check if this path should be watched according to server registrations
864 // Check if this path should be watched according to server registrations
649 if watched, _ := w.isPathWatched(path); watched {
865 if watched, _ := w.isPathWatched(path); watched {
650− // Don't need to check if it's already open - the client.OpenFile handles that
866+ // Get server name for specialized handling
651− if err := w.client.OpenFile(ctx, path); err != nil && cnf.Debug {
867+ serverName := getServerNameFromContext(ctx)
652− logging.Error("Error opening file", "path", path, "error", err)
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+ }
653 }
923 }
654 }
924 }
655}
925}
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
2
2
3import (
3import (
4 "encoding/base64"
4 "encoding/base64"
5+ "slices"
6+ "time"
7+
8+ "github.com/kujtimiihoxha/opencode/internal/llm/models"
5)
9)
6
10
7type MessageRole string
11type MessageRole string
@@ -13,6 +17,20 @@ const (
13 Tool MessageRole = "tool"
17 Tool MessageRole = "tool"
14)
18)
15
19
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+
16type ContentPart interface {
34type ContentPart interface {
17 isPart()
35 isPart()
18}
36}
@@ -73,13 +91,15 @@ type ToolResult struct {
73 ToolCallID string `json:"tool_call_id"`
91 ToolCallID string `json:"tool_call_id"`
74 Name string `json:"name"`
92 Name string `json:"name"`
75 Content string `json:"content"`
93 Content string `json:"content"`
94+ Metadata string `json:"metadata"`
76 IsError bool `json:"is_error"`
95 IsError bool `json:"is_error"`
77}
96}
78
97
79func (ToolResult) isPart() {}
98func (ToolResult) isPart() {}
80
99
81type Finish struct {
100type Finish struct {
82− Reason string `json:"reason"`
101+ Reason FinishReason `json:"reason"`
102+ Time int64 `json:"time"`
83}
103}
84
104
85func (Finish) isPart() {}
105func (Finish) isPart() {}
@@ -89,6 +109,7 @@ type Message struct {
89 Role MessageRole
109 Role MessageRole
90 SessionID string
110 SessionID string
91 Parts []ContentPart
111 Parts []ContentPart
112+ Model models.ModelID
92
113
93 CreatedAt int64
114 CreatedAt int64
94 UpdatedAt int64
115 UpdatedAt int64
@@ -161,7 +182,16 @@ func (m *Message) IsFinished() bool {
161 return false
182 return false
162}
183}
163
184
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 {
165 for _, part := range m.Parts {
195 for _, part := range m.Parts {
166 if c, ok := part.(Finish); ok {
196 if c, ok := part.(Finish); ok {
167 return c.Reason
197 return c.Reason
@@ -203,6 +233,40 @@ func (m *Message) AppendReasoningContent(delta string) {
203 }
233 }
204}
234}
205
235
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+
206func (m *Message) AddToolCall(tc ToolCall) {
270func (m *Message) AddToolCall(tc ToolCall) {
207 for i, part := range m.Parts {
271 for i, part := range m.Parts {
208 if c, ok := part.(ToolCall); ok {
272 if c, ok := part.(ToolCall); ok {
@@ -216,6 +280,15 @@ func (m *Message) AddToolCall(tc ToolCall) {
216}
280}
217
281
218func (m *Message) SetToolCalls(tc []ToolCall) {
282func (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
219 for _, toolCall := range tc {
292 for _, toolCall := range tc {
220 m.Parts = append(m.Parts, toolCall)
293 m.Parts = append(m.Parts, toolCall)
221 }
294 }
@@ -231,8 +304,15 @@ func (m *Message) SetToolResults(tr []ToolResult) {
231 }
304 }
232}
305}
233
306
234−func (m *Message) AddFinish(reason string) {
307+func (m *Message) AddFinish(reason FinishReason) {
235− m.Parts = append(m.Parts, Finish{Reason: reason})
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()})
236}
316}
237
317
238func (m *Message) AddImageURL(url, detail string) {
318func (m *Message) AddImageURL(url, detail string) {
internal/message/message.go+39−28
@@ -2,49 +2,51 @@ package message
2
2
3import (
3import (
4 "context"
4 "context"
5+ "database/sql"
5 "encoding/json"
6 "encoding/json"
6 "fmt"
7 "fmt"
8+ "time"
7
9
8 "github.com/google/uuid"
10 "github.com/google/uuid"
9− "github.com/kujtimiihoxha/termai/internal/db"
11+ "github.com/kujtimiihoxha/opencode/internal/db"
10− "github.com/kujtimiihoxha/termai/internal/pubsub"
12+ "github.com/kujtimiihoxha/opencode/internal/llm/models"
13+ "github.com/kujtimiihoxha/opencode/internal/pubsub"
11)
14)
12
15
13type CreateMessageParams struct {
16type CreateMessageParams struct {
14 Role MessageRole
17 Role MessageRole
15 Parts []ContentPart
18 Parts []ContentPart
19+ Model models.ModelID
16}
20}
17
21
18type Service interface {
22type Service interface {
19 pubsub.Suscriber[Message]
23 pubsub.Suscriber[Message]
20− Create(sessionID string, params CreateMessageParams) (Message, error)
24+ Create(ctx context.Context, sessionID string, params CreateMessageParams) (Message, error)
21− Update(message Message) error
25+ Update(ctx context.Context, message Message) error
22− Get(id string) (Message, error)
26+ Get(ctx context.Context, id string) (Message, error)
23− List(sessionID string) ([]Message, error)
27+ List(ctx context.Context, sessionID string) ([]Message, error)
24− Delete(id string) error
28+ Delete(ctx context.Context, id string) error
25− DeleteSessionMessages(sessionID string) error
29+ DeleteSessionMessages(ctx context.Context, sessionID string) error
26}
30}
27
31
28type service struct {
32type service struct {
29 *pubsub.Broker[Message]
33 *pubsub.Broker[Message]
30− q db.Querier
34+ q db.Querier
31− ctx context.Context
32}
35}
33
36
34−func NewService(ctx context.Context, q db.Querier) Service {
37+func NewService(q db.Querier) Service {
35 return &service{
38 return &service{
36 Broker: pubsub.NewBroker[Message](),
39 Broker: pubsub.NewBroker[Message](),
37 q: q,
40 q: q,
38− ctx: ctx,
39 }
41 }
40}
42}
41
43
42−func (s *service) Delete(id string) error {
44+func (s *service) Delete(ctx context.Context, id string) error {
43− message, err := s.Get(id)
45+ message, err := s.Get(ctx, id)
44 if err != nil {
46 if err != nil {
45 return err
47 return err
46 }
48 }
47− err = s.q.DeleteMessage(s.ctx, message.ID)
49+ err = s.q.DeleteMessage(ctx, message.ID)
48 if err != nil {
50 if err != nil {
49 return err
51 return err
50 }
52 }
@@ -52,7 +54,7 @@ func (s *service) Delete(id string) error {
52 return nil
54 return nil
53}
55}
54
56
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) {
56 if params.Role != Assistant {
58 if params.Role != Assistant {
57 params.Parts = append(params.Parts, Finish{
59 params.Parts = append(params.Parts, Finish{
58 Reason: "stop",
60 Reason: "stop",
@@ -63,11 +65,12 @@ func (s *service) Create(sessionID string, params CreateMessageParams) (Message,
63 return Message{}, err
65 return Message{}, err
64 }
66 }
65
67
66− dbMessage, err := s.q.CreateMessage(s.ctx, db.CreateMessageParams{
68+ dbMessage, err := s.q.CreateMessage(ctx, db.CreateMessageParams{
67 ID: uuid.New().String(),
69 ID: uuid.New().String(),
68 SessionID: sessionID,
70 SessionID: sessionID,
69 Role: string(params.Role),
71 Role: string(params.Role),
70 Parts: string(partsJSON),
72 Parts: string(partsJSON),
73+ Model: sql.NullString{String: string(params.Model), Valid: true},
71 })
74 })
72 if err != nil {
75 if err != nil {
73 return Message{}, err
76 return Message{}, err
@@ -80,14 +83,14 @@ func (s *service) Create(sessionID string, params CreateMessageParams) (Message,
80 return message, nil
83 return message, nil
81}
84}
82
85
83−func (s *service) DeleteSessionMessages(sessionID string) error {
86+func (s *service) DeleteSessionMessages(ctx context.Context, sessionID string) error {
84− messages, err := s.List(sessionID)
87+ messages, err := s.List(ctx, sessionID)
85 if err != nil {
88 if err != nil {
86 return err
89 return err
87 }
90 }
88 for _, message := range messages {
91 for _, message := range messages {
89 if message.SessionID == sessionID {
92 if message.SessionID == sessionID {
90− err = s.Delete(message.ID)
93+ err = s.Delete(ctx, message.ID)
91 if err != nil {
94 if err != nil {
92 return err
95 return err
93 }
96 }
@@ -96,32 +99,39 @@ func (s *service) DeleteSessionMessages(sessionID string) error {
96 return nil
99 return nil
97}
100}
98
101
99−func (s *service) Update(message Message) error {
102+func (s *service) Update(ctx context.Context, message Message) error {
100 parts, err := marshallParts(message.Parts)
103 parts, err := marshallParts(message.Parts)
101 if err != nil {
104 if err != nil {
102 return err
105 return err
103 }
106 }
104− err = s.q.UpdateMessage(s.ctx, db.UpdateMessageParams{
107+ finishedAt := sql.NullInt64{}
105− ID: message.ID,
108+ if f := message.FinishPart(); f != nil {
106− Parts: string(parts),
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,
107 })
116 })
108 if err != nil {
117 if err != nil {
109 return err
118 return err
110 }
119 }
120+ message.UpdatedAt = time.Now().Unix()
111 s.Publish(pubsub.UpdatedEvent, message)
121 s.Publish(pubsub.UpdatedEvent, message)
112 return nil
122 return nil
113}
123}
114
124
115−func (s *service) Get(id string) (Message, error) {
125+func (s *service) Get(ctx context.Context, id string) (Message, error) {
116− dbMessage, err := s.q.GetMessage(s.ctx, id)
126+ dbMessage, err := s.q.GetMessage(ctx, id)
117 if err != nil {
127 if err != nil {
118 return Message{}, err
128 return Message{}, err
119 }
129 }
120 return s.fromDBItem(dbMessage)
130 return s.fromDBItem(dbMessage)
121}
131}
122
132
123−func (s *service) List(sessionID string) ([]Message, error) {
133+func (s *service) List(ctx context.Context, sessionID string) ([]Message, error) {
124− dbMessages, err := s.q.ListMessagesBySession(s.ctx, sessionID)
134+ dbMessages, err := s.q.ListMessagesBySession(ctx, sessionID)
125 if err != nil {
135 if err != nil {
126 return nil, err
136 return nil, err
127 }
137 }
@@ -145,6 +155,7 @@ func (s *service) fromDBItem(item db.Message) (Message, error) {
145 SessionID: item.SessionID,
155 SessionID: item.SessionID,
146 Role: MessageRole(item.Role),
156 Role: MessageRole(item.Role),
147 Parts: parts,
157 Parts: parts,
158+ Model: models.ModelID(item.Model.String),
148 CreatedAt: item.CreatedAt,
159 CreatedAt: item.CreatedAt,
149 UpdatedAt: item.UpdatedAt,
160 UpdatedAt: item.UpdatedAt,
150 }, nil
161 }, nil
internal/permission/permission.go+27−5
@@ -1,20 +1,28 @@
1package permission
1package permission
2
2
3import (
3import (
4+ "errors"
5+ "path/filepath"
6+ "slices"
4 "sync"
7 "sync"
5 "time"
8 "time"
6
9
7 "github.com/google/uuid"
10 "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"
9)
13)
10
14
15+var ErrorPermissionDenied = errors.New("permission denied")
16+
11type CreatePermissionRequest struct {
17type CreatePermissionRequest struct {
18+ SessionID string `json:"session_id"`
12 ToolName string `json:"tool_name"`
19 ToolName string `json:"tool_name"`
13 Description string `json:"description"`
20 Description string `json:"description"`
14 Action string `json:"action"`
21 Action string `json:"action"`
15 Params any `json:"params"`
22 Params any `json:"params"`
16 Path string `json:"path"`
23 Path string `json:"path"`
17}
24}
25+
18type PermissionRequest struct {
26type PermissionRequest struct {
19 ID string `json:"id"`
27 ID string `json:"id"`
20 SessionID string `json:"session_id"`
28 SessionID string `json:"session_id"`
@@ -31,13 +39,15 @@ type Service interface {
31 Grant(permission PermissionRequest)
39 Grant(permission PermissionRequest)
32 Deny(permission PermissionRequest)
40 Deny(permission PermissionRequest)
33 Request(opts CreatePermissionRequest) bool
41 Request(opts CreatePermissionRequest) bool
42+ AutoApproveSession(sessionID string)
34}
43}
35
44
36type permissionService struct {
45type permissionService struct {
37 *pubsub.Broker[PermissionRequest]
46 *pubsub.Broker[PermissionRequest]
38
47
39− sessionPermissions []PermissionRequest
48+ sessionPermissions []PermissionRequest
40− pendingRequests sync.Map
49+ pendingRequests sync.Map
50+ autoApproveSessions []string
41}
51}
42
52
43func (s *permissionService) GrantPersistant(permission PermissionRequest) {
53func (s *permissionService) GrantPersistant(permission PermissionRequest) {
@@ -63,9 +73,17 @@ func (s *permissionService) Deny(permission PermissionRequest) {
63}
73}
64
74
65func (s *permissionService) Request(opts CreatePermissionRequest) bool {
75func (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+ }
66 permission := PermissionRequest{
83 permission := PermissionRequest{
67 ID: uuid.New().String(),
84 ID: uuid.New().String(),
68− Path: opts.Path,
85+ Path: dir,
86+ SessionID: opts.SessionID,
69 ToolName: opts.ToolName,
87 ToolName: opts.ToolName,
70 Description: opts.Description,
88 Description: opts.Description,
71 Action: opts.Action,
89 Action: opts.Action,
@@ -73,7 +91,7 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
73 }
91 }
74
92
75 for _, p := range s.sessionPermissions {
93 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 {
77 return true
95 return true
78 }
96 }
79 }
97 }
@@ -94,6 +112,10 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
94 }
112 }
95}
113}
96
114
115+func (s *permissionService) AutoApproveSession(sessionID string) {
116+ s.autoApproveSessions = append(s.autoApproveSessions, sessionID)
117+}
118+
97func NewPermissionService() Service {
119func NewPermissionService() Service {
98 return &permissionService{
120 return &permissionService{
99 Broker: pubsub.NewBroker[PermissionRequest](),
121 Broker: pubsub.NewBroker[PermissionRequest](),
internal/pubsub/broker.go+47−32
@@ -5,54 +5,53 @@ import (
5 "sync"
5 "sync"
6)
6)
7
7
8−const bufferSize = 1024 * 1024
8+const bufferSize = 64
9
9
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
18type Broker[T any] struct {
10type Broker[T any] struct {
19− subs map[chan Event[T]]struct{} // subscriptions
11+ subs map[chan Event[T]]struct{}
20− mu sync.Mutex // sync access to map
12+ mu sync.RWMutex
21− done chan struct{} // close when broker is shutting down
13+ done chan struct{}
14+ subCount int
15+ maxEvents int
22}
16}
23
17
24−// NewBroker constructs a pub/sub broker.
25func NewBroker[T any]() *Broker[T] {
18func NewBroker[T any]() *Broker[T] {
19+ return NewBrokerWithOptions[T](bufferSize, 1000)
20+}
21+
22+func NewBrokerWithOptions[T any](channelBufferSize, maxEvents int) *Broker[T] {
26 b := &Broker[T]{
23 b := &Broker[T]{
27− subs: make(map[chan Event[T]]struct{}),
24+ subs: make(map[chan Event[T]]struct{}),
28− done: make(chan struct{}),
25+ done: make(chan struct{}),
26+ subCount: 0,
27+ maxEvents: maxEvents,
29 }
28 }
30 return b
29 return b
31}
30}
32
31
33−// Shutdown the broker, terminating any subscriptions.
34func (b *Broker[T]) Shutdown() {
32func (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+ }
36
39
37 b.mu.Lock()
40 b.mu.Lock()
38 defer b.mu.Unlock()
41 defer b.mu.Unlock()
39
42
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.
43 for ch := range b.subs {
43 for ch := range b.subs {
44 delete(b.subs, ch)
44 delete(b.subs, ch)
45 close(ch)
45 close(ch)
46 }
46 }
47+
48+ b.subCount = 0
47}
49}
48
50
49−// Subscribe subscribes the caller to a stream of events. The returned channel
50−// is closed when the broker is shutdown.
51func (b *Broker[T]) Subscribe(ctx context.Context) <-chan Event[T] {
51func (b *Broker[T]) Subscribe(ctx context.Context) <-chan Event[T] {
52 b.mu.Lock()
52 b.mu.Lock()
53 defer b.mu.Unlock()
53 defer b.mu.Unlock()
54
54
55− // Check if broker has shutdown and if so return closed channel
56 select {
55 select {
57 case <-b.done:
56 case <-b.done:
58 ch := make(chan Event[T])
57 ch := make(chan Event[T])
@@ -61,18 +60,16 @@ func (b *Broker[T]) Subscribe(ctx context.Context) <-chan Event[T] {
61 default:
60 default:
62 }
61 }
63
62
64− // Subscribe
65 sub := make(chan Event[T], bufferSize)
63 sub := make(chan Event[T], bufferSize)
66 b.subs[sub] = struct{}{}
64 b.subs[sub] = struct{}{}
65+ b.subCount++
67
66
68− // Unsubscribe when context is done.
69 go func() {
67 go func() {
70 <-ctx.Done()
68 <-ctx.Done()
71
69
72 b.mu.Lock()
70 b.mu.Lock()
73 defer b.mu.Unlock()
71 defer b.mu.Unlock()
74
72
75− // Check if broker has shutdown and if so do nothing
76 select {
73 select {
77 case <-b.done:
74 case <-b.done:
78 return
75 return
@@ -81,21 +78,39 @@ func (b *Broker[T]) Subscribe(ctx context.Context) <-chan Event[T] {
81
78
82 delete(b.subs, sub)
79 delete(b.subs, sub)
83 close(sub)
80 close(sub)
81+ b.subCount--
84 }()
82 }()
85
83
86 return sub
84 return sub
87}
85}
88
86
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+
90func (b *Broker[T]) Publish(t EventType, payload T) {
93func (b *Broker[T]) Publish(t EventType, payload T) {
91− b.mu.Lock()
94+ b.mu.RLock()
92− defer b.mu.Unlock()
95+ select {
96+ case <-b.done:
97+ b.mu.RUnlock()
98+ return
99+ default:
100+ }
93
101
102+ subscribers := make([]chan Event[T], 0, len(b.subs))
94 for sub := range b.subs {
103 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 {
95 select {
111 select {
96− case sub <- Event[T]{Type: t, Payload: payload}:
112+ case sub <- event:
97− case <-b.done:
113+ default:
98− return
99 }
114 }
100 }
115 }
101}
116}
internal/session/session.go+38−25
@@ -5,8 +5,8 @@ import (
5 "database/sql"
5 "database/sql"
6
6
7 "github.com/google/uuid"
7 "github.com/google/uuid"
8− "github.com/kujtimiihoxha/termai/internal/db"
8+ "github.com/kujtimiihoxha/opencode/internal/db"
9− "github.com/kujtimiihoxha/termai/internal/pubsub"
9+ "github.com/kujtimiihoxha/opencode/internal/pubsub"
10)
10)
11
11
12type Session struct {
12type Session struct {
@@ -23,22 +23,22 @@ type Session struct {
23
23
24type Service interface {
24type Service interface {
25 pubsub.Suscriber[Session]
25 pubsub.Suscriber[Session]
26− Create(title string) (Session, error)
26+ Create(ctx context.Context, title string) (Session, error)
27− CreateTaskSession(toolCallID, parentSessionID, title string) (Session, error)
27+ CreateTitleSession(ctx context.Context, parentSessionID string) (Session, error)
28− Get(id string) (Session, error)
28+ CreateTaskSession(ctx context.Context, toolCallID, parentSessionID, title string) (Session, error)
29− List() ([]Session, error)
29+ Get(ctx context.Context, id string) (Session, error)
30− Save(session Session) (Session, error)
30+ List(ctx context.Context) ([]Session, error)
31− Delete(id string) error
31+ Save(ctx context.Context, session Session) (Session, error)
32+ Delete(ctx context.Context, id string) error
32}
33}
33
34
34type service struct {
35type service struct {
35 *pubsub.Broker[Session]
36 *pubsub.Broker[Session]
36− q db.Querier
37+ q db.Querier
37− ctx context.Context
38}
38}
39
39
40−func (s *service) Create(title string) (Session, error) {
40+func (s *service) Create(ctx context.Context, title string) (Session, error) {
41− dbSession, err := s.q.CreateSession(s.ctx, db.CreateSessionParams{
41+ dbSession, err := s.q.CreateSession(ctx, db.CreateSessionParams{
42 ID: uuid.New().String(),
42 ID: uuid.New().String(),
43 Title: title,
43 Title: title,
44 })
44 })
@@ -50,8 +50,8 @@ func (s *service) Create(title string) (Session, error) {
50 return session, nil
50 return session, nil
51}
51}
52
52
53−func (s *service) CreateTaskSession(toolCallID, parentSessionID, title string) (Session, error) {
53+func (s *service) CreateTaskSession(ctx context.Context, toolCallID, parentSessionID, title string) (Session, error) {
54− dbSession, err := s.q.CreateSession(s.ctx, db.CreateSessionParams{
54+ dbSession, err := s.q.CreateSession(ctx, db.CreateSessionParams{
55 ID: toolCallID,
55 ID: toolCallID,
56 ParentSessionID: sql.NullString{String: parentSessionID, Valid: true},
56 ParentSessionID: sql.NullString{String: parentSessionID, Valid: true},
57 Title: title,
57 Title: title,
@@ -64,12 +64,26 @@ func (s *service) CreateTaskSession(toolCallID, parentSessionID, title string) (
64 return session, nil
64 return session, nil
65}
65}
66
66
67−func (s *service) Delete(id string) error {
67+func (s *service) CreateTitleSession(ctx context.Context, parentSessionID string) (Session, error) {
68− session, err := s.Get(id)
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)
69 if err != nil {
83 if err != nil {
70 return err
84 return err
71 }
85 }
72− err = s.q.DeleteSession(s.ctx, session.ID)
86+ err = s.q.DeleteSession(ctx, session.ID)
73 if err != nil {
87 if err != nil {
74 return err
88 return err
75 }
89 }
@@ -77,16 +91,16 @@ func (s *service) Delete(id string) error {
77 return nil
91 return nil
78}
92}
79
93
80−func (s *service) Get(id string) (Session, error) {
94+func (s *service) Get(ctx context.Context, id string) (Session, error) {
81− dbSession, err := s.q.GetSessionByID(s.ctx, id)
95+ dbSession, err := s.q.GetSessionByID(ctx, id)
82 if err != nil {
96 if err != nil {
83 return Session{}, err
97 return Session{}, err
84 }
98 }
85 return s.fromDBItem(dbSession), nil
99 return s.fromDBItem(dbSession), nil
86}
100}
87
101
88−func (s *service) Save(session Session) (Session, error) {
102+func (s *service) Save(ctx context.Context, session Session) (Session, error) {
89− dbSession, err := s.q.UpdateSession(s.ctx, db.UpdateSessionParams{
103+ dbSession, err := s.q.UpdateSession(ctx, db.UpdateSessionParams{
90 ID: session.ID,
104 ID: session.ID,
91 Title: session.Title,
105 Title: session.Title,
92 PromptTokens: session.PromptTokens,
106 PromptTokens: session.PromptTokens,
@@ -101,8 +115,8 @@ func (s *service) Save(session Session) (Session, error) {
101 return session, nil
115 return session, nil
102}
116}
103
117
104−func (s *service) List() ([]Session, error) {
118+func (s *service) List(ctx context.Context) ([]Session, error) {
105− dbSessions, err := s.q.ListSessions(s.ctx)
119+ dbSessions, err := s.q.ListSessions(ctx)
106 if err != nil {
120 if err != nil {
107 return nil, err
121 return nil, err
108 }
122 }
@@ -127,11 +141,10 @@ func (s service) fromDBItem(item db.Session) Session {
127 }
141 }
128}
142}
129
143
130−func NewService(ctx context.Context, q db.Querier) Service {
144+func NewService(q db.Querier) Service {
131 broker := pubsub.NewBroker[Session]()
145 broker := pubsub.NewBroker[Session]()
132 return &service{
146 return &service{
133 broker,
147 broker,
134 q,
148 q,
135− ctx,
136 }
149 }
137}
150}
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 @@
1package core
1package core
2
2
3import (
3import (
4+ "fmt"
5+ "strings"
4 "time"
6 "time"
5
7
6 tea "github.com/charmbracelet/bubbletea"
8 tea "github.com/charmbracelet/bubbletea"
7 "github.com/charmbracelet/lipgloss"
9 "github.com/charmbracelet/lipgloss"
8− "github.com/kujtimiihoxha/termai/internal/config"
10+ "github.com/kujtimiihoxha/opencode/internal/config"
9− "github.com/kujtimiihoxha/termai/internal/llm/models"
11+ "github.com/kujtimiihoxha/opencode/internal/llm/models"
10− "github.com/kujtimiihoxha/termai/internal/tui/styles"
12+ "github.com/kujtimiihoxha/opencode/internal/lsp"
11− "github.com/kujtimiihoxha/termai/internal/tui/util"
13+ "github.com/kujtimiihoxha/opencode/internal/lsp/protocol"
12− "github.com/kujtimiihoxha/termai/internal/version"
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"
13)
19)
14
20
21+type StatusCmp interface {
22+ tea.Model
23+ SetHelpMsg(string)
24+}
25+
15type statusCmp struct {
26type statusCmp struct {
16 info util.InfoMsg
27 info util.InfoMsg
17 width int
28 width int
18 messageTTL time.Duration
29 messageTTL time.Duration
30+ lspClients map[string]*lsp.Client
31+ session session.Session
19}
32}
20
33
21// clearMessageCmd is a command that clears status messages after a timeout
34// 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) {
34 case tea.WindowSizeMsg:
47 case tea.WindowSizeMsg:
35 m.width = msg.Width
48 m.width = msg.Width
36 return m, nil
49 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+ }
37 case util.InfoMsg:
60 case util.InfoMsg:
38 m.info = msg
61 m.info = msg
39 ttl := msg.TTL
62 ttl := msg.TTL
@@ -47,20 +70,53 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
47 return m, nil
70 return m, nil
48}
71}
49
72
50−var (
73+var helpWidget = styles.Padded.Background(styles.ForgroundMid).Foreground(styles.BackgroundDarker).Bold(true).Render("ctrl+? help")
51− versionWidget = styles.Padded.Background(styles.DarkGrey).Foreground(styles.Text).Render(version.Version)
74+
52− helpWidget = styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render("? help")
75+func formatTokensAndCost(tokens int64, cost float64) string {
53−)
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+}
54
100
55func (m statusCmp) View() string {
101func (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())
57 if m.info.Msg != "" {
113 if m.info.Msg != "" {
58 infoStyle := styles.Padded.
114 infoStyle := styles.Padded.
59 Foreground(styles.Base).
115 Foreground(styles.Base).
60− Width(m.availableFooterMsgWidth())
116+ Width(m.availableFooterMsgWidth(diagnostics))
61 switch m.info.Type {
117 switch m.info.Type {
62 case util.InfoTypeInfo:
118 case util.InfoTypeInfo:
63− infoStyle = infoStyle.Background(styles.Blue)
119+ infoStyle = infoStyle.Background(styles.BorderColor)
64 case util.InfoTypeWarn:
120 case util.InfoTypeWarn:
65 infoStyle = infoStyle.Background(styles.Peach)
121 infoStyle = infoStyle.Background(styles.Peach)
66 case util.InfoTypeError:
122 case util.InfoTypeError:
@@ -68,7 +124,7 @@ func (m statusCmp) View() string {
68 }
124 }
69 // Truncate message if it's longer than available width
125 // Truncate message if it's longer than available width
70 msg := m.info.Msg
126 msg := m.info.Msg
71− availWidth := m.availableFooterMsgWidth() - 10
127+ availWidth := m.availableFooterMsgWidth(diagnostics) - 10
72 if len(msg) > availWidth && availWidth > 0 {
128 if len(msg) > availWidth && availWidth > 0 {
73 msg = msg[:availWidth] + "..."
129 msg = msg[:availWidth] + "..."
74 }
130 }
@@ -76,27 +132,121 @@ func (m statusCmp) View() string {
76 } else {
132 } else {
77 status += styles.Padded.
133 status += styles.Padded.
78 Foreground(styles.Base).
134 Foreground(styles.Base).
79− Background(styles.LightGrey).
135+ Background(styles.BackgroundDim).
80− Width(m.availableFooterMsgWidth()).
136+ Width(m.availableFooterMsgWidth(diagnostics)).
81 Render("")
137 Render("")
82 }
138 }
139+
140+ status += diagnostics
83 status += m.model()
141 status += m.model()
84− status += versionWidget
85 return status
142 return status
86}
143}
87
144
88−func (m statusCmp) availableFooterMsgWidth() int {
145+func (m *statusCmp) projectDiagnostics() string {
89− // -2 to accommodate padding
146+ // Check if any LSP server is still initializing
90− return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(versionWidget)-lipgloss.Width(m.model()))
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)
91}
230}
92
231
93func (m statusCmp) model() string {
232func (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]
95 return styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render(model.Name)
240 return styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render(model.Name)
96}
241}
97
242
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 {
99 return &statusCmp{
248 return &statusCmp{
100 messageTTL: 10 * time.Second,
249 messageTTL: 10 * time.Second,
250+ lspClients: lspClients,
101 }
251 }
102}
252}
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 (
9 tea "github.com/charmbracelet/bubbletea"
9 tea "github.com/charmbracelet/bubbletea"
10 "github.com/charmbracelet/glamour"
10 "github.com/charmbracelet/glamour"
11 "github.com/charmbracelet/lipgloss"
11 "github.com/charmbracelet/lipgloss"
12− "github.com/kujtimiihoxha/termai/internal/llm/tools"
12+ "github.com/kujtimiihoxha/opencode/internal/diff"
13− "github.com/kujtimiihoxha/termai/internal/permission"
13+ "github.com/kujtimiihoxha/opencode/internal/llm/tools"
14− "github.com/kujtimiihoxha/termai/internal/tui/components/core"
14+ "github.com/kujtimiihoxha/opencode/internal/permission"
15− "github.com/kujtimiihoxha/termai/internal/tui/layout"
15+ "github.com/kujtimiihoxha/opencode/internal/tui/layout"
16− "github.com/kujtimiihoxha/termai/internal/tui/styles"
16+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
17− "github.com/kujtimiihoxha/termai/internal/tui/util"
17+ "github.com/kujtimiihoxha/opencode/internal/tui/util"
18−
19− "github.com/charmbracelet/huh"
20)
18)
21
19
22type PermissionAction string
20type PermissionAction string
@@ -34,69 +32,69 @@ type PermissionResponseMsg struct {
34 Action PermissionAction
32 Action PermissionAction
35}
33}
36
34
37−// PermissionDialog interface for permission dialog component
35+// PermissionDialogCmp interface for permission dialog component
38−type PermissionDialog interface {
36+type PermissionDialogCmp interface {
39 tea.Model
37 tea.Model
40− layout.Sizeable
41 layout.Bindings
38 layout.Bindings
39+ SetPermissions(permission permission.PermissionRequest) tea.Cmd
42}
40}
43
41
44−type keyMap struct {
42+type permissionsMapping struct {
45− ChangeFocus key.Binding
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
46}
50}
47
51
48−var keyMapValue = keyMap{
52+var permissionsKeys = permissionsMapping{
49− ChangeFocus: key.NewBinding(
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(
50 key.WithKeys("tab"),
78 key.WithKeys("tab"),
51− key.WithHelp("tab", "change focus"),
79+ key.WithHelp("tab", "switch options"),
52 ),
80 ),
53}
81}
54
82
55// permissionDialogCmp is the implementation of PermissionDialog
83// permissionDialogCmp is the implementation of PermissionDialog
56type permissionDialogCmp struct {
84type permissionDialogCmp struct {
57− form *huh.Form
58 width int
85 width int
59 height int
86 height int
60 permission permission.PermissionRequest
87 permission permission.PermissionRequest
61 windowSize tea.WindowSizeMsg
88 windowSize tea.WindowSizeMsg
62− r *glamour.TermRenderer
63 contentViewPort viewport.Model
89 contentViewPort viewport.Model
64− isViewportFocus bool
90+ selectedOption int // 0: Allow, 1: Allow for session, 2: Deny
65− selectOption *huh.Select[string]
66−}
67
91
68−// formatDiff formats a diff string with colors for additions and deletions
92+ diffCache map[string]string
69−func formatDiff(diffText string) string {
93+ markdownCache map[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")
96}
94}
97
95
98func (p *permissionDialogCmp) Init() tea.Cmd {
96func (p *permissionDialogCmp) Init() tea.Cmd {
99− return nil
97+ return p.contentViewPort.Init()
100}
98}
101
99
102func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
100func (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) {
105 switch msg := msg.(type) {
103 switch msg := msg.(type) {
106 case tea.WindowSizeMsg:
104 case tea.WindowSizeMsg:
107 p.windowSize = msg
105 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)
108 case tea.KeyMsg:
110 case tea.KeyMsg:
109− if key.Matches(msg, keyMapValue.ChangeFocus) {
111+ switch {
110− p.isViewportFocus = !p.isViewportFocus
112+ case key.Matches(msg, permissionsKeys.Right) || key.Matches(msg, permissionsKeys.Tab):
111− if p.isViewportFocus {
113+ p.selectedOption = (p.selectedOption + 1) % 3
112− p.selectOption.Blur()
114+ return p, nil
113− // Add a visual indicator for focus change
115+ case key.Matches(msg, permissionsKeys.Left):
114− cmds = append(cmds, tea.Batch(
116+ p.selectedOption = (p.selectedOption + 2) % 3
115− util.ReportInfo("Viewing content - use arrow keys to scroll"),
117+ case key.Matches(msg, permissionsKeys.EnterSpace):
116− ))
118+ return p, p.selectCurrentOption()
117− } else {
119+ case key.Matches(msg, permissionsKeys.Allow):
118− p.selectOption.Focus()
120+ return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionAllow, Permission: p.permission})
119− // Add a visual indicator for focus change
121+ case key.Matches(msg, permissionsKeys.AllowSession):
120− cmds = append(cmds, tea.Batch(
122+ return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionAllowForSession, Permission: p.permission})
121− util.CmdHandler(util.ReportInfo("Select an action")),
123+ case key.Matches(msg, permissionsKeys.Deny):
122− ))
124+ return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionDeny, Permission: p.permission})
123− }
125+ default:
124− return p, tea.Batch(cmds...)
126+ // Pass other keys to viewport
127+ viewPort, cmd := p.contentViewPort.Update(msg)
128+ p.contentViewPort = viewPort
129+ cmds = append(cmds, cmd)
125 }
130 }
126 }
131 }
127
132
128− if p.isViewportFocus {
133+ return p, tea.Batch(cmds...)
129− viewPort, cmd := p.contentViewPort.Update(msg)
134+}
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− }
138
135
139− if p.form.State == huh.StateCompleted {
136+func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
140− // Get the selected action
137+ var action PermissionAction
141− action := p.form.GetString("action")
142
138
143− // Close the dialog and return the response
139+ switch p.selectedOption {
144− return p, tea.Batch(
140+ case 0:
145− util.CmdHandler(core.DialogCloseMsg{}),
141+ action = PermissionAllow
146− util.CmdHandler(PermissionResponseMsg{Action: PermissionAction(action), Permission: p.permission}),
142+ case 1:
147− )
143+ action = PermissionAllowForSession
148− }
144+ case 2:
145+ action = PermissionDeny
149 }
146 }
150− return p, tea.Batch(cmds...)
147+
148+ return util.CmdHandler(PermissionResponseMsg{Action: action, Permission: p.permission})
151}
149}
152
150
153−func (p *permissionDialogCmp) render() string {
151+func (p *permissionDialogCmp) renderButtons() string {
154− keyStyle := lipgloss.NewStyle().Bold(true).Foreground(styles.Rosewater)
152+ allowStyle := styles.BaseStyle
155− valueStyle := lipgloss.NewStyle().Foreground(styles.Peach)
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+ }
156
172
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+ )
158
186
159− headerParts := []string{
187+ remainingWidth := p.width - lipgloss.Width(content)
160− lipgloss.JoinHorizontal(lipgloss.Left, keyStyle.Render("Tool:"), " ", valueStyle.Render(p.permission.ToolName)),
188+ if remainingWidth > 0 {
161− " ",
189+ content = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + content
162− lipgloss.JoinHorizontal(lipgloss.Left, keyStyle.Render("Path:"), " ", valueStyle.Render(p.permission.Path)),
163− " ",
164 }
190 }
191+ return content
192+}
165
193
166− // Create the header content first so it can be used in all cases
194+func (p *permissionDialogCmp) renderHeader() string {
167− headerContent := lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
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))
168
200
169− r, _ := glamour.NewTermRenderer(
201+ pathKey := styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("Path")
170− glamour.WithStyles(styles.CatppuccinMarkdownStyle()),
202+ pathValue := styles.BaseStyle.
171− glamour.WithWordWrap(p.width-10),
203+ Foreground(styles.Forground).
172− glamour.WithEmoji(),
204+ Width(p.width - lipgloss.Width(pathKey)).
173− )
205+ Render(fmt.Sprintf(": %s", p.permission.Path))
174
206
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
176 switch p.permission.ToolName {
223 switch p.permission.ToolName {
177 case tools.BashToolName:
224 case tools.BashToolName:
178− pr := p.permission.Params.(tools.BashPermissionsParams)
225+ headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("Command"))
179− headerParts = append(headerParts, keyStyle.Render("Command:"))
226+ case tools.EditToolName:
180− content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
227+ headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("Diff"))
181−
228+ case tools.WriteToolName:
182− renderedContent, _ := r.Render(content)
229+ headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("Diff"))
183− p.contentViewPort.Width = p.width - 2 - 2
230+ case tools.FetchToolName:
231+ headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("URL"))
232+ }
184
233
185− // Calculate content height dynamically based on content
234+ return lipgloss.NewStyle().Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
186− contentLines := len(strings.Split(renderedContent, "\n"))
235+}
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
190
236
191− // Add some padding to the content lines
237+func (p *permissionDialogCmp) renderBashContent() string {
192− contentHeight := contentLines + 2
238+ if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
193− contentHeight = max(contentHeight, minContentHeight)
239+ content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
194− contentHeight = min(contentHeight, maxContentHeight)
195− p.contentViewPort.Height = contentHeight
196
240
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+}
198
259
199− // Style the viewport
260+func (p *permissionDialogCmp) renderEditContent() string {
200− var contentBorder lipgloss.Border
261+ if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
201− var borderColor lipgloss.TerminalColor
262+ diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
263+ return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
264+ })
202
265
203− if p.isViewportFocus {
266+ p.contentViewPort.SetContent(diff)
204− contentBorder = lipgloss.DoubleBorder()
267+ return p.styleViewport()
205− borderColor = styles.Blue
268+ }
206− } else {
269+ return ""
207− contentBorder = lipgloss.RoundedBorder()
270+}
208− borderColor = styles.Flamingo
209− }
210
271
211− contentStyle := lipgloss.NewStyle().
272+func (p *permissionDialogCmp) renderPatchContent() string {
212− MarginTop(1).
273+ if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
213− Padding(0, 1).
274+ diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
214− Border(contentBorder).
275+ return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
215− BorderForeground(borderColor)
276+ })
216
277
217− if p.isViewportFocus {
278+ p.contentViewPort.SetContent(diff)
218− contentStyle = contentStyle.BorderBackground(styles.Surface0)
279+ return p.styleViewport()
219− }
280+ }
281+ return ""
282+}
220
283
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+ })
222
290
223− return lipgloss.JoinVertical(
291+ p.contentViewPort.SetContent(diff)
224− lipgloss.Top,
292+ return p.styleViewport()
225− headerContent,
293+ }
226− contentFinal,
294+ return ""
227− form,
295+}
228− )
229
296
230− case tools.EditToolName:
297+func (p *permissionDialogCmp) renderFetchContent() string {
231− pr := p.permission.Params.(tools.EditPermissionsParams)
298+ if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
232− headerParts = append(headerParts, keyStyle.Render("Update"))
299+ content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
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− }
258
300
259− contentStyle := lipgloss.NewStyle().
301+ // Use the cache for markdown rendering
260− MarginTop(1).
302+ renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
261− Padding(0, 1).
303+ r, _ := glamour.NewTermRenderer(
262− Border(contentBorder).
304+ glamour.WithStyles(styles.MarkdownTheme(true)),
263− BorderForeground(borderColor)
305+ glamour.WithWordWrap(p.width-10),
306+ )
307+ s, err := r.Render(content)
308+ return styles.ForceReplaceBackgroundWithLipgloss(s, styles.Background), err
309+ })
264
310
265− if p.isViewportFocus {
311+ p.contentViewPort.SetContent(renderedContent)
266− contentStyle = contentStyle.BorderBackground(styles.Surface0)
312+ return p.styleViewport()
267− }
313+ }
314+ return ""
315+}
268
316
269− contentFinal := contentStyle.Render(p.contentViewPort.View())
317+func (p *permissionDialogCmp) renderDefaultContent() string {
318+ content := p.permission.Description
270
319
271− return lipgloss.JoinVertical(
320+ // Use the cache for markdown rendering
272− lipgloss.Top,
321+ renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
273− headerContent,
322+ r, _ := glamour.NewTermRenderer(
274− contentFinal,
323+ glamour.WithStyles(styles.CatppuccinMarkdownStyle()),
275− form,
324+ glamour.WithWordWrap(p.width-10),
276 )
325 )
326+ s, err := r.Render(content)
327+ return styles.ForceReplaceBackgroundWithLipgloss(s, styles.Background), err
328+ })
277
329
278− case tools.WriteToolName:
330+ p.contentViewPort.SetContent(renderedContent)
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− }
306
331
307− contentStyle := lipgloss.NewStyle().
332+ if renderedContent == "" {
308− MarginTop(1).
333+ return ""
309− Padding(0, 1).
334+ }
310− Border(contentBorder).
311− BorderForeground(borderColor)
312
335
313− if p.isViewportFocus {
336+ return p.styleViewport()
314− contentStyle = contentStyle.BorderBackground(styles.Surface0)
337+}
315− }
316
338
317− contentFinal := contentStyle.Render(p.contentViewPort.View())
339+func (p *permissionDialogCmp) styleViewport() string {
340+ contentStyle := lipgloss.NewStyle().
341+ Background(styles.Background)
318
342
319− return lipgloss.JoinVertical(
343+ return contentStyle.Render(p.contentViewPort.View())
320− lipgloss.Top,
344+}
321− headerContent,
322− contentFinal,
323− form,
324− )
325
345
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()
326 case tools.FetchToolName:
372 case tools.FetchToolName:
327− pr := p.permission.Params.(tools.FetchPermissionsParams)
373+ contentFinal = p.renderFetchContent()
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−
355 default:
374 default:
356− content := p.permission.Description
375+ contentFinal = p.renderDefaultContent()
357−
376+ }
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)
369
377
370− contentFinal := contentStyle.Render(p.contentViewPort.View())
378+ // Add help text
371− if renderedContent == "" {
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")
372− contentFinal = ""
380+
373− }
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+ )
374
391
375− return lipgloss.JoinVertical(
392+ return styles.BaseStyle.
376− lipgloss.Top,
393+ Padding(1, 0, 0, 1).
377− headerContent,
394+ Border(lipgloss.RoundedBorder()).
378− contentFinal,
395+ BorderBackground(styles.Background).
379− form,
396+ BorderForeground(styles.ForgroundDim).
397+ Width(p.width).
398+ Height(p.height).
399+ Render(
400+ content,
380 )
401 )
381− }
382}
402}
383
403
384func (p *permissionDialogCmp) View() string {
404func (p *permissionDialogCmp) View() string {
385 return p.render()
405 return p.render()
386}
406}
387
407
388−func (p *permissionDialogCmp) GetSize() (int, int) {
408+func (p *permissionDialogCmp) BindingKeys() []key.Binding {
389− return p.width, p.height
409+ return layout.KeyMapToSlice(permissionsKeys)
390}
410}
391
411
392−func (p *permissionDialogCmp) SetSize(width int, height int) {
412+func (p *permissionDialogCmp) SetSize() tea.Cmd {
393− p.width = width
413+ if p.permission.ID == "" {
394− p.height = height
414+ return nil
395− p.form = p.form.WithWidth(width)
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
396}
434}
397
435
398−func (p *permissionDialogCmp) BindingKeys() []key.Binding {
436+func (p *permissionDialogCmp) SetPermissions(permission permission.PermissionRequest) tea.Cmd {
399− return p.form.KeyBinds()
437+ p.permission = permission
438+ return p.SetSize()
400}
439}
401
440
402−func newPermissionDialogCmp(permission permission.PermissionRequest) PermissionDialog {
441+// Helper to get or set cached diff content
403− // Create a note field for displaying the 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+ }
404
446
405− // Create select field for the permission options
447+ content, err := generator()
406− selectOption := huh.NewSelect[string]().
448+ if err != nil {
407− Key("action").
449+ return fmt.Sprintf("Error formatting diff: %v", err)
408− Options(
450+ }
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")
414
451
415− // Apply theme
452+ c.diffCache[key] = content
416− theme := styles.HuhTheme()
417
453
418− // Setup form width and height
454+ return content
419− form := huh.NewForm(huh.NewGroup(selectOption)).
455+}
420− WithShowHelp(false).
421− WithTheme(theme).
422− WithShowErrors(false)
423
456
424− // Focus the form for immediate interaction
457+// Helper to get or set cached markdown content
425− selectOption.Focus()
458+func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
459+ if cached, ok := c.markdownCache[key]; ok {
460+ return cached
461+ }
426
462
427− return &permissionDialogCmp{
463+ content, err := generator()
428− permission: permission,
464+ if err != nil {
429− form: form,
465+ return fmt.Sprintf("Error rendering markdown: %v", err)
430− selectOption: selectOption,
431 }
466 }
432−}
433
467
434−// NewPermissionDialogCmd creates a new permission dialog command
468+ c.markdownCache[key] = content
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− )
448
469
449− // Focus the dialog
470+ return content
450− dialogPane.Focus()
471+}
451− widthRatio := 0.7
452− heightRatio := 0.6
453− minWidth := 100
454− minHeight := 30
455
472
456− // Make the dialog size more appropriate for different tools
473+func NewPermissionDialogCmp() PermissionDialogCmp {
457− switch permission.ToolName {
474+ // Create viewport for content
458− case tools.BashToolName:
475+ contentViewport := viewport.New(0, 0)
459− // For bash commands, use a more compact dialog
476+
460− widthRatio = 0.7
477+ return &permissionDialogCmp{
461− heightRatio = 0.4 // Reduced from 0.5
478+ contentViewPort: contentViewport,
462− minWidth = 100
479+ selectedOption: 0, // Default to "Allow"
463− minHeight = 20 // Reduced from 30
480+ diffCache: make(map[string]string),
481+ markdownCache: make(map[string]string),
464 }
482 }
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− })
473}
483}
internal/tui/components/dialog/quit.go+94−68
@@ -1,28 +1,58 @@
1package dialog
1package dialog
2
2
3import (
3import (
4+ "strings"
5+
4 "github.com/charmbracelet/bubbles/key"
6 "github.com/charmbracelet/bubbles/key"
5 tea "github.com/charmbracelet/bubbletea"
7 tea "github.com/charmbracelet/bubbletea"
6− "github.com/kujtimiihoxha/termai/internal/tui/components/core"
8+ "github.com/charmbracelet/lipgloss"
7− "github.com/kujtimiihoxha/termai/internal/tui/layout"
9+ "github.com/kujtimiihoxha/opencode/internal/tui/layout"
8− "github.com/kujtimiihoxha/termai/internal/tui/styles"
10+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
9− "github.com/kujtimiihoxha/termai/internal/tui/util"
11+ "github.com/kujtimiihoxha/opencode/internal/tui/util"
10−
11− "github.com/charmbracelet/huh"
12)
12)
13
13
14const question = "Are you sure you want to quit?"
14const question = "Are you sure you want to quit?"
15
15
16+type CloseQuitMsg struct{}
17+
16type QuitDialog interface {
18type QuitDialog interface {
17 tea.Model
19 tea.Model
18− layout.Sizeable
19 layout.Bindings
20 layout.Bindings
20}
21}
21
22
22type quitDialogCmp struct {
23type quitDialogCmp struct {
23− form *huh.Form
24+ selectedNo bool
24− width int
25+}
25− height int
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+ ),
26}
56}
27
57
28func (q *quitDialogCmp) Init() tea.Cmd {
58func (q *quitDialogCmp) Init() tea.Cmd {
@@ -30,77 +60,73 @@ func (q *quitDialogCmp) Init() tea.Cmd {
30}
60}
31
61
32func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
62func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
33− var cmds []tea.Cmd
63+ switch msg := msg.(type) {
34− form, cmd := q.form.Update(msg)
64+ case tea.KeyMsg:
35− if f, ok := form.(*huh.Form); ok {
65+ switch {
36− q.form = f
66+ case key.Matches(msg, helpKeys.LeftRight) || key.Matches(msg, helpKeys.Tab):
37− cmds = append(cmds, cmd)
67+ q.selectedNo = !q.selectedNo
38− }
68+ return q, nil
39−
69+ case key.Matches(msg, helpKeys.EnterSpace):
40− if q.form.State == huh.StateCompleted {
70+ if !q.selectedNo {
41− v := q.form.GetBool("quit")
71+ return q, tea.Quit
42− if v {
72+ }
73+ return q, util.CmdHandler(CloseQuitMsg{})
74+ case key.Matches(msg, helpKeys.Yes):
43 return q, tea.Quit
75 return q, tea.Quit
76+ case key.Matches(msg, helpKeys.No):
77+ return q, util.CmdHandler(CloseQuitMsg{})
44 }
78 }
45− cmds = append(cmds, util.CmdHandler(core.DialogCloseMsg{}))
46 }
79 }
47−
80+ return q, nil
48− return q, tea.Batch(cmds...)
49}
81}
50
82
51func (q *quitDialogCmp) View() string {
83func (q *quitDialogCmp) View() string {
52− return q.form.View()
84+ yesStyle := styles.BaseStyle
53−}
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+ }
54
95
55−func (q *quitDialogCmp) GetSize() (int, int) {
96+ yesButton := yesStyle.Padding(0, 1).Render("Yes")
56− return q.width, q.height
97+ noButton := noStyle.Padding(0, 1).Render("No")
57−}
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+ }
58
106
59−func (q *quitDialogCmp) SetSize(width int, height int) {
107+ content := styles.BaseStyle.Render(
60− q.width = width
108+ lipgloss.JoinVertical(
61− q.height = height
109+ lipgloss.Center,
62− q.form = q.form.WithWidth(width).WithHeight(height)
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)
63}
122}
64
123
65func (q *quitDialogCmp) BindingKeys() []key.Binding {
124func (q *quitDialogCmp) BindingKeys() []key.Binding {
66− return q.form.KeyBinds()
125+ return layout.KeyMapToSlice(helpKeys)
67}
126}
68
127
69−func newQuitDialogCmp() QuitDialog {
128+func NewQuitCmp() 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()
86 return &quitDialogCmp{
129 return &quitDialogCmp{
87− form: form,
130+ selectedNo: true,
88 }
131 }
89}
132}
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 (
9 "github.com/charmbracelet/bubbles/viewport"
9 "github.com/charmbracelet/bubbles/viewport"
10 tea "github.com/charmbracelet/bubbletea"
10 tea "github.com/charmbracelet/bubbletea"
11 "github.com/charmbracelet/lipgloss"
11 "github.com/charmbracelet/lipgloss"
12− "github.com/kujtimiihoxha/termai/internal/logging"
12+ "github.com/kujtimiihoxha/opencode/internal/logging"
13− "github.com/kujtimiihoxha/termai/internal/tui/layout"
13+ "github.com/kujtimiihoxha/opencode/internal/tui/layout"
14− "github.com/kujtimiihoxha/termai/internal/tui/styles"
14+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
15)
15)
16
16
17type DetailComponent interface {
17type DetailComponent interface {
18 tea.Model
18 tea.Model
19− layout.Focusable
20 layout.Sizeable
19 layout.Sizeable
21 layout.Bindings
20 layout.Bindings
22− layout.Bordered
23}
21}
24
22
25type detailCmp struct {
23type detailCmp struct {
26 width, height int
24 width, height int
27− focused bool
28 currentLog logging.LogMessage
25 currentLog logging.LogMessage
29 viewport viewport.Model
26 viewport viewport.Model
30}
27}
@@ -39,11 +36,6 @@ func (i *detailCmp) Init() tea.Cmd {
39}
36}
40
37
41func (i *detailCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
38func (i *detailCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
42− var (
43− cmd tea.Cmd
44− cmds []tea.Cmd
45− )
46−
47 switch msg := msg.(type) {
39 switch msg := msg.(type) {
48 case selectedLogMsg:
40 case selectedLogMsg:
49 if msg.ID != i.currentLog.ID {
41 if msg.ID != i.currentLog.ID {
@@ -52,12 +44,7 @@ func (i *detailCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
52 }
44 }
53 }
45 }
54
46
55− if i.focused {
47+ return i, nil
56− i.viewport, cmd = i.viewport.Update(msg)
57− cmds = append(cmds, cmd)
58− }
59−
60− return i, tea.Batch(cmds...)
61}
48}
62
49
63func (i *detailCmp) updateContent() {
50func (i *detailCmp) updateContent() {
@@ -125,48 +112,24 @@ func getLevelStyle(level string) lipgloss.Style {
125}
112}
126
113
127func (i *detailCmp) View() string {
114func (i *detailCmp) View() string {
128− return i.viewport.View()
115+ return styles.ForceReplaceBackgroundWithLipgloss(i.viewport.View(), styles.Background)
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
143}
116}
144
117
145func (i *detailCmp) GetSize() (int, int) {
118func (i *detailCmp) GetSize() (int, int) {
146 return i.width, i.height
119 return i.width, i.height
147}
120}
148
121
149−func (i *detailCmp) SetSize(width int, height int) {
122+func (i *detailCmp) SetSize(width int, height int) tea.Cmd {
150 i.width = width
123 i.width = width
151 i.height = height
124 i.height = height
152 i.viewport.Width = i.width
125 i.viewport.Width = i.width
153 i.viewport.Height = i.height
126 i.viewport.Height = i.height
154 i.updateContent()
127 i.updateContent()
128+ return nil
155}
129}
156
130
157func (i *detailCmp) BindingKeys() []key.Binding {
131func (i *detailCmp) BindingKeys() []key.Binding {
158− return []key.Binding{
132+ return layout.KeyMapToSlice(i.viewport.KeyMap)
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− }
170}
133}
171
134
172func NewLogsDetails() DetailComponent {
135func NewLogsDetails() DetailComponent {
internal/tui/components/logs/table.go+29−51
@@ -7,31 +7,23 @@ import (
7 "github.com/charmbracelet/bubbles/key"
7 "github.com/charmbracelet/bubbles/key"
8 "github.com/charmbracelet/bubbles/table"
8 "github.com/charmbracelet/bubbles/table"
9 tea "github.com/charmbracelet/bubbletea"
9 tea "github.com/charmbracelet/bubbletea"
10− "github.com/kujtimiihoxha/termai/internal/logging"
10+ "github.com/kujtimiihoxha/opencode/internal/logging"
11− "github.com/kujtimiihoxha/termai/internal/pubsub"
11+ "github.com/kujtimiihoxha/opencode/internal/pubsub"
12− "github.com/kujtimiihoxha/termai/internal/tui/layout"
12+ "github.com/kujtimiihoxha/opencode/internal/tui/layout"
13− "github.com/kujtimiihoxha/termai/internal/tui/styles"
13+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
14− "github.com/kujtimiihoxha/termai/internal/tui/util"
14+ "github.com/kujtimiihoxha/opencode/internal/tui/util"
15)
15)
16
16
17type TableComponent interface {
17type TableComponent interface {
18 tea.Model
18 tea.Model
19− layout.Focusable
20 layout.Sizeable
19 layout.Sizeable
21 layout.Bindings
20 layout.Bindings
22− layout.Bordered
23}
21}
24
22
25type tableCmp struct {
23type tableCmp struct {
26 table table.Model
24 table table.Model
27}
25}
28
26
29−func (i *tableCmp) BorderText() map[layout.BorderPosition]string {
30− return map[layout.BorderPosition]string{
31− layout.TopLeftBorder: "Logs",
32− }
33−}
34−
35type selectedLogMsg logging.LogMessage
27type selectedLogMsg logging.LogMessage
36
28
37func (i *tableCmp) Init() tea.Cmd {
29func (i *tableCmp) Init() tea.Cmd {
@@ -41,58 +33,42 @@ func (i *tableCmp) Init() tea.Cmd {
41
33
42func (i *tableCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
34func (i *tableCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
43 var cmds []tea.Cmd
35 var cmds []tea.Cmd
44− if i.table.Focused() {
36+ switch msg.(type) {
45− switch msg.(type) {
37+ case pubsub.Event[logging.LogMessage]:
46− case pubsub.Event[logging.LogMessage]:
38+ i.setRows()
47− i.setRows()
39+ return i, nil
48− return i, nil
40+ }
49− }
41+ prevSelectedRow := i.table.SelectedRow()
50− prevSelectedRow := i.table.SelectedRow()
42+ t, cmd := i.table.Update(msg)
51− t, cmd := i.table.Update(msg)
43+ cmds = append(cmds, cmd)
52− cmds = append(cmds, cmd)
44+ i.table = t
53− i.table = t
45+ selectedRow := i.table.SelectedRow()
54− selectedRow := i.table.SelectedRow()
46+ if selectedRow != nil {
55− if selectedRow != nil {
47+ if prevSelectedRow == nil || selectedRow[0] == prevSelectedRow[0] {
56− if prevSelectedRow == nil || selectedRow[0] == prevSelectedRow[0] {
48+ var log logging.LogMessage
57− var log logging.LogMessage
49+ for _, row := range logging.List() {
58− for _, row := range logging.List() {
50+ if row.ID == selectedRow[0] {
59− if row.ID == selectedRow[0] {
51+ log = row
60− log = row
52+ break
61− break
62− }
63− }
64− if log.ID != "" {
65− cmds = append(cmds, util.CmdHandler(selectedLogMsg(log)))
66 }
53 }
67 }
54 }
55+ if log.ID != "" {
56+ cmds = append(cmds, util.CmdHandler(selectedLogMsg(log)))
57+ }
68 }
58 }
69 }
59 }
70 return i, tea.Batch(cmds...)
60 return i, tea.Batch(cmds...)
71}
61}
72
62
73func (i *tableCmp) View() string {
63func (i *tableCmp) View() string {
74− return i.table.View()
64+ return styles.ForceReplaceBackgroundWithLipgloss(i.table.View(), styles.Background)
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()
89}
65}
90
66
91func (i *tableCmp) GetSize() (int, int) {
67func (i *tableCmp) GetSize() (int, int) {
92 return i.table.Width(), i.table.Height()
68 return i.table.Width(), i.table.Height()
93}
69}
94
70
95−func (i *tableCmp) SetSize(width int, height int) {
71+func (i *tableCmp) SetSize(width int, height int) tea.Cmd {
96 i.table.SetWidth(width)
72 i.table.SetWidth(width)
97 i.table.SetHeight(height)
73 i.table.SetHeight(height)
98 cloumns := i.table.Columns()
74 cloumns := i.table.Columns()
@@ -101,6 +77,7 @@ func (i *tableCmp) SetSize(width int, height int) {
101 cloumns[i] = col
77 cloumns[i] = col
102 }
78 }
103 i.table.SetColumns(cloumns)
79 i.table.SetColumns(cloumns)
80+ return nil
104}
81}
105
82
106func (i *tableCmp) BindingKeys() []key.Binding {
83func (i *tableCmp) BindingKeys() []key.Binding {
@@ -150,6 +127,7 @@ func NewLogsTable() TableComponent {
150 table.WithColumns(columns),
127 table.WithColumns(columns),
151 table.WithStyles(defaultStyles),
128 table.WithStyles(defaultStyles),
152 )
129 )
130+ tableModel.Focus()
153 return &tableCmp{
131 return &tableCmp{
154 table: tableModel,
132 table: tableModel,
155 }
133 }
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.