on main help sign in
reimplement agent,provider and add file history
commitbbfa60c787f2ec459f1689b9a650ddbec9693ed9 authorKujtim Hoxha <kujtimii.h@gmail.com> dateWed 16 Apr 2025, 20:06 (2 years ago) parent76b4065
73 files changed+3595−3879
.opencode.json−4 README.md+17−17 cmd/root.go+7−17 go.mod+1−7 go.sum−14 internal/app/app.go+12−5 internal/app/lsp.go+8−11 internal/config/config.go+76−32 internal/db/files.sql.go+3−1 internal/db/sql/files.sql+3−1 internal/diff/diff.go+83−16 internal/llm/agent/agent-tool.go+7−11 internal/llm/agent/agent.go+313−548 internal/llm/agent/coder.go−63 internal/llm/agent/mcp-tools.go+2−2 internal/llm/agent/task.go−47 internal/llm/agent/tools.go+50 internal/llm/models/anthropic.go+71 internal/llm/models/models.go+103−87 internal/llm/prompt/coder.go+14−14 internal/llm/prompt/prompt.go+19 internal/llm/prompt/task.go+3−2 internal/llm/prompt/title.go+3−1 internal/llm/provider/anthropic.go+274−257 internal/llm/provider/bedrock.go+57−44 internal/llm/provider/gemini.go+350−183 internal/llm/provider/openai.go+241−160 internal/llm/provider/provider.go+131−38 internal/llm/tools/bash.go+4−3 internal/llm/tools/bash_test.go−31 internal/llm/tools/edit.go+72−3 internal/llm/tools/edit_test.go+15−15 internal/llm/tools/file.go−10 internal/llm/tools/glob.go+2−2 internal/llm/tools/grep.go+2−2 internal/llm/tools/ls.go+2−2 internal/llm/tools/mocks_test.go+246 internal/llm/tools/shell/shell.go+11−1 internal/llm/tools/sourcegraph.go+1−1 internal/llm/tools/tools.go+7−2 internal/llm/tools/write.go+26−1 internal/llm/tools/write_test.go+11−11 internal/logging/logger.go+40−1 internal/lsp/client.go+9−4 internal/lsp/handlers.go+1−1 internal/lsp/transport.go+14−14 internal/lsp/watcher/watcher.go+9−9 internal/message/content.go+26−4 internal/pubsub/broker.go+1−1 internal/session/session.go+15 internal/tui/components/chat/chat.go−2 internal/tui/components/chat/editor.go+14−8 internal/tui/components/chat/messages.go+179−26 internal/tui/components/chat/sidebar.go+165−11 internal/tui/components/core/dialog.go−117 internal/tui/components/core/help.go−119 internal/tui/components/core/status.go+73−17 internal/tui/components/dialog/help.go+182 internal/tui/components/dialog/permission.go+332−350 internal/tui/components/dialog/quit.go+91−65 internal/tui/components/logs/details.go−2 internal/tui/components/logs/table.go−22 internal/tui/components/repl/editor.go−201 internal/tui/components/repl/messages.go−513 internal/tui/components/repl/sessions.go−249 internal/tui/layout/overlay.go+6−5 internal/tui/layout/split.go+1 internal/tui/page/chat.go+25−7 internal/tui/page/init.go−308 internal/tui/page/logs.go+17 internal/tui/page/repl.go−21 internal/tui/tui.go+141−136 main.go+7
.opencode.json−4
@@ -1,8 +1,4 @@
11{
2− "model": {
3− "coder": "claude-3.7-sonnet",
4− "coderMaxTokens": 20000
5− },
62 "lsp": {
73 "gopls": {
84 "command": "gopls"
README.md+17−17
@@ -1,14 +1,14 @@
1−# TermAI
1+# OpenCode
22
33> **⚠️ Early Development Notice:** This project is in early development and is not yet ready for production use. Features may change, break, or be incomplete. Use at your own risk.
44
55A powerful terminal-based AI assistant for developers, providing intelligent coding assistance directly in your terminal.
66
7−[![TermAI Demo](https://asciinema.org/a/dtc4nJyGSZX79HRUmFLY3gmoy.svg)](https://asciinema.org/a/dtc4nJyGSZX79HRUmFLY3gmoy)
7+[![OpenCode Demo](https://asciinema.org/a/dtc4nJyGSZX79HRUmFLY3gmoy.svg)](https://asciinema.org/a/dtc4nJyGSZX79HRUmFLY3gmoy)
88
99## Overview
1010
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.
11+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.
1212
1313## Features
1414
@@ -23,16 +23,16 @@ TermAI is a Go-based CLI application that brings AI assistance to your terminal.
2323
2424```bash
2525# Coming soon
26−go install github.com/kujtimiihoxha/termai@latest
26+go install github.com/kujtimiihoxha/opencode@latest
2727```
2828
2929## Configuration
3030
31−TermAI looks for configuration in the following locations:
31+OpenCode looks for configuration in the following locations:
3232
33−- `$HOME/.termai.json`
34−- `$XDG_CONFIG_HOME/termai/.termai.json`
35−- `./.termai.json` (local directory)
33+- `$HOME/.opencode.json`
34+- `$XDG_CONFIG_HOME/opencode/.opencode.json`
35+- `./.opencode.json` (local directory)
3636
3737You can also use environment variables:
3838
@@ -43,11 +43,11 @@ You can also use environment variables:
4343## Usage
4444
4545```bash
46−# Start TermAI
47−termai
46+# Start OpenCode
47+opencode
4848
4949# Start with debug logging
50−termai -d
50+opencode -d
5151```
5252
5353### Keyboard Shortcuts
@@ -81,7 +81,7 @@ termai -d
8181
8282## Architecture
8383
84−TermAI is built with a modular architecture:
84+OpenCode is built with a modular architecture:
8585
8686- **cmd**: Command-line interface using Cobra
8787- **internal/app**: Core application services
@@ -103,22 +103,22 @@ TermAI is built with a modular architecture:
103103
104104```bash
105105# Clone the repository
106−git clone https://github.com/kujtimiihoxha/termai.git
107−cd termai
106+git clone https://github.com/kujtimiihoxha/opencode.git
107+cd opencode
108108
109109# Build the diff script first
110110go run cmd/diff/main.go
111111
112112# Build
113−go build -o termai
113+go build -o opencode
114114
115115# Run
116−./termai
116+./opencode
117117```
118118
119119## Acknowledgments
120120
121−TermAI builds upon the work of several open source projects and developers:
121+OpenCode builds upon the work of several open source projects and developers:
122122
123123- [@isaacphi](https://github.com/isaacphi) - LSP client implementation
124124
cmd/root.go+7−17
@@ -20,7 +20,7 @@ import (
2020)
2121
2222var rootCmd = &cobra.Command{
23− Use: "termai",
23+ Use: "OpenCode",
2424 Short: "A terminal ai assistant",
2525 Long: `A terminal ai assistant`,
2626 RunE: func(cmd *cobra.Command, args []string) error {
@@ -89,12 +89,9 @@ var rootCmd = &cobra.Command{
8989 // Set up message handling for the TUI
9090 go func() {
9191 defer tuiWg.Done()
92− defer func() {
93− if r := recover(); r != nil {
94− logging.Error("Panic in TUI message handling: %v", r)
95− attemptTUIRecovery(program)
96− }
97− }()
92+ defer logging.RecoverPanic("TUI-message-handler", func() {
93+ attemptTUIRecovery(program)
94+ })
9895
9996 for {
10097 select {
@@ -153,11 +150,7 @@ func attemptTUIRecovery(program *tea.Program) {
153150
154151func initMCPTools(ctx context.Context, app *app.App) {
155152 go func() {
156− defer func() {
157− if r := recover(); r != nil {
158− logging.Error("Panic in MCP goroutine: %v", r)
159− }
160− }()
153+ defer logging.RecoverPanic("MCP-goroutine", nil)
161154
162155 // Create a context with timeout for the initial MCP tools fetch
163156 ctxWithTimeout, cancel := context.WithTimeout(ctx, 30*time.Second)
@@ -179,11 +172,7 @@ func setupSubscriber[T any](
179172 wg.Add(1)
180173 go func() {
181174 defer wg.Done()
182− defer func() {
183− if r := recover(); r != nil {
184− logging.Error("Panic in %s subscription goroutine: %v", name, r)
185− }
186− }()
175+ defer logging.RecoverPanic(fmt.Sprintf("subscription-%s", name), nil)
187176
188177 for {
189178 select {
@@ -232,6 +221,7 @@ func setupSubscriptions(app *app.App) (chan tea.Msg, func()) {
232221 // Wait with a timeout for all goroutines to complete
233222 waitCh := make(chan struct{})
234223 go func() {
224+ defer logging.RecoverPanic("subscription-cleanup", nil)
235225 wg.Wait()
236226 close(waitCh)
237227 }()
go.mod+1−7
@@ -23,7 +23,6 @@ require (
2323 github.com/golang-migrate/migrate/v4 v4.18.2
2424 github.com/google/generative-ai-go v0.19.0
2525 github.com/google/uuid v1.6.0
26− github.com/kujtimiihoxha/vimtea v0.0.3-0.20250329221256-a250e98498f9
2726 github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231
2827 github.com/mark3labs/mcp-go v0.17.0
2928 github.com/mattn/go-runewidth v0.0.16
@@ -36,7 +35,6 @@ require (
3635 github.com/spf13/cobra v1.9.1
3736 github.com/spf13/viper v1.20.0
3837 github.com/stretchr/testify v1.10.0
39− golang.org/x/net v0.39.0
4038 google.golang.org/api v0.215.0
4139)
4240
@@ -106,7 +104,6 @@ require (
106104 github.com/pmezard/go-difflib v1.0.0 // indirect
107105 github.com/rivo/uniseg v0.4.7 // indirect
108106 github.com/sagikazarmark/locafero v0.7.0 // indirect
109− github.com/sahilm/fuzzy v0.1.1 // indirect
110107 github.com/skeema/knownhosts v1.3.1 // indirect
111108 github.com/sourcegraph/conc v0.3.0 // indirect
112109 github.com/spf13/afero v1.12.0 // indirect
@@ -129,11 +126,8 @@ require (
129126 go.opentelemetry.io/otel/trace v1.29.0 // indirect
130127 go.uber.org/atomic v1.9.0 // indirect
131128 go.uber.org/multierr v1.9.0 // indirect
132− golang.design/x/clipboard v0.7.0 // indirect
133129 golang.org/x/crypto v0.37.0 // indirect
134− golang.org/x/exp/shiny v0.0.0-20250305212735-054e65f0b394 // indirect
135− golang.org/x/image v0.14.0 // indirect
136− golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a // indirect
130+ golang.org/x/net v0.39.0 // indirect
137131 golang.org/x/oauth2 v0.25.0 // indirect
138132 golang.org/x/sync v0.13.0 // indirect
139133 golang.org/x/sys v0.32.0 // indirect
go.sum−14
@@ -180,10 +180,6 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
180180github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
181181github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
182182github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
183−github.com/kujtimiihoxha/vimtea v0.0.3-0.20250329221256-a250e98498f9 h1:xYfCLI8KUwmXDFp1pOpNX+XsQczQw9VbEuju1pQF5/A=
184−github.com/kujtimiihoxha/vimtea v0.0.3-0.20250329221256-a250e98498f9/go.mod h1:Ye+kIkTmPO5xuqCQ+PPHDTGIViRRoSpSIlcYgma8YlA=
185−github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
186−github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
187183github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
188184github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
189185github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231 h1:9rjt7AfnrXKNSZhp36A3/4QAZAwGGCGD/p8Bse26zms=
@@ -235,8 +231,6 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
235231github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
236232github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
237233github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
238−github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
239−github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
240234github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y=
241235github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
242236github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
@@ -302,8 +296,6 @@ go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
302296go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
303297go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
304298go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
305−golang.design/x/clipboard v0.7.0 h1:4Je8M/ys9AJumVnl8m+rZnIvstSnYj1fvzqYrU3TXvo=
306−golang.design/x/clipboard v0.7.0/go.mod h1:PQIvqYO9GP29yINEfsEn5zSQKAz3UgXmZKzDA6dnq2E=
307299golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
308300golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
309301golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
@@ -314,12 +306,6 @@ golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
314306golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
315307golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
316308golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
317−golang.org/x/exp/shiny v0.0.0-20250305212735-054e65f0b394 h1:bFYqOIMdeiCEdzPJkLiOoMDzW/v3tjW4AA/RmUZYsL8=
318−golang.org/x/exp/shiny v0.0.0-20250305212735-054e65f0b394/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8=
319−golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
320−golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
321−golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a h1:sYbmY3FwUWCBTodZL1S3JUuOvaW6kM2o+clDzzDNBWg=
322−golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a/go.mod h1:Ede7gF0KGoHlj822RtphAHK1jLdrcuRBZg0sF1Q+SPc=
323309golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
324310golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
325311golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
internal/app/app.go+12−5
@@ -7,6 +7,7 @@ import (
77 "sync"
88 "time"
99
10+ "github.com/kujtimiihoxha/termai/internal/config"
1011 "github.com/kujtimiihoxha/termai/internal/db"
1112 "github.com/kujtimiihoxha/termai/internal/history"
1213 "github.com/kujtimiihoxha/termai/internal/llm/agent"
@@ -20,7 +21,7 @@ import (
2021type App struct {
2122 Sessions session.Service
2223 Messages message.Service
23− Files history.Service
24+ History history.Service
2425 Permissions permission.Service
2526
2627 CoderAgent agent.Service
@@ -43,7 +44,7 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) {
4344 app := &App{
4445 Sessions: sessions,
4546 Messages: messages,
46− Files: files,
47+ History: files,
4748 Permissions: permission.NewPermissionService(),
4849 LSPClients: make(map[string]*lsp.Client),
4950 }
@@ -51,11 +52,17 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) {
5152 app.initLSPClients(ctx)
5253
5354 var err error
54− app.CoderAgent, err = agent.NewCoderAgent(
55− app.Permissions,
55+ app.CoderAgent, err = agent.NewAgent(
56+ config.AgentCoder,
5657 app.Sessions,
5758 app.Messages,
58− app.LSPClients,
59+ agent.CoderAgentTools(
60+ app.Permissions,
61+ app.Sessions,
62+ app.Messages,
63+ app.History,
64+ app.LSPClients,
65+ ),
5966 )
6067 if err != nil {
6168 logging.Error("Failed to create coder agent", err)
internal/app/lsp.go+8−11
@@ -22,16 +22,17 @@ func (app *App) initLSPClients(ctx context.Context) {
2222// createAndStartLSPClient creates a new LSP client, initializes it, and starts its workspace watcher
2323func (app *App) createAndStartLSPClient(ctx context.Context, name string, command string, args ...string) {
2424 // Create a specific context for initialization with a timeout
25− initCtx, initCancel := context.WithTimeout(context.Background(), 30*time.Second)
26− defer initCancel()
2725
2826 // Create the LSP client
29− lspClient, err := lsp.NewClient(initCtx, command, args...)
27+ lspClient, err := lsp.NewClient(ctx, command, args...)
3028 if err != nil {
3129 logging.Error("Failed to create LSP client for", name, err)
3230 return
31+
3332 }
3433
34+ initCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
35+ defer cancel()
3536 // Initialize with the initialization context
3637 _, err = lspClient.InitializeLSPClient(initCtx, config.WorkingDirectory())
3738 if err != nil {
@@ -64,14 +65,10 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman
6465// runWorkspaceWatcher executes the workspace watcher for an LSP client
6566func (app *App) runWorkspaceWatcher(ctx context.Context, name string, workspaceWatcher *watcher.WorkspaceWatcher) {
6667 defer app.watcherWG.Done()
67− defer func() {
68− if r := recover(); r != nil {
69− logging.Error("LSP client crashed", "client", name, "panic", r)
70−
71− // Try to restart the client
72− app.restartLSPClient(ctx, name)
73− }
74− }()
68+ defer logging.RecoverPanic("LSP-"+name, func() {
69+ // Try to restart the client
70+ app.restartLSPClient(ctx, name)
71+ })
7572
7673 workspaceWatcher.WatchWorkspace(ctx, config.WorkingDirectory())
7774 logging.Info("Workspace watcher stopped", "client", name)
internal/config/config.go+76−32
@@ -31,12 +31,18 @@ type MCPServer struct {
3131 Headers map[string]string `json:"headers"`
3232}
3333
34−// Model defines configuration for different LLM models and their token limits.
35−type Model struct {
36− Coder models.ModelID `json:"coder"`
37− CoderMaxTokens int64 `json:"coderMaxTokens"`
38− Task models.ModelID `json:"task"`
39− TaskMaxTokens int64 `json:"taskMaxTokens"`
34+type AgentName string
35+
36+const (
37+ AgentCoder AgentName = "coder"
38+ AgentTask AgentName = "task"
39+ AgentTitle AgentName = "title"
40+)
41+
42+// Agent defines configuration for different LLM models and their token limits.
43+type Agent struct {
44+ Model models.ModelID `json:"model"`
45+ MaxTokens int64 `json:"maxTokens"`
4046}
4147
4248// Provider defines configuration for an LLM provider.
@@ -65,8 +71,9 @@ type Config struct {
6571 MCPServers map[string]MCPServer `json:"mcpServers,omitempty"`
6672 Providers map[models.ModelProvider]Provider `json:"providers,omitempty"`
6773 LSP map[string]LSPConfig `json:"lsp,omitempty"`
68− Model Model `json:"model"`
74+ Agents map[AgentName]Agent `json:"agents"`
6975 Debug bool `json:"debug,omitempty"`
76+ DebugLSP bool `json:"debugLSP,omitempty"`
7077}
7178
7279// Application constants
@@ -118,11 +125,42 @@ func Load(workingDir string, debug bool) (*Config, error) {
118125 if cfg.Debug {
119126 defaultLevel = slog.LevelDebug
120127 }
121− // Configure logger
122− logger := slog.New(slog.NewTextHandler(logging.NewWriter(), &slog.HandlerOptions{
123− Level: defaultLevel,
124− }))
125− slog.SetDefault(logger)
128+ // if we are in debug mode make the writer a file
129+ if cfg.Debug {
130+ loggingFile := fmt.Sprintf("%s/%s", cfg.Data.Directory, "debug.log")
131+
132+ // if file does not exist create it
133+ if _, err := os.Stat(loggingFile); os.IsNotExist(err) {
134+ if err := os.MkdirAll(cfg.Data.Directory, 0o755); err != nil {
135+ return cfg, fmt.Errorf("failed to create directory: %w", err)
136+ }
137+ if _, err := os.Create(loggingFile); err != nil {
138+ return cfg, fmt.Errorf("failed to create log file: %w", err)
139+ }
140+ }
141+
142+ sloggingFileWriter, err := os.OpenFile(loggingFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666)
143+ if err != nil {
144+ return cfg, fmt.Errorf("failed to open log file: %w", err)
145+ }
146+ // Configure logger
147+ logger := slog.New(slog.NewTextHandler(sloggingFileWriter, &slog.HandlerOptions{
148+ Level: defaultLevel,
149+ }))
150+ slog.SetDefault(logger)
151+ } else {
152+ // Configure logger
153+ logger := slog.New(slog.NewTextHandler(logging.NewWriter(), &slog.HandlerOptions{
154+ Level: defaultLevel,
155+ }))
156+ slog.SetDefault(logger)
157+ }
158+
159+ // Override the max tokens for title agent
160+ cfg.Agents[AgentTitle] = Agent{
161+ Model: cfg.Agents[AgentTitle].Model,
162+ MaxTokens: 80,
163+ }
126164 return cfg, nil
127165}
128166
@@ -159,44 +197,50 @@ func setProviderDefaults() {
159197 // Groq configuration
160198 if apiKey := os.Getenv("GROQ_API_KEY"); apiKey != "" {
161199 viper.SetDefault("providers.groq.apiKey", apiKey)
162− viper.SetDefault("model.coder", models.QWENQwq)
163− viper.SetDefault("model.coderMaxTokens", defaultMaxTokens)
164− viper.SetDefault("model.task", models.QWENQwq)
165− viper.SetDefault("model.taskMaxTokens", defaultMaxTokens)
200+ viper.SetDefault("agents.coder.model", models.QWENQwq)
201+ viper.SetDefault("agents.coder.maxTokens", defaultMaxTokens)
202+ viper.SetDefault("agents.task.model", models.QWENQwq)
203+ viper.SetDefault("agents.task.maxTokens", defaultMaxTokens)
204+ viper.SetDefault("agents.title.model", models.QWENQwq)
166205 }
167206
168207 // Google Gemini configuration
169208 if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
170209 viper.SetDefault("providers.gemini.apiKey", apiKey)
171− viper.SetDefault("model.coder", models.GRMINI20Flash)
172− viper.SetDefault("model.coderMaxTokens", defaultMaxTokens)
173− viper.SetDefault("model.task", models.GRMINI20Flash)
174− viper.SetDefault("model.taskMaxTokens", defaultMaxTokens)
210+ viper.SetDefault("agents.coder.model", models.GRMINI20Flash)
211+ viper.SetDefault("agents.coder.maxTokens", defaultMaxTokens)
212+ viper.SetDefault("agents.task.model", models.GRMINI20Flash)
213+ viper.SetDefault("agents.task.maxTokens", defaultMaxTokens)
214+ viper.SetDefault("agents.title.model", models.GRMINI20Flash)
175215 }
176216
177217 // OpenAI configuration
178218 if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
179219 viper.SetDefault("providers.openai.apiKey", apiKey)
180− viper.SetDefault("model.coder", models.GPT4o)
181− viper.SetDefault("model.coderMaxTokens", defaultMaxTokens)
182− viper.SetDefault("model.task", models.GPT4o)
183− viper.SetDefault("model.taskMaxTokens", defaultMaxTokens)
220+ viper.SetDefault("agents.coder.model", models.GPT4o)
221+ viper.SetDefault("agents.coder.maxTokens", defaultMaxTokens)
222+ viper.SetDefault("agents.task.model", models.GPT4o)
223+ viper.SetDefault("agents.task.maxTokens", defaultMaxTokens)
224+ viper.SetDefault("agents.title.model", models.GPT4o)
225+
184226 }
185227
186228 // Anthropic configuration
187229 if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
188230 viper.SetDefault("providers.anthropic.apiKey", apiKey)
189− viper.SetDefault("model.coder", models.Claude37Sonnet)
190− viper.SetDefault("model.coderMaxTokens", defaultMaxTokens)
191− viper.SetDefault("model.task", models.Claude37Sonnet)
192− viper.SetDefault("model.taskMaxTokens", defaultMaxTokens)
231+ viper.SetDefault("agents.coder.model", models.Claude37Sonnet)
232+ viper.SetDefault("agents.coder.maxTokens", defaultMaxTokens)
233+ viper.SetDefault("agents.task.model", models.Claude37Sonnet)
234+ viper.SetDefault("agents.task.maxTokens", defaultMaxTokens)
235+ viper.SetDefault("agents.title.model", models.Claude37Sonnet)
193236 }
194237
195238 if hasAWSCredentials() {
196− viper.SetDefault("model.coder", models.BedrockClaude37Sonnet)
197− viper.SetDefault("model.coderMaxTokens", defaultMaxTokens)
198− viper.SetDefault("model.task", models.BedrockClaude37Sonnet)
199− viper.SetDefault("model.taskMaxTokens", defaultMaxTokens)
239+ viper.SetDefault("agents.coder.model", models.BedrockClaude37Sonnet)
240+ viper.SetDefault("agents.coder.maxTokens", defaultMaxTokens)
241+ viper.SetDefault("agents.task.model", models.BedrockClaude37Sonnet)
242+ viper.SetDefault("agents.task.maxTokens", defaultMaxTokens)
243+ viper.SetDefault("agents.title.model", models.BedrockClaude37Sonnet)
200244 }
201245}
202246
internal/db/files.sql.go+3−1
@@ -97,7 +97,9 @@ func (q *Queries) GetFile(ctx context.Context, id string) (File, error) {
9797const getFileByPathAndSession = `-- name: GetFileByPathAndSession :one
9898SELECT id, session_id, path, content, version, created_at, updated_at
9999FROM files
100−WHERE path = ? AND session_id = ? LIMIT 1
100+WHERE path = ? AND session_id = ?
101+ORDER BY created_at DESC
102+LIMIT 1
101103`
102104
103105type GetFileByPathAndSessionParams struct {
internal/db/sql/files.sql+3−1
@@ -6,7 +6,9 @@ WHERE id = ? LIMIT 1;
66-- name: GetFileByPathAndSession :one
77SELECT *
88FROM files
9−WHERE path = ? AND session_id = ? LIMIT 1;
9+WHERE path = ? AND session_id = ?
10+ORDER BY created_at DESC
11+LIMIT 1;
1012
1113-- name: ListFilesBySession :many
1214SELECT *
internal/diff/diff.go+83−16
@@ -19,6 +19,8 @@ import (
1919 "github.com/charmbracelet/x/ansi"
2020 "github.com/go-git/go-git/v5"
2121 "github.com/go-git/go-git/v5/plumbing/object"
22+ "github.com/kujtimiihoxha/termai/internal/config"
23+ "github.com/kujtimiihoxha/termai/internal/logging"
2224 "github.com/sergi/go-diff/diffmatchpatch"
2325)
2426
@@ -77,6 +79,8 @@ type linePair struct {
7779
7880// StyleConfig defines styling for diff rendering
7981type StyleConfig struct {
82+ ShowHeader bool
83+ FileNameFg lipgloss.Color
8084 // Background colors
8185 RemovedLineBg lipgloss.Color
8286 AddedLineBg lipgloss.Color
@@ -106,11 +110,13 @@ type StyleOption func(*StyleConfig)
106110func NewStyleConfig(opts ...StyleOption) StyleConfig {
107111 // Default color scheme
108112 config := StyleConfig{
113+ ShowHeader: true,
114+ FileNameFg: lipgloss.Color("#fab283"),
109115 RemovedLineBg: lipgloss.Color("#3A3030"),
110116 AddedLineBg: lipgloss.Color("#303A30"),
111117 ContextLineBg: lipgloss.Color("#212121"),
112− HunkLineBg: lipgloss.Color("#23252D"),
113− HunkLineFg: lipgloss.Color("#8CA3B4"),
118+ HunkLineBg: lipgloss.Color("#212121"),
119+ HunkLineFg: lipgloss.Color("#a0a0a0"),
114120 RemovedFg: lipgloss.Color("#7C4444"),
115121 AddedFg: lipgloss.Color("#478247"),
116122 LineNumberFg: lipgloss.Color("#888888"),
@@ -132,6 +138,10 @@ func NewStyleConfig(opts ...StyleOption) StyleConfig {
132138}
133139
134140// Style option functions
141+func WithFileNameFg(color lipgloss.Color) StyleOption {
142+ return func(s *StyleConfig) { s.FileNameFg = color }
143+}
144+
135145func WithRemovedLineBg(color lipgloss.Color) StyleOption {
136146 return func(s *StyleConfig) { s.RemovedLineBg = color }
137147}
@@ -190,6 +200,10 @@ func WithHunkLineFg(color lipgloss.Color) StyleOption {
190200 return func(s *StyleConfig) { s.HunkLineFg = color }
191201}
192202
203+func WithShowHeader(show bool) StyleOption {
204+ return func(s *StyleConfig) { s.ShowHeader = show }
205+}
206+
193207// -------------------------------------------------------------------------
194208// Parse Configuration
195209// -------------------------------------------------------------------------
@@ -841,10 +855,12 @@ func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) str
841855 // Calculate column width
842856 colWidth := config.TotalWidth / 2
843857
858+ leftWidth := colWidth
859+ rightWidth := config.TotalWidth - colWidth
844860 var sb strings.Builder
845861 for _, p := range pairs {
846− leftStr := renderLeftColumn(fileName, p.left, colWidth, config.Style)
847− rightStr := renderRightColumn(fileName, p.right, colWidth, config.Style)
862+ leftStr := renderLeftColumn(fileName, p.left, leftWidth, config.Style)
863+ rightStr := renderRightColumn(fileName, p.right, rightWidth, config.Style)
848864 sb.WriteString(leftStr + rightStr + "\n")
849865 }
850866
@@ -861,17 +877,50 @@ func FormatDiff(diffText string, opts ...SideBySideOption) (string, error) {
861877 var sb strings.Builder
862878 config := NewSideBySideConfig(opts...)
863879
864− for i, h := range diffResult.Hunks {
865− if i > 0 {
866− // Render hunk header
867− sb.WriteString(
868− lipgloss.NewStyle().
869− Background(config.Style.HunkLineBg).
870− Foreground(config.Style.HunkLineFg).
871− Width(config.TotalWidth).
872− Render(h.Header) + "\n",
873− )
874− }
880+ if config.Style.ShowHeader {
881+ removeIcon := lipgloss.NewStyle().
882+ Background(config.Style.RemovedLineBg).
883+ Foreground(config.Style.RemovedFg).
884+ Render("⏹")
885+ addIcon := lipgloss.NewStyle().
886+ Background(config.Style.AddedLineBg).
887+ Foreground(config.Style.AddedFg).
888+ Render("⏹")
889+
890+ fileName := lipgloss.NewStyle().
891+ Background(config.Style.ContextLineBg).
892+ Foreground(config.Style.FileNameFg).
893+ Render(" " + diffResult.OldFile)
894+ sb.WriteString(
895+ lipgloss.NewStyle().
896+ Background(config.Style.ContextLineBg).
897+ Padding(0, 1, 0, 1).
898+ Foreground(config.Style.FileNameFg).
899+ BorderStyle(lipgloss.NormalBorder()).
900+ BorderTop(true).
901+ BorderBottom(true).
902+ BorderForeground(config.Style.FileNameFg).
903+ BorderBackground(config.Style.ContextLineBg).
904+ Width(config.TotalWidth).
905+ Render(
906+ lipgloss.JoinHorizontal(lipgloss.Top,
907+ removeIcon,
908+ addIcon,
909+ fileName,
910+ ),
911+ ) + "\n",
912+ )
913+ }
914+
915+ for _, h := range diffResult.Hunks {
916+ // Render hunk header
917+ sb.WriteString(
918+ lipgloss.NewStyle().
919+ Background(config.Style.HunkLineBg).
920+ Foreground(config.Style.HunkLineFg).
921+ Width(config.TotalWidth).
922+ Render(h.Header) + "\n",
923+ )
875924 sb.WriteString(RenderSideBySideHunk(diffResult.OldFile, h, opts...))
876925 }
877926
@@ -880,9 +929,15 @@ func FormatDiff(diffText string, opts ...SideBySideOption) (string, error) {
880929
881930// GenerateDiff creates a unified diff from two file contents
882931func GenerateDiff(beforeContent, afterContent, fileName string) (string, int, int) {
932+ // remove the cwd prefix and ensure consistent path format
933+ // this prevents issues with absolute paths in different environments
934+ cwd := config.WorkingDirectory()
935+ fileName = strings.TrimPrefix(fileName, cwd)
936+ fileName = strings.TrimPrefix(fileName, "/")
883937 // Create temporary directory for git operations
884− tempDir, err := os.MkdirTemp("", "git-diff-temp")
938+ tempDir, err := os.MkdirTemp("", fmt.Sprintf("git-diff-%d", time.Now().UnixNano()))
885939 if err != nil {
940+ logging.Error("Failed to create temp directory for git diff", "error", err)
886941 return "", 0, 0
887942 }
888943 defer os.RemoveAll(tempDir)
@@ -890,25 +945,30 @@ func GenerateDiff(beforeContent, afterContent, fileName string) (string, int, in
890945 // Initialize git repo
891946 repo, err := git.PlainInit(tempDir, false)
892947 if err != nil {
948+ logging.Error("Failed to initialize git repository", "error", err)
893949 return "", 0, 0
894950 }
895951
896952 wt, err := repo.Worktree()
897953 if err != nil {
954+ logging.Error("Failed to get git worktree", "error", err)
898955 return "", 0, 0
899956 }
900957
901958 // Write the "before" content and commit it
902959 fullPath := filepath.Join(tempDir, fileName)
903960 if err = os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
961+ logging.Error("Failed to create directory for file", "error", err)
904962 return "", 0, 0
905963 }
906964 if err = os.WriteFile(fullPath, []byte(beforeContent), 0o644); err != nil {
965+ logging.Error("Failed to write before content to file", "error", err)
907966 return "", 0, 0
908967 }
909968
910969 _, err = wt.Add(fileName)
911970 if err != nil {
971+ logging.Error("Failed to add file to git", "error", err)
912972 return "", 0, 0
913973 }
914974
@@ -920,16 +980,19 @@ func GenerateDiff(beforeContent, afterContent, fileName string) (string, int, in
920980 },
921981 })
922982 if err != nil {
983+ logging.Error("Failed to commit before content", "error", err)
923984 return "", 0, 0
924985 }
925986
926987 // Write the "after" content and commit it
927988 if err = os.WriteFile(fullPath, []byte(afterContent), 0o644); err != nil {
989+ logging.Error("Failed to write after content to file", "error", err)
928990 return "", 0, 0
929991 }
930992
931993 _, err = wt.Add(fileName)
932994 if err != nil {
995+ logging.Error("Failed to add file to git", "error", err)
933996 return "", 0, 0
934997 }
935998
@@ -941,22 +1004,26 @@ func GenerateDiff(beforeContent, afterContent, fileName string) (string, int, in
9411004 },
9421005 })
9431006 if err != nil {
1007+ logging.Error("Failed to commit after content", "error", err)
9441008 return "", 0, 0
9451009 }
9461010
9471011 // Get the diff between the two commits
9481012 beforeCommitObj, err := repo.CommitObject(beforeCommit)
9491013 if err != nil {
1014+ logging.Error("Failed to get before commit object", "error", err)
9501015 return "", 0, 0
9511016 }
9521017
9531018 afterCommitObj, err := repo.CommitObject(afterCommit)
9541019 if err != nil {
1020+ logging.Error("Failed to get after commit object", "error", err)
9551021 return "", 0, 0
9561022 }
9571023
9581024 patch, err := beforeCommitObj.Patch(afterCommitObj)
9591025 if err != nil {
1026+ logging.Error("Failed to create git diff patch", "error", err)
9601027 return "", 0, 0
9611028 }
9621029
internal/llm/agent/agent-tool.go+7−11
@@ -5,6 +5,7 @@ import (
55 "encoding/json"
66 "fmt"
77
8+ "github.com/kujtimiihoxha/termai/internal/config"
89 "github.com/kujtimiihoxha/termai/internal/llm/tools"
910 "github.com/kujtimiihoxha/termai/internal/lsp"
1011 "github.com/kujtimiihoxha/termai/internal/message"
@@ -53,7 +54,7 @@ func (b *agentTool) Run(ctx context.Context, call tools.ToolCall) (tools.ToolRes
5354 return tools.ToolResponse{}, fmt.Errorf("session_id and message_id are required")
5455 }
5556
56− agent, err := NewTaskAgent(b.messages, b.sessions, b.lspClients)
57+ agent, err := NewAgent(config.AgentTask, b.sessions, b.messages, TaskAgentTools(b.lspClients))
5758 if err != nil {
5859 return tools.ToolResponse{}, fmt.Errorf("error creating agent: %s", err)
5960 }
@@ -63,21 +64,16 @@ func (b *agentTool) Run(ctx context.Context, call tools.ToolCall) (tools.ToolRes
6364 return tools.ToolResponse{}, fmt.Errorf("error creating session: %s", err)
6465 }
6566
66− err = agent.Generate(ctx, session.ID, params.Prompt)
67+ done, err := agent.Run(ctx, session.ID, params.Prompt)
6768 if err != nil {
6869 return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", err)
6970 }
70−
71− messages, err := b.messages.List(ctx, session.ID)
72− if err != nil {
73− return tools.ToolResponse{}, fmt.Errorf("error listing messages: %s", err)
74− }
75−
76− if len(messages) == 0 {
77− return tools.NewTextErrorResponse("no response"), nil
71+ result := <-done
72+ if result.Err() != nil {
73+ return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", result.Err())
7874 }
7975
80− response := messages[len(messages)-1]
76+ response := result.Response()
8177 if response.Role != message.Assistant {
8278 return tools.NewTextErrorResponse("no response"), nil
8379 }
internal/llm/agent/agent.go+313−548
@@ -4,8 +4,6 @@ import (
44 "context"
55 "errors"
66 "fmt"
7− "os"
8− "runtime/debug"
97 "strings"
108 "sync"
119
@@ -16,133 +14,101 @@ import (
1614 "github.com/kujtimiihoxha/termai/internal/llm/tools"
1715 "github.com/kujtimiihoxha/termai/internal/logging"
1816 "github.com/kujtimiihoxha/termai/internal/message"
17+ "github.com/kujtimiihoxha/termai/internal/permission"
1918 "github.com/kujtimiihoxha/termai/internal/session"
2019)
2120
2221// Common errors
2322var (
24− ErrProviderNotEnabled = errors.New("provider is not enabled")
25− ErrRequestCancelled = errors.New("request cancelled by user")
26− ErrSessionBusy = errors.New("session is currently processing another request")
23+ ErrRequestCancelled = errors.New("request cancelled by user")
24+ ErrSessionBusy = errors.New("session is currently processing another request")
2725)
2826
29−// Service defines the interface for generating responses
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+
3040type Service interface {
31− Generate(ctx context.Context, sessionID string, content string) error
32− Cancel(sessionID string) error
41+ Run(ctx context.Context, sessionID string, content string) (<-chan AgentEvent, error)
42+ Cancel(sessionID string)
43+ IsSessionBusy(sessionID string) bool
3344}
3445
3546type agent struct {
36− sessions session.Service
37− messages message.Service
38− model models.Model
39− tools []tools.BaseTool
40− agent provider.Provider
41− titleGenerator provider.Provider
42− activeRequests sync.Map // map[sessionID]context.CancelFunc
47+ sessions session.Service
48+ messages message.Service
49+
50+ tools []tools.BaseTool
51+ provider provider.Provider
52+
53+ titleProvider provider.Provider
54+
55+ activeRequests sync.Map
4356}
4457
45−// NewAgent creates a new agent instance with the given model and tools
46−func NewAgent(ctx context.Context, sessions session.Service, messages message.Service, model models.Model, tools []tools.BaseTool) (Service, error) {
47− agentProvider, titleGenerator, err := getAgentProviders(ctx, model)
58+func NewAgent(
59+ agentName config.AgentName,
60+ sessions session.Service,
61+ messages message.Service,
62+ agentTools []tools.BaseTool,
63+) (Service, error) {
64+ agentProvider, err := createAgentProvider(agentName)
4865 if err != nil {
49− return nil, fmt.Errorf("failed to initialize providers: %w", err)
66+ return nil, err
67+ }
68+ var titleProvider provider.Provider
69+ // Only generate titles for the coder agent
70+ if agentName == config.AgentCoder {
71+ titleProvider, err = createAgentProvider(config.AgentTitle)
72+ if err != nil {
73+ return nil, err
74+ }
5075 }
5176
52− return &agent{
53− model: model,
54− tools: tools,
55− sessions: sessions,
77+ agent := &agent{
78+ provider: agentProvider,
5679 messages: messages,
57− agent: agentProvider,
58− titleGenerator: titleGenerator,
80+ sessions: sessions,
81+ tools: agentTools,
82+ titleProvider: titleProvider,
5983 activeRequests: sync.Map{},
60− }, nil
84+ }
85+
86+ return agent, nil
6187}
6288
63−// Cancel cancels an active request by session ID
64−func (a *agent) Cancel(sessionID string) error {
89+func (a *agent) Cancel(sessionID string) {
6590 if cancelFunc, exists := a.activeRequests.LoadAndDelete(sessionID); exists {
6691 if cancel, ok := cancelFunc.(context.CancelFunc); ok {
6792 logging.InfoPersist(fmt.Sprintf("Request cancellation initiated for session: %s", sessionID))
6893 cancel()
69− return nil
7094 }
7195 }
72− return errors.New("no active request found for this session")
7396}
7497
75−// Generate starts the generation process
76−func (a *agent) Generate(ctx context.Context, sessionID string, content string) error {
77− // Check if this session already has an active request
78− if _, busy := a.activeRequests.Load(sessionID); busy {
79− return ErrSessionBusy
80− }
81−
82− // Create a cancellable context
83− genCtx, cancel := context.WithCancel(ctx)
84−
85− // Store cancel function to allow user cancellation
86− a.activeRequests.Store(sessionID, cancel)
87−
88− // Launch the generation in a goroutine
89− go func() {
90− defer func() {
91− if r := recover(); r != nil {
92− logging.ErrorPersist(fmt.Sprintf("Panic in Generate: %v", r))
93−
94− // dump stack trace into a file
95− file, err := os.Create("panic.log")
96− if err != nil {
97− logging.ErrorPersist(fmt.Sprintf("Failed to create panic log: %v", err))
98− return
99− }
100−
101− defer file.Close()
102−
103− stackTrace := debug.Stack()
104− if _, err := file.Write(stackTrace); err != nil {
105− logging.ErrorPersist(fmt.Sprintf("Failed to write panic log: %v", err))
106− }
107−
108− }
109− }()
110− defer a.activeRequests.Delete(sessionID)
111− defer cancel()
112−
113− if err := a.generate(genCtx, sessionID, content); err != nil {
114− if !errors.Is(err, ErrRequestCancelled) && !errors.Is(err, context.Canceled) {
115− // Log the error (avoid logging cancellations as they're expected)
116− logging.ErrorPersist(fmt.Sprintf("Generation error for session %s: %v", sessionID, err))
117−
118− // You may want to create an error message in the chat
119− bgCtx := context.Background()
120− errorMsg := fmt.Sprintf("Sorry, an error occurred: %v", err)
121− _, createErr := a.messages.Create(bgCtx, sessionID, message.CreateMessageParams{
122− Role: message.System,
123− Parts: []message.ContentPart{
124− message.TextContent{
125− Text: errorMsg,
126− },
127− },
128− })
129− if createErr != nil {
130− logging.ErrorPersist(fmt.Sprintf("Failed to create error message: %v", createErr))
131− }
132− }
133− }
134− }()
135−
136− return nil
137−}
138−
139−// IsSessionBusy checks if a session currently has an active request
14098func (a *agent) IsSessionBusy(sessionID string) bool {
14199 _, busy := a.activeRequests.Load(sessionID)
142100 return busy
143−} // handleTitleGeneration asynchronously generates a title for new sessions
144−func (a *agent) handleTitleGeneration(ctx context.Context, sessionID, content string) {
145− response, err := a.titleGenerator.SendMessages(
101+}
102+
103+func (a *agent) generateTitle(ctx context.Context, sessionID string, content string) error {
104+ if a.titleProvider == nil {
105+ return nil
106+ }
107+ session, err := a.sessions.Get(ctx, sessionID)
108+ if err != nil {
109+ return err
110+ }
111+ response, err := a.titleProvider.SendMessages(
146112 ctx,
147113 []message.Message{
148114 {
@@ -154,121 +120,152 @@ func (a *agent) handleTitleGeneration(ctx context.Context, sessionID, content st
154120 },
155121 },
156122 },
157− nil,
123+ make([]tools.BaseTool, 0),
158124 )
159125 if err != nil {
160− logging.ErrorPersist(fmt.Sprintf("Failed to generate title: %v", err))
161− return
126+ return err
162127 }
163128
164− session, err := a.sessions.Get(ctx, sessionID)
165− if err != nil {
166− logging.ErrorPersist(fmt.Sprintf("Failed to get session: %v", err))
167− return
129+ title := strings.TrimSpace(strings.ReplaceAll(response.Content, "\n", " "))
130+ if title == "" {
131+ return nil
168132 }
169133
170− if response.Content != "" {
171− session.Title = strings.TrimSpace(response.Content)
172− session.Title = strings.ReplaceAll(session.Title, "\n", " ")
173− if _, err := a.sessions.Save(ctx, session); err != nil {
174− logging.ErrorPersist(fmt.Sprintf("Failed to save session title: %v", err))
175− }
134+ session.Title = title
135+ _, err = a.sessions.Save(ctx, session)
136+ return err
137+}
138+
139+func (a *agent) err(err error) AgentEvent {
140+ return AgentEvent{
141+ err: err,
176142 }
177143}
178144
179−// TrackUsage updates token usage statistics for the session
180−func (a *agent) TrackUsage(ctx context.Context, sessionID string, model models.Model, usage provider.TokenUsage) error {
181− session, err := a.sessions.Get(ctx, sessionID)
182− if err != nil {
183− return fmt.Errorf("failed to get session: %w", err)
145+func (a *agent) Run(ctx context.Context, sessionID string, content string) (<-chan AgentEvent, error) {
146+ events := make(chan AgentEvent)
147+ if a.IsSessionBusy(sessionID) {
148+ return nil, ErrSessionBusy
184149 }
185150
186− cost := model.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) +
187− model.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) +
188− model.CostPer1MIn/1e6*float64(usage.InputTokens) +
189− model.CostPer1MOut/1e6*float64(usage.OutputTokens)
151+ genCtx, cancel := context.WithCancel(ctx)
152+
153+ a.activeRequests.Store(sessionID, cancel)
154+ go func() {
155+ logging.Debug("Request started", "sessionID", sessionID)
156+ defer logging.RecoverPanic("agent.Run", func() {
157+ events <- a.err(fmt.Errorf("panic while running the agent"))
158+ })
190159
191− session.Cost += cost
192− session.CompletionTokens += usage.OutputTokens
193− session.PromptTokens += usage.InputTokens
160+ result := a.processGeneration(genCtx, sessionID, content)
161+ if result.Err() != nil && !errors.Is(result.Err(), ErrRequestCancelled) && !errors.Is(result.Err(), context.Canceled) {
162+ logging.ErrorPersist(fmt.Sprintf("Generation error for session %s: %v", sessionID, result))
163+ }
164+ logging.Debug("Request completed", "sessionID", sessionID)
165+ a.activeRequests.Delete(sessionID)
166+ cancel()
167+ events <- result
168+ close(events)
169+ }()
170+ return events, nil
171+}
194172
195− _, err = a.sessions.Save(ctx, session)
173+func (a *agent) processGeneration(ctx context.Context, sessionID, content string) AgentEvent {
174+ // List existing messages; if none, start title generation asynchronously.
175+ msgs, err := a.messages.List(ctx, sessionID)
196176 if err != nil {
197− return fmt.Errorf("failed to save session: %w", err)
177+ return a.err(fmt.Errorf("failed to list messages: %w", err))
178+ }
179+ if len(msgs) == 0 {
180+ go func() {
181+ defer logging.RecoverPanic("agent.Run", func() {
182+ logging.ErrorPersist("panic while generating title")
183+ })
184+ titleErr := a.generateTitle(context.Background(), sessionID, content)
185+ if titleErr != nil {
186+ logging.ErrorPersist(fmt.Sprintf("failed to generate title: %v", titleErr))
187+ }
188+ }()
198189 }
199− return nil
200−}
201190
202−// processEvent handles different types of events during generation
203−func (a *agent) processEvent(
204− ctx context.Context,
205− sessionID string,
206− assistantMsg *message.Message,
207− event provider.ProviderEvent,
208−) error {
209− select {
210− case <-ctx.Done():
211− return ctx.Err()
212− default:
213− // Continue processing
191+ userMsg, err := a.createUserMessage(ctx, sessionID, content)
192+ if err != nil {
193+ return a.err(fmt.Errorf("failed to create user message: %w", err))
214194 }
215195
216− switch event.Type {
217− case provider.EventThinkingDelta:
218− assistantMsg.AppendReasoningContent(event.Content)
219− return a.messages.Update(ctx, *assistantMsg)
220− case provider.EventContentDelta:
221− assistantMsg.AppendContent(event.Content)
222− return a.messages.Update(ctx, *assistantMsg)
223− case provider.EventError:
224− if errors.Is(event.Error, context.Canceled) {
225− logging.InfoPersist(fmt.Sprintf("Event processing canceled for session: %s", sessionID))
226− return context.Canceled
196+ // Append the new user message to the conversation history.
197+ msgHistory := append(msgs, userMsg)
198+ for {
199+ // Check for cancellation before each iteration
200+ select {
201+ case <-ctx.Done():
202+ return a.err(ctx.Err())
203+ default:
204+ // Continue processing
227205 }
228− logging.ErrorPersist(event.Error.Error())
229− return event.Error
230− case provider.EventWarning:
231− logging.WarnPersist(event.Info)
232− case provider.EventInfo:
233− logging.InfoPersist(event.Info)
234− case provider.EventComplete:
235− assistantMsg.SetToolCalls(event.Response.ToolCalls)
236− assistantMsg.AddFinish(event.Response.FinishReason)
237− if err := a.messages.Update(ctx, *assistantMsg); err != nil {
238− return fmt.Errorf("failed to update message: %w", err)
206+ agentMessage, toolResults, err := a.streamAndHandleEvents(ctx, sessionID, msgHistory)
207+ if err != nil {
208+ if errors.Is(err, context.Canceled) {
209+ return a.err(ErrRequestCancelled)
210+ }
211+ return a.err(fmt.Errorf("failed to process events: %w", err))
212+ }
213+ logging.Info("Result", "message", agentMessage.FinishReason(), "toolResults", toolResults)
214+ if (agentMessage.FinishReason() == message.FinishReasonToolUse) && toolResults != nil {
215+ // We are not done, we need to respond with the tool response
216+ msgHistory = append(msgHistory, agentMessage, *toolResults)
217+ continue
218+ }
219+ return AgentEvent{
220+ message: agentMessage,
239221 }
240− return a.TrackUsage(ctx, sessionID, a.model, event.Response.Usage)
241222 }
223+}
242224
243− return nil
225+func (a *agent) createUserMessage(ctx context.Context, sessionID, content string) (message.Message, error) {
226+ return a.messages.Create(ctx, sessionID, message.CreateMessageParams{
227+ Role: message.User,
228+ Parts: []message.ContentPart{
229+ message.TextContent{Text: content},
230+ },
231+ })
244232}
245233
246−// ExecuteTools runs all tool calls sequentially and returns the results
247−func (a *agent) ExecuteTools(ctx context.Context, toolCalls []message.ToolCall, tls []tools.BaseTool) ([]message.ToolResult, error) {
248− toolResults := make([]message.ToolResult, len(toolCalls))
234+func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msgHistory []message.Message) (message.Message, *message.Message, error) {
235+ eventChan := a.provider.StreamResponse(ctx, msgHistory, a.tools)
236+
237+ assistantMsg, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
238+ Role: message.Assistant,
239+ Parts: []message.ContentPart{},
240+ Model: a.provider.Model().ID,
241+ })
242+ if err != nil {
243+ return assistantMsg, nil, fmt.Errorf("failed to create assistant message: %w", err)
244+ }
249245
250− // Create a child context that can be canceled
251− ctx, cancel := context.WithCancel(ctx)
252− defer cancel()
246+ // Add the session and message ID into the context if needed by tools.
247+ ctx = context.WithValue(ctx, tools.MessageIDContextKey, assistantMsg.ID)
248+ ctx = context.WithValue(ctx, tools.SessionIDContextKey, sessionID)
253249
254− // Check if already canceled before starting any execution
255− if ctx.Err() != nil {
256− // Mark all tools as canceled
257− for i, toolCall := range toolCalls {
258− toolResults[i] = message.ToolResult{
259− ToolCallID: toolCall.ID,
260− Content: "Tool execution canceled by user",
261− IsError: true,
262− }
250+ // Process each event in the stream.
251+ for event := range eventChan {
252+ if processErr := a.processEvent(ctx, sessionID, &assistantMsg, event); processErr != nil {
253+ a.finishMessage(ctx, &assistantMsg, message.FinishReasonCanceled)
254+ return assistantMsg, nil, processErr
255+ }
256+ if ctx.Err() != nil {
257+ a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled)
258+ return assistantMsg, nil, ctx.Err()
263259 }
264− return toolResults, ctx.Err()
265260 }
266261
262+ toolResults := make([]message.ToolResult, len(assistantMsg.ToolCalls()))
263+ toolCalls := assistantMsg.ToolCalls()
267264 for i, toolCall := range toolCalls {
268− // Check for cancellation before executing each tool
269265 select {
270266 case <-ctx.Done():
271− // Mark this and all remaining tools as canceled
267+ a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled)
268+ // Make all future tool calls cancelled
272269 for j := i; j < len(toolCalls); j++ {
273270 toolResults[j] = message.ToolResult{
274271 ToolCallID: toolCalls[j].ID,
@@ -276,412 +273,180 @@ func (a *agent) ExecuteTools(ctx context.Context, toolCalls []message.ToolCall,
276273 IsError: true,
277274 }
278275 }
279− return toolResults, ctx.Err()
276+ goto out
280277 default:
281278 // Continue processing
282− }
283−
284− response := ""
285− isError := false
286− found := false
287−
288− // Find and execute the appropriate tool
289− for _, tool := range tls {
290− if tool.Info().Name == toolCall.Name {
291− found = true
292− toolResult, toolErr := tool.Run(ctx, tools.ToolCall{
293− ID: toolCall.ID,
294− Name: toolCall.Name,
295− Input: toolCall.Input,
296− })
297−
298− if toolErr != nil {
299− if errors.Is(toolErr, context.Canceled) {
300− response = "Tool execution canceled by user"
301− } else {
302− response = fmt.Sprintf("Error running tool: %s", toolErr)
303− }
304− isError = true
305− } else {
306− response = toolResult.Content
307− isError = toolResult.IsError
279+ var tool tools.BaseTool
280+ for _, availableTools := range a.tools {
281+ if availableTools.Info().Name == toolCall.Name {
282+ tool = availableTools
308283 }
309− break
310284 }
311− }
312−
313− if !found {
314− response = fmt.Sprintf("Tool not found: %s", toolCall.Name)
315− isError = true
316− }
317−
318− toolResults[i] = message.ToolResult{
319− ToolCallID: toolCall.ID,
320− Content: response,
321− IsError: isError,
322− }
323− }
324285
325− return toolResults, nil
326−}
327−
328−// handleToolExecution processes tool calls and creates tool result messages
329−func (a *agent) handleToolExecution(
330− ctx context.Context,
331− assistantMsg message.Message,
332−) (*message.Message, error) {
333− select {
334− case <-ctx.Done():
335− // If cancelled, create tool results that indicate cancellation
336− if len(assistantMsg.ToolCalls()) > 0 {
337− toolResults := make([]message.ToolResult, 0, len(assistantMsg.ToolCalls()))
338− for _, tc := range assistantMsg.ToolCalls() {
339− toolResults = append(toolResults, message.ToolResult{
340− ToolCallID: tc.ID,
341− Content: "Tool execution canceled by user",
286+ // Tool not found
287+ if tool == nil {
288+ toolResults[i] = message.ToolResult{
289+ ToolCallID: toolCall.ID,
290+ Content: fmt.Sprintf("Tool not found: %s", toolCall.Name),
342291 IsError: true,
343− })
292+ }
293+ continue
344294 }
345295
346− // Use background context to ensure the message is created even if original context is cancelled
347− bgCtx := context.Background()
348− parts := make([]message.ContentPart, 0)
349− for _, toolResult := range toolResults {
350− parts = append(parts, toolResult)
351− }
352− msg, err := a.messages.Create(bgCtx, assistantMsg.SessionID, message.CreateMessageParams{
353− Role: message.Tool,
354− Parts: parts,
296+ toolResult, toolErr := tool.Run(ctx, tools.ToolCall{
297+ ID: toolCall.ID,
298+ Name: toolCall.Name,
299+ Input: toolCall.Input,
355300 })
356− if err != nil {
357− return nil, fmt.Errorf("failed to create cancelled tool message: %w", err)
358− }
359− return &msg, ctx.Err()
360− }
361− return nil, ctx.Err()
362− default:
363− // Continue processing
364− }
365−
366− if len(assistantMsg.ToolCalls()) == 0 {
367− return nil, nil
368− }
369−
370− toolResults, err := a.ExecuteTools(ctx, assistantMsg.ToolCalls(), a.tools)
371− if err != nil {
372− // If error is from cancellation, still return the partial results we have
373− if errors.Is(err, context.Canceled) {
374− // Use background context to ensure the message is created even if original context is cancelled
375− bgCtx := context.Background()
376− parts := make([]message.ContentPart, 0)
377− for _, toolResult := range toolResults {
378− parts = append(parts, toolResult)
301+ if toolErr != nil {
302+ if errors.Is(toolErr, permission.ErrorPermissionDenied) {
303+ toolResults[i] = message.ToolResult{
304+ ToolCallID: toolCall.ID,
305+ Content: "Permission denied",
306+ IsError: true,
307+ }
308+ for j := i + 1; j < len(toolCalls); j++ {
309+ toolResults[j] = message.ToolResult{
310+ ToolCallID: toolCalls[j].ID,
311+ Content: "Tool execution canceled by user",
312+ IsError: true,
313+ }
314+ }
315+ a.finishMessage(ctx, &assistantMsg, message.FinishReasonPermissionDenied)
316+ } else {
317+ toolResults[i] = message.ToolResult{
318+ ToolCallID: toolCall.ID,
319+ Content: toolErr.Error(),
320+ IsError: true,
321+ }
322+ for j := i; j < len(toolCalls); j++ {
323+ toolResults[j] = message.ToolResult{
324+ ToolCallID: toolCalls[j].ID,
325+ Content: "Previous tool failed",
326+ IsError: true,
327+ }
328+ }
329+ a.finishMessage(ctx, &assistantMsg, message.FinishReasonError)
330+ }
331+ // If permission is denied or an error happens we cancel all the following tools
332+ break
379333 }
380−
381− msg, createErr := a.messages.Create(bgCtx, assistantMsg.SessionID, message.CreateMessageParams{
382− Role: message.Tool,
383− Parts: parts,
384− })
385− if createErr != nil {
386− logging.ErrorPersist(fmt.Sprintf("Failed to create tool message after cancellation: %v", createErr))
387− return nil, err
334+ toolResults[i] = message.ToolResult{
335+ ToolCallID: toolCall.ID,
336+ Content: toolResult.Content,
337+ Metadata: toolResult.Metadata,
338+ IsError: toolResult.IsError,
388339 }
389− return &msg, err
390340 }
391− return nil, err
392341 }
393−
394− parts := make([]message.ContentPart, 0, len(toolResults))
395− for _, toolResult := range toolResults {
396− parts = append(parts, toolResult)
342+out:
343+ if len(toolResults) == 0 {
344+ return assistantMsg, nil, nil
397345 }
398−
399− msg, err := a.messages.Create(ctx, assistantMsg.SessionID, message.CreateMessageParams{
346+ parts := make([]message.ContentPart, 0)
347+ for _, tr := range toolResults {
348+ parts = append(parts, tr)
349+ }
350+ msg, err := a.messages.Create(context.Background(), assistantMsg.SessionID, message.CreateMessageParams{
400351 Role: message.Tool,
401352 Parts: parts,
402353 })
403354 if err != nil {
404− return nil, fmt.Errorf("failed to create tool message: %w", err)
355+ return assistantMsg, nil, fmt.Errorf("failed to create cancelled tool message: %w", err)
405356 }
406357
407− return &msg, nil
358+ return assistantMsg, &msg, err
408359}
409360
410−// generate handles the main generation workflow
411−func (a *agent) generate(ctx context.Context, sessionID string, content string) error {
412− ctx = context.WithValue(ctx, tools.SessionIDContextKey, sessionID)
361+func (a *agent) finishMessage(ctx context.Context, msg *message.Message, finishReson message.FinishReason) {
362+ msg.AddFinish(finishReson)
363+ _ = a.messages.Update(ctx, *msg)
364+}
413365
414− // Handle context cancellation at any point
415− if err := ctx.Err(); err != nil {
416− return ErrRequestCancelled
366+func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg *message.Message, event provider.ProviderEvent) error {
367+ select {
368+ case <-ctx.Done():
369+ return ctx.Err()
370+ default:
371+ // Continue processing.
417372 }
418373
419− messages, err := a.messages.List(ctx, sessionID)
420− if err != nil {
421− return fmt.Errorf("failed to list messages: %w", err)
374+ switch event.Type {
375+ case provider.EventThinkingDelta:
376+ assistantMsg.AppendReasoningContent(event.Content)
377+ return a.messages.Update(ctx, *assistantMsg)
378+ case provider.EventContentDelta:
379+ assistantMsg.AppendContent(event.Content)
380+ return a.messages.Update(ctx, *assistantMsg)
381+ case provider.EventError:
382+ if errors.Is(event.Error, context.Canceled) {
383+ logging.InfoPersist(fmt.Sprintf("Event processing canceled for session: %s", sessionID))
384+ return context.Canceled
385+ }
386+ logging.ErrorPersist(event.Error.Error())
387+ return event.Error
388+ case provider.EventComplete:
389+ assistantMsg.SetToolCalls(event.Response.ToolCalls)
390+ assistantMsg.AddFinish(event.Response.FinishReason)
391+ if err := a.messages.Update(ctx, *assistantMsg); err != nil {
392+ return fmt.Errorf("failed to update message: %w", err)
393+ }
394+ return a.TrackUsage(ctx, sessionID, a.provider.Model(), event.Response.Usage)
422395 }
423396
424− if len(messages) == 0 {
425− titleCtx := context.Background()
426− go a.handleTitleGeneration(titleCtx, sessionID, content)
427− }
397+ return nil
398+}
428399
429− userMsg, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
430− Role: message.User,
431− Parts: []message.ContentPart{
432− message.TextContent{
433− Text: content,
434− },
435− },
436− })
400+func (a *agent) TrackUsage(ctx context.Context, sessionID string, model models.Model, usage provider.TokenUsage) error {
401+ sess, err := a.sessions.Get(ctx, sessionID)
437402 if err != nil {
438− return fmt.Errorf("failed to create user message: %w", err)
403+ return fmt.Errorf("failed to get session: %w", err)
439404 }
440405
441− messages = append(messages, userMsg)
442−
443− for {
444− // Check for cancellation before each iteration
445− select {
446− case <-ctx.Done():
447− return ErrRequestCancelled
448− default:
449− // Continue processing
450− }
451−
452− eventChan, err := a.agent.StreamResponse(ctx, messages, a.tools)
453− if err != nil {
454− if errors.Is(err, context.Canceled) {
455− return ErrRequestCancelled
456− }
457− return fmt.Errorf("failed to stream response: %w", err)
458− }
459−
460− assistantMsg, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
461− Role: message.Assistant,
462− Parts: []message.ContentPart{},
463− Model: a.model.ID,
464− })
465− if err != nil {
466− return fmt.Errorf("failed to create assistant message: %w", err)
467− }
468−
469− ctx = context.WithValue(ctx, tools.MessageIDContextKey, assistantMsg.ID)
470−
471− // Process events from the LLM provider
472− for event := range eventChan {
473− if err := a.processEvent(ctx, sessionID, &assistantMsg, event); err != nil {
474− if errors.Is(err, context.Canceled) {
475− // Mark as canceled but don't create separate message
476− assistantMsg.AddFinish("canceled")
477− _ = a.messages.Update(context.Background(), assistantMsg)
478− return ErrRequestCancelled
479− }
480− assistantMsg.AddFinish("error:" + err.Error())
481− _ = a.messages.Update(ctx, assistantMsg)
482− return fmt.Errorf("event processing error: %w", err)
483− }
484−
485− // Check for cancellation during event processing
486− select {
487− case <-ctx.Done():
488− // Mark as canceled
489− assistantMsg.AddFinish("canceled")
490− _ = a.messages.Update(context.Background(), assistantMsg)
491− return ErrRequestCancelled
492− default:
493− }
494− }
495−
496− // Check for cancellation before tool execution
497− select {
498− case <-ctx.Done():
499− assistantMsg.AddFinish("canceled_by_user")
500− _ = a.messages.Update(context.Background(), assistantMsg)
501− return ErrRequestCancelled
502− default:
503− }
504−
505− // Execute any tool calls
506− toolMsg, err := a.handleToolExecution(ctx, assistantMsg)
507− if err != nil {
508− if errors.Is(err, context.Canceled) {
509− assistantMsg.AddFinish("canceled_by_user")
510− _ = a.messages.Update(context.Background(), assistantMsg)
511− return ErrRequestCancelled
512− }
513− return fmt.Errorf("tool execution error: %w", err)
514− }
515−
516− if err := a.messages.Update(ctx, assistantMsg); err != nil {
517− return fmt.Errorf("failed to update assistant message: %w", err)
518− }
519−
520− // If no tool calls, we're done
521− if len(assistantMsg.ToolCalls()) == 0 {
522− break
523− }
406+ cost := model.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) +
407+ model.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) +
408+ model.CostPer1MIn/1e6*float64(usage.InputTokens) +
409+ model.CostPer1MOut/1e6*float64(usage.OutputTokens)
524410
525− // Add messages for next iteration
526− messages = append(messages, assistantMsg)
527− if toolMsg != nil {
528− messages = append(messages, *toolMsg)
529− }
411+ sess.Cost += cost
412+ sess.CompletionTokens += usage.OutputTokens
413+ sess.PromptTokens += usage.InputTokens
530414
531− // Check for cancellation after tool execution
532− select {
533− case <-ctx.Done():
534− return ErrRequestCancelled
535− default:
536− }
415+ _, err = a.sessions.Save(ctx, sess)
416+ if err != nil {
417+ return fmt.Errorf("failed to save session: %w", err)
537418 }
538−
539419 return nil
540420}
541421
542−// getAgentProviders initializes the LLM providers based on the chosen model
543−func getAgentProviders(ctx context.Context, model models.Model) (provider.Provider, provider.Provider, error) {
544− maxTokens := config.Get().Model.CoderMaxTokens
545−
546− providerConfig, ok := config.Get().Providers[model.Provider]
547− if !ok || providerConfig.Disabled {
548− return nil, nil, ErrProviderNotEnabled
422+func createAgentProvider(agentName config.AgentName) (provider.Provider, error) {
423+ cfg := config.Get()
424+ agentConfig, ok := cfg.Agents[agentName]
425+ if !ok {
426+ return nil, fmt.Errorf("agent %s not found", agentName)
427+ }
428+ model, ok := models.SupportedModels[agentConfig.Model]
429+ if !ok {
430+ return nil, fmt.Errorf("model %s not supported", agentConfig.Model)
549431 }
550432
551− var agentProvider provider.Provider
552− var titleGenerator provider.Provider
553− var err error
554−
555− switch model.Provider {
556− case models.ProviderOpenAI:
557− agentProvider, err = provider.NewOpenAIProvider(
558− provider.WithOpenAISystemMessage(
559− prompt.CoderOpenAISystemPrompt(),
560− ),
561− provider.WithOpenAIMaxTokens(maxTokens),
562− provider.WithOpenAIModel(model),
563− provider.WithOpenAIKey(providerConfig.APIKey),
564− )
565− if err != nil {
566− return nil, nil, fmt.Errorf("failed to create OpenAI agent provider: %w", err)
567− }
568−
569− titleGenerator, err = provider.NewOpenAIProvider(
570− provider.WithOpenAISystemMessage(
571− prompt.TitlePrompt(),
572− ),
573− provider.WithOpenAIMaxTokens(80),
574− provider.WithOpenAIModel(model),
575− provider.WithOpenAIKey(providerConfig.APIKey),
576− )
577− if err != nil {
578− return nil, nil, fmt.Errorf("failed to create OpenAI title generator: %w", err)
579− }
580−
581− case models.ProviderAnthropic:
582− agentProvider, err = provider.NewAnthropicProvider(
583− provider.WithAnthropicSystemMessage(
584− prompt.CoderAnthropicSystemPrompt(),
585− ),
586− provider.WithAnthropicMaxTokens(maxTokens),
587− provider.WithAnthropicKey(providerConfig.APIKey),
588− provider.WithAnthropicModel(model),
589− )
590− if err != nil {
591− return nil, nil, fmt.Errorf("failed to create Anthropic agent provider: %w", err)
592− }
593−
594− titleGenerator, err = provider.NewAnthropicProvider(
595− provider.WithAnthropicSystemMessage(
596− prompt.TitlePrompt(),
597− ),
598− provider.WithAnthropicMaxTokens(80),
599− provider.WithAnthropicKey(providerConfig.APIKey),
600− provider.WithAnthropicModel(model),
601− )
602− if err != nil {
603− return nil, nil, fmt.Errorf("failed to create Anthropic title generator: %w", err)
604− }
605−
606− case models.ProviderGemini:
607− agentProvider, err = provider.NewGeminiProvider(
608− ctx,
609− provider.WithGeminiSystemMessage(
610− prompt.CoderOpenAISystemPrompt(),
611− ),
612− provider.WithGeminiMaxTokens(int32(maxTokens)),
613− provider.WithGeminiKey(providerConfig.APIKey),
614− provider.WithGeminiModel(model),
615− )
616− if err != nil {
617− return nil, nil, fmt.Errorf("failed to create Gemini agent provider: %w", err)
618− }
619−
620− titleGenerator, err = provider.NewGeminiProvider(
621− ctx,
622− provider.WithGeminiSystemMessage(
623− prompt.TitlePrompt(),
624− ),
625− provider.WithGeminiMaxTokens(80),
626− provider.WithGeminiKey(providerConfig.APIKey),
627− provider.WithGeminiModel(model),
628− )
629− if err != nil {
630− return nil, nil, fmt.Errorf("failed to create Gemini title generator: %w", err)
631− }
632−
633− case models.ProviderGROQ:
634− agentProvider, err = provider.NewOpenAIProvider(
635− provider.WithOpenAISystemMessage(
636− prompt.CoderAnthropicSystemPrompt(),
637− ),
638− provider.WithOpenAIMaxTokens(maxTokens),
639− provider.WithOpenAIModel(model),
640− provider.WithOpenAIKey(providerConfig.APIKey),
641− provider.WithOpenAIBaseURL("https://api.groq.com/openai/v1"),
642− )
643− if err != nil {
644− return nil, nil, fmt.Errorf("failed to create GROQ agent provider: %w", err)
645− }
646−
647− titleGenerator, err = provider.NewOpenAIProvider(
648− provider.WithOpenAISystemMessage(
649− prompt.TitlePrompt(),
650− ),
651− provider.WithOpenAIMaxTokens(80),
652− provider.WithOpenAIModel(model),
653− provider.WithOpenAIKey(providerConfig.APIKey),
654− provider.WithOpenAIBaseURL("https://api.groq.com/openai/v1"),
655− )
656− if err != nil {
657− return nil, nil, fmt.Errorf("failed to create GROQ title generator: %w", err)
658− }
659−
660− case models.ProviderBedrock:
661− agentProvider, err = provider.NewBedrockProvider(
662− provider.WithBedrockSystemMessage(
663− prompt.CoderAnthropicSystemPrompt(),
664− ),
665− provider.WithBedrockMaxTokens(maxTokens),
666− provider.WithBedrockModel(model),
667− )
668− if err != nil {
669− return nil, nil, fmt.Errorf("failed to create Bedrock agent provider: %w", err)
670− }
671−
672− titleGenerator, err = provider.NewBedrockProvider(
673− provider.WithBedrockSystemMessage(
674− prompt.TitlePrompt(),
675− ),
676− provider.WithBedrockMaxTokens(80),
677− provider.WithBedrockModel(model),
678− )
679− if err != nil {
680− return nil, nil, fmt.Errorf("failed to create Bedrock title generator: %w", err)
681− }
682− default:
683− return nil, nil, fmt.Errorf("unsupported provider: %s", model.Provider)
433+ providerCfg, ok := cfg.Providers[model.Provider]
434+ if !ok {
435+ return nil, fmt.Errorf("provider %s not supported", model.Provider)
436+ }
437+ if providerCfg.Disabled {
438+ return nil, fmt.Errorf("provider %s is not enabled", model.Provider)
439+ }
440+ agentProvider, err := provider.NewProvider(
441+ model.Provider,
442+ provider.WithAPIKey(providerCfg.APIKey),
443+ provider.WithModel(model),
444+ provider.WithSystemMessage(prompt.GetAgentPrompt(agentName, model.Provider)),
445+ provider.WithMaxTokens(agentConfig.MaxTokens),
446+ )
447+ if err != nil {
448+ return nil, fmt.Errorf("could not create provider: %v", err)
684449 }
685450
686− return agentProvider, titleGenerator, nil
451+ return agentProvider, nil
687452}
internal/llm/agent/coder.godeleted−63
@@ -1,63 +0,0 @@
1−package agent
2−
3−import (
4− "context"
5− "errors"
6−
7− "github.com/kujtimiihoxha/termai/internal/config"
8− "github.com/kujtimiihoxha/termai/internal/llm/models"
9− "github.com/kujtimiihoxha/termai/internal/llm/tools"
10− "github.com/kujtimiihoxha/termai/internal/lsp"
11− "github.com/kujtimiihoxha/termai/internal/message"
12− "github.com/kujtimiihoxha/termai/internal/permission"
13− "github.com/kujtimiihoxha/termai/internal/session"
14−)
15−
16−type coderAgent struct {
17− Service
18−}
19−
20−func NewCoderAgent(
21− permissions permission.Service,
22− sessions session.Service,
23− messages message.Service,
24− lspClients map[string]*lsp.Client,
25−) (Service, error) {
26− model, ok := models.SupportedModels[config.Get().Model.Coder]
27− if !ok {
28− return nil, errors.New("model not supported")
29− }
30−
31− ctx := context.Background()
32− otherTools := GetMcpTools(ctx, permissions)
33− if len(lspClients) > 0 {
34− otherTools = append(otherTools, tools.NewDiagnosticsTool(lspClients))
35− }
36− agent, err := NewAgent(
37− ctx,
38− sessions,
39− messages,
40− model,
41− append(
42− []tools.BaseTool{
43− tools.NewBashTool(permissions),
44− tools.NewEditTool(lspClients, permissions),
45− tools.NewFetchTool(permissions),
46− tools.NewGlobTool(),
47− tools.NewGrepTool(),
48− tools.NewLsTool(),
49− tools.NewSourcegraphTool(),
50− tools.NewViewTool(lspClients),
51− tools.NewWriteTool(lspClients, permissions),
52− NewAgentTool(sessions, messages, lspClients),
53− }, otherTools...,
54− ),
55− )
56− if err != nil {
57− return nil, err
58− }
59−
60− return &coderAgent{
61− agent,
62− }, nil
63−}
internal/llm/agent/mcp-tools.go+2−2
@@ -46,7 +46,7 @@ func runTool(ctx context.Context, c MCPClient, toolName string, input string) (t
4646 initRequest := mcp.InitializeRequest{}
4747 initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
4848 initRequest.Params.ClientInfo = mcp.Implementation{
49− Name: "termai",
49+ Name: "OpenCode",
5050 Version: version.Version,
5151 }
5252
@@ -135,7 +135,7 @@ func getTools(ctx context.Context, name string, m config.MCPServer, permissions
135135 initRequest := mcp.InitializeRequest{}
136136 initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
137137 initRequest.Params.ClientInfo = mcp.Implementation{
138− Name: "termai",
138+ Name: "OpenCode",
139139 Version: version.Version,
140140 }
141141
internal/llm/agent/task.godeleted−47
@@ -1,47 +0,0 @@
1−package agent
2−
3−import (
4− "context"
5− "errors"
6−
7− "github.com/kujtimiihoxha/termai/internal/config"
8− "github.com/kujtimiihoxha/termai/internal/llm/models"
9− "github.com/kujtimiihoxha/termai/internal/llm/tools"
10− "github.com/kujtimiihoxha/termai/internal/lsp"
11− "github.com/kujtimiihoxha/termai/internal/message"
12− "github.com/kujtimiihoxha/termai/internal/session"
13−)
14−
15−type taskAgent struct {
16− Service
17−}
18−
19−func NewTaskAgent(messages message.Service, sessions session.Service, lspClients map[string]*lsp.Client) (Service, error) {
20− model, ok := models.SupportedModels[config.Get().Model.Coder]
21− if !ok {
22− return nil, errors.New("model not supported")
23− }
24−
25− ctx := context.Background()
26−
27− agent, err := NewAgent(
28− ctx,
29− sessions,
30− messages,
31− model,
32− []tools.BaseTool{
33− tools.NewGlobTool(),
34− tools.NewGrepTool(),
35− tools.NewLsTool(),
36− tools.NewSourcegraphTool(),
37− tools.NewViewTool(lspClients),
38− },
39− )
40− if err != nil {
41− return nil, err
42− }
43−
44− return &taskAgent{
45− agent,
46− }, nil
47−}
internal/llm/agent/tools.gonew file+50
@@ -0,0 +1,50 @@
1+package agent
2+
3+import (
4+ "context"
5+
6+ "github.com/kujtimiihoxha/termai/internal/history"
7+ "github.com/kujtimiihoxha/termai/internal/llm/tools"
8+ "github.com/kujtimiihoxha/termai/internal/lsp"
9+ "github.com/kujtimiihoxha/termai/internal/message"
10+ "github.com/kujtimiihoxha/termai/internal/permission"
11+ "github.com/kujtimiihoxha/termai/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.NewWriteTool(lspClients, permissions, history),
37+ NewAgentTool(sessions, messages, lspClients),
38+ }, otherTools...,
39+ )
40+}
41+
42+func TaskAgentTools(lspClients map[string]*lsp.Client) []tools.BaseTool {
43+ return []tools.BaseTool{
44+ tools.NewGlobTool(),
45+ tools.NewGrepTool(),
46+ tools.NewLsTool(),
47+ tools.NewSourcegraphTool(),
48+ tools.NewViewTool(lspClients),
49+ }
50+}
internal/llm/models/anthropic.gonew file+71
@@ -0,0 +1,71 @@
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+ },
27+ Claude3Haiku: {
28+ ID: Claude3Haiku,
29+ Name: "Claude 3 Haiku",
30+ Provider: ProviderAnthropic,
31+ APIModel: "claude-3-haiku-latest",
32+ CostPer1MIn: 0.25,
33+ CostPer1MInCached: 0.30,
34+ CostPer1MOutCached: 0.03,
35+ CostPer1MOut: 1.25,
36+ ContextWindow: 200000,
37+ },
38+ Claude37Sonnet: {
39+ ID: Claude37Sonnet,
40+ Name: "Claude 3.7 Sonnet",
41+ Provider: ProviderAnthropic,
42+ APIModel: "claude-3-7-sonnet-latest",
43+ CostPer1MIn: 3.0,
44+ CostPer1MInCached: 3.75,
45+ CostPer1MOutCached: 0.30,
46+ CostPer1MOut: 15.0,
47+ ContextWindow: 200000,
48+ },
49+ Claude35Haiku: {
50+ ID: Claude35Haiku,
51+ Name: "Claude 3.5 Haiku",
52+ Provider: ProviderAnthropic,
53+ APIModel: "claude-3-5-haiku-latest",
54+ CostPer1MIn: 0.80,
55+ CostPer1MInCached: 1.0,
56+ CostPer1MOutCached: 0.08,
57+ CostPer1MOut: 4.0,
58+ ContextWindow: 200000,
59+ },
60+ Claude3Opus: {
61+ ID: Claude3Opus,
62+ Name: "Claude 3 Opus",
63+ Provider: ProviderAnthropic,
64+ APIModel: "claude-3-opus-latest",
65+ CostPer1MIn: 15.0,
66+ CostPer1MInCached: 18.75,
67+ CostPer1MOutCached: 1.50,
68+ CostPer1MOut: 75.0,
69+ ContextWindow: 200000,
70+ },
71+}
internal/llm/models/models.go+103−87
@@ -1,5 +1,7 @@
11package models
22
3+import "maps"
4+
35type (
46 ModelID string
57 ModelProvider string
@@ -14,15 +16,13 @@ type Model struct {
1416 CostPer1MOut float64 `json:"cost_per_1m_out"`
1517 CostPer1MInCached float64 `json:"cost_per_1m_in_cached"`
1618 CostPer1MOutCached float64 `json:"cost_per_1m_out_cached"`
19+ ContextWindow int64 `json:"context_window"`
1720}
1821
1922// Model IDs
2023const (
21− // Anthropic
22− Claude35Sonnet ModelID = "claude-3.5-sonnet"
23− Claude3Haiku ModelID = "claude-3-haiku"
24− Claude37Sonnet ModelID = "claude-3.7-sonnet"
2524 // OpenAI
25+ GPT4o ModelID = "gpt-4o"
2626 GPT41 ModelID = "gpt-4.1"
2727
2828 // GEMINI
@@ -37,47 +37,59 @@ const (
3737)
3838
3939const (
40− ProviderOpenAI ModelProvider = "openai"
41− ProviderAnthropic ModelProvider = "anthropic"
42− ProviderBedrock ModelProvider = "bedrock"
43− ProviderGemini ModelProvider = "gemini"
44− ProviderGROQ ModelProvider = "groq"
40+ ProviderOpenAI ModelProvider = "openai"
41+ ProviderBedrock ModelProvider = "bedrock"
42+ ProviderGemini ModelProvider = "gemini"
43+ ProviderGROQ ModelProvider = "groq"
44+
45+ // ForTests
46+ ProviderMock ModelProvider = "__mock"
4547)
4648
4749var SupportedModels = map[ModelID]Model{
48− // Anthropic
49− Claude35Sonnet: {
50− ID: Claude35Sonnet,
51− Name: "Claude 3.5 Sonnet",
52− Provider: ProviderAnthropic,
53− APIModel: "claude-3-5-sonnet-latest",
54− CostPer1MIn: 3.0,
55− CostPer1MInCached: 3.75,
56− CostPer1MOutCached: 0.30,
57− CostPer1MOut: 15.0,
58− },
59− Claude3Haiku: {
60− ID: Claude3Haiku,
61− Name: "Claude 3 Haiku",
62− Provider: ProviderAnthropic,
63− APIModel: "claude-3-haiku-latest",
64− CostPer1MIn: 0.80,
65− CostPer1MInCached: 1,
66− CostPer1MOutCached: 0.08,
67− CostPer1MOut: 4,
68− },
69− Claude37Sonnet: {
70− ID: Claude37Sonnet,
71− Name: "Claude 3.7 Sonnet",
72− Provider: ProviderAnthropic,
73− APIModel: "claude-3-7-sonnet-latest",
74− CostPer1MIn: 3.0,
75− CostPer1MInCached: 3.75,
76− CostPer1MOutCached: 0.30,
77− CostPer1MOut: 15.0,
50+ // // Anthropic
51+ // Claude35Sonnet: {
52+ // ID: Claude35Sonnet,
53+ // Name: "Claude 3.5 Sonnet",
54+ // Provider: ProviderAnthropic,
55+ // APIModel: "claude-3-5-sonnet-latest",
56+ // CostPer1MIn: 3.0,
57+ // CostPer1MInCached: 3.75,
58+ // CostPer1MOutCached: 0.30,
59+ // CostPer1MOut: 15.0,
60+ // },
61+ // Claude3Haiku: {
62+ // ID: Claude3Haiku,
63+ // Name: "Claude 3 Haiku",
64+ // Provider: ProviderAnthropic,
65+ // APIModel: "claude-3-haiku-latest",
66+ // CostPer1MIn: 0.80,
67+ // CostPer1MInCached: 1,
68+ // CostPer1MOutCached: 0.08,
69+ // CostPer1MOut: 4,
70+ // },
71+ // Claude37Sonnet: {
72+ // ID: Claude37Sonnet,
73+ // Name: "Claude 3.7 Sonnet",
74+ // Provider: ProviderAnthropic,
75+ // APIModel: "claude-3-7-sonnet-latest",
76+ // CostPer1MIn: 3.0,
77+ // CostPer1MInCached: 3.75,
78+ // CostPer1MOutCached: 0.30,
79+ // CostPer1MOut: 15.0,
80+ // },
81+ //
82+ // // OpenAI
83+ GPT4o: {
84+ ID: GPT4o,
85+ Name: "GPT-4o",
86+ Provider: ProviderOpenAI,
87+ APIModel: "gpt-4.1",
88+ CostPer1MIn: 2.00,
89+ CostPer1MInCached: 0.50,
90+ CostPer1MOutCached: 0,
91+ CostPer1MOut: 8.00,
7892 },
79−
80− // OpenAI
8193 GPT41: {
8294 ID: GPT41,
8395 Name: "GPT-4.1",
@@ -88,51 +100,55 @@ var SupportedModels = map[ModelID]Model{
88100 CostPer1MOutCached: 0,
89101 CostPer1MOut: 8.00,
90102 },
103+ //
104+ // // GEMINI
105+ // GEMINI25: {
106+ // ID: GEMINI25,
107+ // Name: "Gemini 2.5 Pro",
108+ // Provider: ProviderGemini,
109+ // APIModel: "gemini-2.5-pro-exp-03-25",
110+ // CostPer1MIn: 0,
111+ // CostPer1MInCached: 0,
112+ // CostPer1MOutCached: 0,
113+ // CostPer1MOut: 0,
114+ // },
115+ //
116+ // GRMINI20Flash: {
117+ // ID: GRMINI20Flash,
118+ // Name: "Gemini 2.0 Flash",
119+ // Provider: ProviderGemini,
120+ // APIModel: "gemini-2.0-flash",
121+ // CostPer1MIn: 0.1,
122+ // CostPer1MInCached: 0,
123+ // CostPer1MOutCached: 0.025,
124+ // CostPer1MOut: 0.4,
125+ // },
126+ //
127+ // // GROQ
128+ // QWENQwq: {
129+ // ID: QWENQwq,
130+ // Name: "Qwen Qwq",
131+ // Provider: ProviderGROQ,
132+ // APIModel: "qwen-qwq-32b",
133+ // CostPer1MIn: 0,
134+ // CostPer1MInCached: 0,
135+ // CostPer1MOutCached: 0,
136+ // CostPer1MOut: 0,
137+ // },
138+ //
139+ // // Bedrock
140+ // BedrockClaude37Sonnet: {
141+ // ID: BedrockClaude37Sonnet,
142+ // Name: "Bedrock: Claude 3.7 Sonnet",
143+ // Provider: ProviderBedrock,
144+ // APIModel: "anthropic.claude-3-7-sonnet-20250219-v1:0",
145+ // CostPer1MIn: 3.0,
146+ // CostPer1MInCached: 3.75,
147+ // CostPer1MOutCached: 0.30,
148+ // CostPer1MOut: 15.0,
149+ // },
150+}
91151
92− // GEMINI
93− GEMINI25: {
94− ID: GEMINI25,
95− Name: "Gemini 2.5 Pro",
96− Provider: ProviderGemini,
97− APIModel: "gemini-2.5-pro-exp-03-25",
98− CostPer1MIn: 0,
99− CostPer1MInCached: 0,
100− CostPer1MOutCached: 0,
101− CostPer1MOut: 0,
102− },
103−
104− GRMINI20Flash: {
105− ID: GRMINI20Flash,
106− Name: "Gemini 2.0 Flash",
107− Provider: ProviderGemini,
108− APIModel: "gemini-2.0-flash",
109− CostPer1MIn: 0.1,
110− CostPer1MInCached: 0,
111− CostPer1MOutCached: 0.025,
112− CostPer1MOut: 0.4,
113− },
114−
115− // GROQ
116− QWENQwq: {
117− ID: QWENQwq,
118− Name: "Qwen Qwq",
119− Provider: ProviderGROQ,
120− APIModel: "qwen-qwq-32b",
121− CostPer1MIn: 0,
122− CostPer1MInCached: 0,
123− CostPer1MOutCached: 0,
124− CostPer1MOut: 0,
125− },
126−
127− // Bedrock
128− BedrockClaude37Sonnet: {
129− ID: BedrockClaude37Sonnet,
130− Name: "Bedrock: Claude 3.7 Sonnet",
131− Provider: ProviderBedrock,
132− APIModel: "anthropic.claude-3-7-sonnet-20250219-v1:0",
133− CostPer1MIn: 3.0,
134− CostPer1MInCached: 3.75,
135− CostPer1MOutCached: 0.30,
136− CostPer1MOut: 15.0,
137− },
152+func init() {
153+ maps.Copy(SupportedModels, AnthropicModels)
138154}
internal/llm/prompt/coder.go+14−14
@@ -9,11 +9,22 @@ import (
99 "time"
1010
1111 "github.com/kujtimiihoxha/termai/internal/config"
12+ "github.com/kujtimiihoxha/termai/internal/llm/models"
1213 "github.com/kujtimiihoxha/termai/internal/llm/tools"
1314)
1415
15−func CoderOpenAISystemPrompt() string {
16− basePrompt := `You are termAI, an autonomous CLI-based software engineer. Your job is to reduce user effort by proactively reasoning, inferring context, and solving software engineering tasks end-to-end with minimal prompting.
16+func CoderPrompt(provider models.ModelProvider) string {
17+ basePrompt := baseAnthropicCoderPrompt
18+ switch provider {
19+ case models.ProviderOpenAI:
20+ basePrompt = baseOpenAICoderPrompt
21+ }
22+ envInfo := getEnvironmentInfo()
23+
24+ return fmt.Sprintf("%s\n\n%s\n%s", basePrompt, envInfo, lspInformation())
25+}
26+
27+const baseOpenAICoderPrompt = `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.
1728
1829# Your mindset
1930Act like a competent, efficient software engineer who is familiar with large codebases. You should:
@@ -65,13 +76,7 @@ assistant: [searches repo for references, returns file paths and lines]
6576
6677Never commit changes unless the user explicitly asks you to.`
6778
68− envInfo := getEnvironmentInfo()
69−
70− return fmt.Sprintf("%s\n\n%s\n%s", basePrompt, envInfo, lspInformation())
71−}
72−
73−func CoderAnthropicSystemPrompt() string {
74− basePrompt := `You are termAI, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
79+const baseAnthropicCoderPrompt = `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.
7580
7681IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
7782
@@ -166,11 +171,6 @@ NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTAN
166171
167172You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.`
168173
169− envInfo := getEnvironmentInfo()
170−
171− return fmt.Sprintf("%s\n\n%s\n%s", basePrompt, envInfo, lspInformation())
172−}
173−
174174func getEnvironmentInfo() string {
175175 cwd := config.WorkingDirectory()
176176 isGit := isGitRepo(cwd)
internal/llm/prompt/prompt.gonew file+19
@@ -0,0 +1,19 @@
1+package prompt
2+
3+import (
4+ "github.com/kujtimiihoxha/termai/internal/config"
5+ "github.com/kujtimiihoxha/termai/internal/llm/models"
6+)
7+
8+func GetAgentPrompt(agentName config.AgentName, provider models.ModelProvider) string {
9+ switch agentName {
10+ case config.AgentCoder:
11+ return CoderPrompt(provider)
12+ case config.AgentTitle:
13+ return TitlePrompt(provider)
14+ case config.AgentTask:
15+ return TaskPrompt(provider)
16+ default:
17+ return "You are a helpful assistant"
18+ }
19+}
internal/llm/prompt/task.go+3−2
@@ -2,11 +2,12 @@ package prompt
22
33import (
44 "fmt"
5+
6+ "github.com/kujtimiihoxha/termai/internal/llm/models"
57)
68
7−func TaskAgentSystemPrompt() string {
9+func TaskPrompt(_ models.ModelProvider) string {
810 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.
9−
1011Notes:
11121. IMPORTANT: You should be concise, direct, and to the point, since your responses will be displayed on a command line interface. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...".
12132. When relevant, share file names and code snippets relevant to the query
internal/llm/prompt/title.go+3−1
@@ -1,6 +1,8 @@
11package prompt
22
3−func TitlePrompt() string {
3+import "github.com/kujtimiihoxha/termai/internal/llm/models"
4+
5+func TitlePrompt(_ models.ModelProvider) string {
46 return `you will generate a short title based on the first message a user begins a conversation with
57- ensure it is not more than 50 characters long
68- the title should be a summary of the user's message
internal/llm/provider/anthropic.go+274−257
@@ -12,187 +12,257 @@ import (
1212 "github.com/anthropics/anthropic-sdk-go"
1313 "github.com/anthropics/anthropic-sdk-go/bedrock"
1414 "github.com/anthropics/anthropic-sdk-go/option"
15− "github.com/kujtimiihoxha/termai/internal/llm/models"
15+ "github.com/kujtimiihoxha/termai/internal/config"
1616 "github.com/kujtimiihoxha/termai/internal/llm/tools"
17+ "github.com/kujtimiihoxha/termai/internal/logging"
1718 "github.com/kujtimiihoxha/termai/internal/message"
1819)
1920
20−type anthropicProvider struct {
21− client anthropic.Client
22− model models.Model
23− maxTokens int64
24− apiKey string
25− systemMessage string
26− useBedrock bool
27− disableCache bool
21+type anthropicOptions struct {
22+ useBedrock bool
23+ disableCache bool
24+ shouldThink func(userMessage string) bool
2825}
2926
30−type AnthropicOption func(*anthropicProvider)
27+type AnthropicOption func(*anthropicOptions)
3128
32−func WithAnthropicSystemMessage(message string) AnthropicOption {
33− return func(a *anthropicProvider) {
34− a.systemMessage = message
35− }
29+type anthropicClient struct {
30+ providerOptions providerClientOptions
31+ options anthropicOptions
32+ client anthropic.Client
3633}
3734
38−func WithAnthropicMaxTokens(maxTokens int64) AnthropicOption {
39− return func(a *anthropicProvider) {
40− a.maxTokens = maxTokens
41− }
42−}
35+type AnthropicClient ProviderClient
4336
44−func WithAnthropicModel(model models.Model) AnthropicOption {
45− return func(a *anthropicProvider) {
46− a.model = model
37+func newAnthropicClient(opts providerClientOptions) AnthropicClient {
38+ anthropicOpts := anthropicOptions{}
39+ for _, o := range opts.anthropicOptions {
40+ o(&anthropicOpts)
4741 }
48−}
4942
50−func WithAnthropicKey(apiKey string) AnthropicOption {
51− return func(a *anthropicProvider) {
52− a.apiKey = apiKey
43+ anthropicClientOptions := []option.RequestOption{}
44+ if opts.apiKey != "" {
45+ anthropicClientOptions = append(anthropicClientOptions, option.WithAPIKey(opts.apiKey))
5346 }
54−}
55−
56−func WithAnthropicBedrock() AnthropicOption {
57− return func(a *anthropicProvider) {
58− a.useBedrock = true
47+ if anthropicOpts.useBedrock {
48+ anthropicClientOptions = append(anthropicClientOptions, bedrock.WithLoadDefaultConfig(context.Background()))
5949 }
60−}
6150
62−func WithAnthropicDisableCache() AnthropicOption {
63− return func(a *anthropicProvider) {
64− a.disableCache = true
51+ client := anthropic.NewClient(anthropicClientOptions...)
52+ return &anthropicClient{
53+ providerOptions: opts,
54+ options: anthropicOpts,
55+ client: client,
6556 }
6657}
6758
68−func NewAnthropicProvider(opts ...AnthropicOption) (Provider, error) {
69− provider := &anthropicProvider{
70− maxTokens: 1024,
71− }
59+func (a *anthropicClient) convertMessages(messages []message.Message) (anthropicMessages []anthropic.MessageParam) {
60+ cachedBlocks := 0
61+ for _, msg := range messages {
62+ switch msg.Role {
63+ case message.User:
64+ content := anthropic.NewTextBlock(msg.Content().String())
65+ if cachedBlocks < 2 && !a.options.disableCache {
66+ content.OfRequestTextBlock.CacheControl = anthropic.CacheControlEphemeralParam{
67+ Type: "ephemeral",
68+ }
69+ cachedBlocks++
70+ }
71+ anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(content))
7272
73− for _, opt := range opts {
74− opt(provider)
75− }
73+ case message.Assistant:
74+ blocks := []anthropic.ContentBlockParamUnion{}
75+ if msg.Content().String() != "" {
76+ content := anthropic.NewTextBlock(msg.Content().String())
77+ if cachedBlocks < 2 && !a.options.disableCache {
78+ content.OfRequestTextBlock.CacheControl = anthropic.CacheControlEphemeralParam{
79+ Type: "ephemeral",
80+ }
81+ cachedBlocks++
82+ }
83+ blocks = append(blocks, content)
84+ }
7685
77− if provider.systemMessage == "" {
78− return nil, errors.New("system message is required")
79− }
86+ for _, toolCall := range msg.ToolCalls() {
87+ var inputMap map[string]any
88+ err := json.Unmarshal([]byte(toolCall.Input), &inputMap)
89+ if err != nil {
90+ continue
91+ }
92+ blocks = append(blocks, anthropic.ContentBlockParamOfRequestToolUseBlock(toolCall.ID, inputMap, toolCall.Name))
93+ }
8094
81− anthropicOptions := []option.RequestOption{}
95+ if len(blocks) == 0 {
96+ logging.Warn("There is a message without content, investigate")
97+ // This should never happend but we log this because we might have a bug in our cleanup method
98+ continue
99+ }
100+ anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...))
82101
83− if provider.apiKey != "" {
84− anthropicOptions = append(anthropicOptions, option.WithAPIKey(provider.apiKey))
85− }
86− if provider.useBedrock {
87− anthropicOptions = append(anthropicOptions, bedrock.WithLoadDefaultConfig(context.Background()))
102+ case message.Tool:
103+ results := make([]anthropic.ContentBlockParamUnion, len(msg.ToolResults()))
104+ for i, toolResult := range msg.ToolResults() {
105+ results[i] = anthropic.NewToolResultBlock(toolResult.ToolCallID, toolResult.Content, toolResult.IsError)
106+ }
107+ anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(results...))
108+ }
88109 }
89−
90− provider.client = anthropic.NewClient(anthropicOptions...)
91− return provider, nil
110+ return
92111}
93112
94−func (a *anthropicProvider) SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
95− messages = cleanupMessages(messages)
96− anthropicMessages := a.convertToAnthropicMessages(messages)
97− anthropicTools := a.convertToAnthropicTools(tools)
98−
99− response, err := a.client.Messages.New(
100− ctx,
101− anthropic.MessageNewParams{
102− Model: anthropic.Model(a.model.APIModel),
103− MaxTokens: a.maxTokens,
104− Temperature: anthropic.Float(0),
105− Messages: anthropicMessages,
106− Tools: anthropicTools,
107− System: []anthropic.TextBlockParam{
108− {
109− Text: a.systemMessage,
110− CacheControl: anthropic.CacheControlEphemeralParam{
111− Type: "ephemeral",
112− },
113− },
113+func (a *anthropicClient) convertTools(tools []tools.BaseTool) []anthropic.ToolUnionParam {
114+ anthropicTools := make([]anthropic.ToolUnionParam, len(tools))
115+
116+ for i, tool := range tools {
117+ info := tool.Info()
118+ toolParam := anthropic.ToolParam{
119+ Name: info.Name,
120+ Description: anthropic.String(info.Description),
121+ InputSchema: anthropic.ToolInputSchemaParam{
122+ Properties: info.Parameters,
123+ // TODO: figure out how we can tell claude the required fields?
114124 },
115− },
116− )
117− if err != nil {
118− return nil, err
119− }
125+ }
120126
121− content := ""
122− for _, block := range response.Content {
123− if text, ok := block.AsAny().(anthropic.TextBlock); ok {
124− content += text.Text
127+ if i == len(tools)-1 && !a.options.disableCache {
128+ toolParam.CacheControl = anthropic.CacheControlEphemeralParam{
129+ Type: "ephemeral",
130+ }
125131 }
126− }
127132
128− toolCalls := a.extractToolCalls(response.Content)
129− tokenUsage := a.extractTokenUsage(response.Usage)
133+ anthropicTools[i] = anthropic.ToolUnionParam{OfTool: &toolParam}
134+ }
130135
131− return &ProviderResponse{
132− Content: content,
133− ToolCalls: toolCalls,
134− Usage: tokenUsage,
135− }, nil
136+ return anthropicTools
136137}
137138
138−func (a *anthropicProvider) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (<-chan ProviderEvent, error) {
139− messages = cleanupMessages(messages)
140− anthropicMessages := a.convertToAnthropicMessages(messages)
141− anthropicTools := a.convertToAnthropicTools(tools)
139+func (a *anthropicClient) finishReason(reason string) message.FinishReason {
140+ switch reason {
141+ case "end_turn":
142+ return message.FinishReasonEndTurn
143+ case "max_tokens":
144+ return message.FinishReasonMaxTokens
145+ case "tool_use":
146+ return message.FinishReasonToolUse
147+ case "stop_sequence":
148+ return message.FinishReasonEndTurn
149+ default:
150+ return message.FinishReasonUnknown
151+ }
152+}
142153
154+func (a *anthropicClient) preparedMessages(messages []anthropic.MessageParam, tools []anthropic.ToolUnionParam) anthropic.MessageNewParams {
143155 var thinkingParam anthropic.ThinkingConfigParamUnion
144156 lastMessage := messages[len(messages)-1]
157+ isUser := lastMessage.Role == anthropic.MessageParamRoleUser
158+ messageContent := ""
145159 temperature := anthropic.Float(0)
146− if lastMessage.Role == message.User && strings.Contains(strings.ToLower(lastMessage.Content().String()), "think") {
147− thinkingParam = anthropic.ThinkingConfigParamUnion{
148− OfThinkingConfigEnabled: &anthropic.ThinkingConfigEnabledParam{
149− BudgetTokens: int64(float64(a.maxTokens) * 0.8),
150− Type: "enabled",
151− },
160+ if isUser {
161+ for _, m := range lastMessage.Content {
162+ if m.OfRequestTextBlock != nil && m.OfRequestTextBlock.Text != "" {
163+ messageContent = m.OfRequestTextBlock.Text
164+ }
165+ }
166+ if messageContent != "" && a.options.shouldThink != nil && a.options.shouldThink(messageContent) {
167+ thinkingParam = anthropic.ThinkingConfigParamUnion{
168+ OfThinkingConfigEnabled: &anthropic.ThinkingConfigEnabledParam{
169+ BudgetTokens: int64(float64(a.providerOptions.maxTokens) * 0.8),
170+ Type: "enabled",
171+ },
172+ }
173+ temperature = anthropic.Float(1)
152174 }
153− temperature = anthropic.Float(1)
154175 }
155176
156− eventChan := make(chan ProviderEvent)
177+ return anthropic.MessageNewParams{
178+ Model: anthropic.Model(a.providerOptions.model.APIModel),
179+ MaxTokens: a.providerOptions.maxTokens,
180+ Temperature: temperature,
181+ Messages: messages,
182+ Tools: tools,
183+ Thinking: thinkingParam,
184+ System: []anthropic.TextBlockParam{
185+ {
186+ Text: a.providerOptions.systemMessage,
187+ CacheControl: anthropic.CacheControlEphemeralParam{
188+ Type: "ephemeral",
189+ },
190+ },
191+ },
192+ }
193+}
157194
158− go func() {
159− defer close(eventChan)
195+func (a *anthropicClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (resposne *ProviderResponse, err error) {
196+ preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
197+ cfg := config.Get()
198+ if cfg.Debug {
199+ jsonData, _ := json.Marshal(preparedMessages)
200+ logging.Debug("Prepared messages", "messages", string(jsonData))
201+ }
202+ attempts := 0
203+ for {
204+ attempts++
205+ anthropicResponse, err := a.client.Messages.New(
206+ ctx,
207+ preparedMessages,
208+ )
209+ // If there is an error we are going to see if we can retry the call
210+ if err != nil {
211+ retry, after, retryErr := a.shouldRetry(attempts, err)
212+ if retryErr != nil {
213+ return nil, retryErr
214+ }
215+ if retry {
216+ logging.WarnPersist("Retrying due to rate limit... attempt %d of %d", logging.PersistTimeArg, time.Millisecond*time.Duration(after+100))
217+ select {
218+ case <-ctx.Done():
219+ return nil, ctx.Err()
220+ case <-time.After(time.Duration(after) * time.Millisecond):
221+ continue
222+ }
223+ }
224+ return nil, retryErr
225+ }
160226
161− const maxRetries = 8
162− attempts := 0
227+ content := ""
228+ for _, block := range anthropicResponse.Content {
229+ if text, ok := block.AsAny().(anthropic.TextBlock); ok {
230+ content += text.Text
231+ }
232+ }
163233
164− for {
234+ return &ProviderResponse{
235+ Content: content,
236+ ToolCalls: a.toolCalls(*anthropicResponse),
237+ Usage: a.usage(*anthropicResponse),
238+ }, nil
239+ }
240+}
165241
242+func (a *anthropicClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
243+ preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
244+ cfg := config.Get()
245+ if cfg.Debug {
246+ jsonData, _ := json.Marshal(preparedMessages)
247+ logging.Debug("Prepared messages", "messages", string(jsonData))
248+ }
249+ attempts := 0
250+ eventChan := make(chan ProviderEvent)
251+ go func() {
252+ for {
166253 attempts++
167−
168− stream := a.client.Messages.NewStreaming(
254+ anthropicStream := a.client.Messages.NewStreaming(
169255 ctx,
170− anthropic.MessageNewParams{
171− Model: anthropic.Model(a.model.APIModel),
172− MaxTokens: a.maxTokens,
173− Temperature: temperature,
174− Messages: anthropicMessages,
175− Tools: anthropicTools,
176− Thinking: thinkingParam,
177− System: []anthropic.TextBlockParam{
178− {
179− Text: a.systemMessage,
180− CacheControl: anthropic.CacheControlEphemeralParam{
181− Type: "ephemeral",
182− },
183− },
184− },
185− },
256+ preparedMessages,
186257 )
187−
188258 accumulatedMessage := anthropic.Message{}
189259
190− for stream.Next() {
191− event := stream.Current()
260+ for anthropicStream.Next() {
261+ event := anthropicStream.Current()
192262 err := accumulatedMessage.Accumulate(event)
193263 if err != nil {
194264 eventChan <- ProviderEvent{Type: EventError, Error: err}
195− return // Don't retry on accumulation errors
265+ continue
196266 }
197267
198268 switch event := event.AsAny().(type) {
@@ -211,6 +281,7 @@ func (a *anthropicProvider) StreamResponse(ctx context.Context, messages []messa
211281 Content: event.Delta.Text,
212282 }
213283 }
284+ // TODO: check if we can somehow stream tool calls
214285
215286 case anthropic.ContentBlockStopEvent:
216287 eventChan <- ProviderEvent{Type: EventContentStop}
@@ -223,84 +294,87 @@ func (a *anthropicProvider) StreamResponse(ctx context.Context, messages []messa
223294 }
224295 }
225296
226− toolCalls := a.extractToolCalls(accumulatedMessage.Content)
227− tokenUsage := a.extractTokenUsage(accumulatedMessage.Usage)
228−
229297 eventChan <- ProviderEvent{
230298 Type: EventComplete,
231299 Response: &ProviderResponse{
232300 Content: content,
233− ToolCalls: toolCalls,
234− Usage: tokenUsage,
235− FinishReason: string(accumulatedMessage.StopReason),
301+ ToolCalls: a.toolCalls(accumulatedMessage),
302+ Usage: a.usage(accumulatedMessage),
303+ FinishReason: a.finishReason(string(accumulatedMessage.StopReason)),
236304 },
237305 }
238306 }
239307 }
240308
241− err := stream.Err()
309+ err := anthropicStream.Err()
242310 if err == nil || errors.Is(err, io.EOF) {
311+ close(eventChan)
243312 return
244313 }
245−
246− var apierr *anthropic.Error
247− if !errors.As(err, &apierr) {
248− eventChan <- ProviderEvent{Type: EventError, Error: err}
249− return
250− }
251−
252− if apierr.StatusCode != 429 && apierr.StatusCode != 529 {
253− eventChan <- ProviderEvent{Type: EventError, Error: err}
314+ // If there is an error we are going to see if we can retry the call
315+ retry, after, retryErr := a.shouldRetry(attempts, err)
316+ if retryErr != nil {
317+ eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
318+ close(eventChan)
254319 return
255320 }
256−
257− if attempts > maxRetries {
258− eventChan <- ProviderEvent{
259− Type: EventError,
260− Error: errors.New("maximum retry attempts reached for rate limit (429)"),
261− }
262− return
263− }
264−
265− retryMs := 0
266− retryAfterValues := apierr.Response.Header.Values("Retry-After")
267− if len(retryAfterValues) > 0 {
268− var retryAfterSec int
269− if _, err := fmt.Sscanf(retryAfterValues[0], "%d", &retryAfterSec); err == nil {
270− retryMs = retryAfterSec * 1000
271− eventChan <- ProviderEvent{
272− Type: EventWarning,
273− Info: fmt.Sprintf("[Rate limited: waiting %d seconds as specified by API]", retryAfterSec),
321+ if retry {
322+ logging.WarnPersist("Retrying due to rate limit... attempt %d of %d", logging.PersistTimeArg, time.Millisecond*time.Duration(after+100))
323+ select {
324+ case <-ctx.Done():
325+ // context cancelled
326+ if ctx.Err() != nil {
327+ eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
274328 }
329+ close(eventChan)
330+ return
331+ case <-time.After(time.Duration(after) * time.Millisecond):
332+ continue
275333 }
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
285334 }
286− select {
287− case <-ctx.Done():
335+ if ctx.Err() != nil {
288336 eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
289− return
290− case <-time.After(time.Duration(retryMs) * time.Millisecond):
291− continue
292337 }
293338
339+ close(eventChan)
340+ return
294341 }
295342 }()
343+ return eventChan
344+}
296345
297− return eventChan, nil
346+func (a *anthropicClient) shouldRetry(attempts int, err error) (bool, int64, error) {
347+ var apierr *anthropic.Error
348+ if !errors.As(err, &apierr) {
349+ return false, 0, err
350+ }
351+
352+ if apierr.StatusCode != 429 && apierr.StatusCode != 529 {
353+ return false, 0, err
354+ }
355+
356+ if attempts > maxRetries {
357+ return false, 0, fmt.Errorf("maximum retry attempts reached for rate limit: %d retries", maxRetries)
358+ }
359+
360+ retryMs := 0
361+ retryAfterValues := apierr.Response.Header.Values("Retry-After")
362+
363+ backoffMs := 2000 * (1 << (attempts - 1))
364+ jitterMs := int(float64(backoffMs) * 0.2)
365+ retryMs = backoffMs + jitterMs
366+ if len(retryAfterValues) > 0 {
367+ if _, err := fmt.Sscanf(retryAfterValues[0], "%d", &retryMs); err == nil {
368+ retryMs = retryMs * 1000
369+ }
370+ }
371+ return true, int64(retryMs), nil
298372}
299373
300−func (a *anthropicProvider) extractToolCalls(content []anthropic.ContentBlockUnion) []message.ToolCall {
374+func (a *anthropicClient) toolCalls(msg anthropic.Message) []message.ToolCall {
301375 var toolCalls []message.ToolCall
302376
303− for _, block := range content {
377+ for _, block := range msg.Content {
304378 switch variant := block.AsAny().(type) {
305379 case anthropic.ToolUseBlock:
306380 toolCall := message.ToolCall{
@@ -316,90 +390,33 @@ func (a *anthropicProvider) extractToolCalls(content []anthropic.ContentBlockUni
316390 return toolCalls
317391}
318392
319−func (a *anthropicProvider) extractTokenUsage(usage anthropic.Usage) TokenUsage {
393+func (a *anthropicClient) usage(msg anthropic.Message) TokenUsage {
320394 return TokenUsage{
321− InputTokens: usage.InputTokens,
322− OutputTokens: usage.OutputTokens,
323− CacheCreationTokens: usage.CacheCreationInputTokens,
324− CacheReadTokens: usage.CacheReadInputTokens,
395+ InputTokens: msg.Usage.InputTokens,
396+ OutputTokens: msg.Usage.OutputTokens,
397+ CacheCreationTokens: msg.Usage.CacheCreationInputTokens,
398+ CacheReadTokens: msg.Usage.CacheReadInputTokens,
325399 }
326400}
327401
328−func (a *anthropicProvider) convertToAnthropicTools(tools []tools.BaseTool) []anthropic.ToolUnionParam {
329− anthropicTools := make([]anthropic.ToolUnionParam, len(tools))
330−
331− for i, tool := range tools {
332− info := tool.Info()
333− toolParam := anthropic.ToolParam{
334− Name: info.Name,
335− Description: anthropic.String(info.Description),
336− InputSchema: anthropic.ToolInputSchemaParam{
337− Properties: info.Parameters,
338− },
339− }
340−
341− if i == len(tools)-1 && !a.disableCache {
342− toolParam.CacheControl = anthropic.CacheControlEphemeralParam{
343− Type: "ephemeral",
344− }
345− }
346−
347− anthropicTools[i] = anthropic.ToolUnionParam{OfTool: &toolParam}
402+func WithAnthropicBedrock(useBedrock bool) AnthropicOption {
403+ return func(options *anthropicOptions) {
404+ options.useBedrock = useBedrock
348405 }
349−
350− return anthropicTools
351406}
352407
353−func (a *anthropicProvider) convertToAnthropicMessages(messages []message.Message) []anthropic.MessageParam {
354− anthropicMessages := make([]anthropic.MessageParam, 0, len(messages))
355− cachedBlocks := 0
356−
357− for _, msg := range messages {
358− switch msg.Role {
359− case message.User:
360− content := anthropic.NewTextBlock(msg.Content().String())
361− if cachedBlocks < 2 && !a.disableCache {
362− content.OfRequestTextBlock.CacheControl = anthropic.CacheControlEphemeralParam{
363− Type: "ephemeral",
364− }
365− cachedBlocks++
366− }
367− anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(content))
368−
369− case message.Assistant:
370− blocks := []anthropic.ContentBlockParamUnion{}
371− if msg.Content().String() != "" {
372− content := anthropic.NewTextBlock(msg.Content().String())
373− if cachedBlocks < 2 && !a.disableCache {
374− content.OfRequestTextBlock.CacheControl = anthropic.CacheControlEphemeralParam{
375− Type: "ephemeral",
376− }
377− cachedBlocks++
378− }
379− blocks = append(blocks, content)
380− }
381−
382− for _, toolCall := range msg.ToolCalls() {
383− var inputMap map[string]any
384− err := json.Unmarshal([]byte(toolCall.Input), &inputMap)
385− if err != nil {
386− continue
387− }
388− blocks = append(blocks, anthropic.ContentBlockParamOfRequestToolUseBlock(toolCall.ID, inputMap, toolCall.Name))
389− }
408+func WithAnthropicDisableCache() AnthropicOption {
409+ return func(options *anthropicOptions) {
410+ options.disableCache = true
411+ }
412+}
390413
391− if len(blocks) > 0 {
392− anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...))
393− }
414+func DefaultShouldThinkFn(s string) bool {
415+ return strings.Contains(strings.ToLower(s), "think")
416+}
394417
395− case message.Tool:
396− results := make([]anthropic.ContentBlockParamUnion, len(msg.ToolResults()))
397− for i, toolResult := range msg.ToolResults() {
398− results[i] = anthropic.NewToolResultBlock(toolResult.ToolCallID, toolResult.Content, toolResult.IsError)
399− }
400− anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(results...))
401− }
418+func WithAnthropicShouldThinkFn(fn func(string) bool) AnthropicOption {
419+ return func(options *anthropicOptions) {
420+ options.shouldThink = fn
402421 }
403−
404− return anthropicMessages
405422}
internal/llm/provider/bedrock.go+57−44
@@ -7,33 +7,29 @@ import (
77 "os"
88 "strings"
99
10− "github.com/kujtimiihoxha/termai/internal/llm/models"
1110 "github.com/kujtimiihoxha/termai/internal/llm/tools"
1211 "github.com/kujtimiihoxha/termai/internal/message"
1312)
1413
15−type bedrockProvider struct {
16− childProvider Provider
17− model models.Model
18− maxTokens int64
19− systemMessage string
14+type bedrockOptions struct {
15+ // Bedrock specific options can be added here
2016}
2117
22−func (b *bedrockProvider) SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
23− return b.childProvider.SendMessages(ctx, messages, tools)
24−}
18+type BedrockOption func(*bedrockOptions)
2519
26−func (b *bedrockProvider) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (<-chan ProviderEvent, error) {
27− return b.childProvider.StreamResponse(ctx, messages, tools)
20+type bedrockClient struct {
21+ providerOptions providerClientOptions
22+ options bedrockOptions
23+ childProvider ProviderClient
2824}
2925
30−func NewBedrockProvider(opts ...BedrockOption) (Provider, error) {
31− provider := &bedrockProvider{}
32− for _, opt := range opts {
33− opt(provider)
34− }
26+type BedrockClient ProviderClient
27+
28+func newBedrockClient(opts providerClientOptions) BedrockClient {
29+ bedrockOpts := bedrockOptions{}
30+ // Apply bedrock specific options if they are added in the future
3531
36− // based on the AWS region prefix the model name with, us, eu, ap, sa, etc.
32+ // Get AWS region from environment
3733 region := os.Getenv("AWS_REGION")
3834 if region == "" {
3935 region = os.Getenv("AWS_DEFAULT_REGION")
@@ -43,45 +39,62 @@ func NewBedrockProvider(opts ...BedrockOption) (Provider, error) {
4339 region = "us-east-1" // default region
4440 }
4541 if len(region) < 2 {
46− return nil, errors.New("AWS_REGION or AWS_DEFAULT_REGION environment variable is invalid")
42+ return &bedrockClient{
43+ providerOptions: opts,
44+ options: bedrockOpts,
45+ childProvider: nil, // Will cause an error when used
46+ }
4747 }
48+
49+ // Prefix the model name with region
4850 regionPrefix := region[:2]
49− provider.model.APIModel = fmt.Sprintf("%s.%s", regionPrefix, provider.model.APIModel)
51+ modelName := opts.model.APIModel
52+ opts.model.APIModel = fmt.Sprintf("%s.%s", regionPrefix, modelName)
5053
51− if strings.Contains(string(provider.model.APIModel), "anthropic") {
52− anthropic, err := NewAnthropicProvider(
53− WithAnthropicModel(provider.model),
54− WithAnthropicMaxTokens(provider.maxTokens),
55− WithAnthropicSystemMessage(provider.systemMessage),
56− WithAnthropicBedrock(),
54+ // Determine which provider to use based on the model
55+ if strings.Contains(string(opts.model.APIModel), "anthropic") {
56+ // Create Anthropic client with Bedrock configuration
57+ anthropicOpts := opts
58+ anthropicOpts.anthropicOptions = append(anthropicOpts.anthropicOptions,
59+ WithAnthropicBedrock(true),
5760 WithAnthropicDisableCache(),
5861 )
59− provider.childProvider = anthropic
60− if err != nil {
61− return nil, err
62+ return &bedrockClient{
63+ providerOptions: opts,
64+ options: bedrockOpts,
65+ childProvider: newAnthropicClient(anthropicOpts),
6266 }
63− } else {
64− return nil, errors.New("unsupported model for bedrock provider")
6567 }
66− return provider, nil
67−}
68−
69−type BedrockOption func(*bedrockProvider)
7068
71−func WithBedrockSystemMessage(message string) BedrockOption {
72− return func(a *bedrockProvider) {
73− a.systemMessage = message
69+ // Return client with nil childProvider if model is not supported
70+ // This will cause an error when used
71+ return &bedrockClient{
72+ providerOptions: opts,
73+ options: bedrockOpts,
74+ childProvider: nil,
7475 }
7576}
7677
77−func WithBedrockMaxTokens(maxTokens int64) BedrockOption {
78− return func(a *bedrockProvider) {
79− a.maxTokens = maxTokens
78+func (b *bedrockClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
79+ if b.childProvider == nil {
80+ return nil, errors.New("unsupported model for bedrock provider")
8081 }
82+ return b.childProvider.send(ctx, messages, tools)
8183}
8284
83−func WithBedrockModel(model models.Model) BedrockOption {
84− return func(a *bedrockProvider) {
85− a.model = model
85+func (b *bedrockClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
86+ eventChan := make(chan ProviderEvent)
87+
88+ if b.childProvider == nil {
89+ go func() {
90+ eventChan <- ProviderEvent{
91+ Type: EventError,
92+ Error: errors.New("unsupported model for bedrock provider"),
93+ }
94+ close(eventChan)
95+ }()
96+ return eventChan
8697 }
87−}
98+
99+ return b.childProvider.stream(ctx, messages, tools)
100+}
internal/llm/provider/gemini.go+350−183
@@ -4,80 +4,68 @@ import (
44 "context"
55 "encoding/json"
66 "errors"
7+ "fmt"
8+ "io"
9+ "strings"
10+ "time"
711
812 "github.com/google/generative-ai-go/genai"
913 "github.com/google/uuid"
10− "github.com/kujtimiihoxha/termai/internal/llm/models"
14+ "github.com/kujtimiihoxha/termai/internal/config"
1115 "github.com/kujtimiihoxha/termai/internal/llm/tools"
16+ "github.com/kujtimiihoxha/termai/internal/logging"
1217 "github.com/kujtimiihoxha/termai/internal/message"
1318 "google.golang.org/api/iterator"
1419 "google.golang.org/api/option"
1520)
1621
17−type geminiProvider struct {
18− client *genai.Client
19− model models.Model
20− maxTokens int32
21− apiKey string
22− systemMessage string
22+type geminiOptions struct {
23+ disableCache bool
2324}
2425
25−type GeminiOption func(*geminiProvider)
26+type GeminiOption func(*geminiOptions)
2627
27−func NewGeminiProvider(ctx context.Context, opts ...GeminiOption) (Provider, error) {
28− provider := &geminiProvider{
29− maxTokens: 5000,
30− }
28+type geminiClient struct {
29+ providerOptions providerClientOptions
30+ options geminiOptions
31+ client *genai.Client
32+}
3133
32− for _, opt := range opts {
33− opt(provider)
34− }
34+type GeminiClient ProviderClient
3535
36− if provider.systemMessage == "" {
37− return nil, errors.New("system message is required")
36+func newGeminiClient(opts providerClientOptions) GeminiClient {
37+ geminiOpts := geminiOptions{}
38+ for _, o := range opts.geminiOptions {
39+ o(&geminiOpts)
3840 }
3941
40− client, err := genai.NewClient(ctx, option.WithAPIKey(provider.apiKey))
42+ client, err := genai.NewClient(context.Background(), option.WithAPIKey(opts.apiKey))
4143 if err != nil {
42− return nil, err
43− }
44− provider.client = client
45−
46− return provider, nil
47−}
48−
49−func WithGeminiSystemMessage(message string) GeminiOption {
50− return func(p *geminiProvider) {
51− p.systemMessage = message
44+ logging.Error("Failed to create Gemini client", "error", err)
45+ return nil
5246 }
53−}
5447
55−func WithGeminiMaxTokens(maxTokens int32) GeminiOption {
56− return func(p *geminiProvider) {
57− p.maxTokens = maxTokens
48+ return &geminiClient{
49+ providerOptions: opts,
50+ options: geminiOpts,
51+ client: client,
5852 }
5953}
6054
61−func WithGeminiModel(model models.Model) GeminiOption {
62− return func(p *geminiProvider) {
63− p.model = model
64− }
65−}
66−
67−func WithGeminiKey(apiKey string) GeminiOption {
68− return func(p *geminiProvider) {
69− p.apiKey = apiKey
70− }
71−}
55+func (g *geminiClient) convertMessages(messages []message.Message) []*genai.Content {
56+ var history []*genai.Content
7257
73−func (p *geminiProvider) Close() {
74− if p.client != nil {
75− p.client.Close()
76− }
77−}
58+ // Add system message first
59+ history = append(history, &genai.Content{
60+ Parts: []genai.Part{genai.Text(g.providerOptions.systemMessage)},
61+ Role: "user",
62+ })
7863
79−func (p *geminiProvider) convertToGeminiHistory(messages []message.Message) []*genai.Content {
80− var history []*genai.Content
64+ // Add a system response to acknowledge the system message
65+ history = append(history, &genai.Content{
66+ Parts: []genai.Part{genai.Text("I'll help you with that.")},
67+ Role: "model",
68+ })
8169
8270 for _, msg := range messages {
8371 switch msg.Role {
@@ -86,6 +74,7 @@ func (p *geminiProvider) convertToGeminiHistory(messages []message.Message) []*g
8674 Parts: []genai.Part{genai.Text(msg.Content().String())},
8775 Role: "user",
8876 })
77+
8978 case message.Assistant:
9079 content := &genai.Content{
9180 Role: "model",
@@ -107,6 +96,7 @@ func (p *geminiProvider) convertToGeminiHistory(messages []message.Message) []*g
10796 }
10897
10998 history = append(history, content)
99+
110100 case message.Tool:
111101 for _, result := range msg.ToolResults() {
112102 response := map[string]interface{}{"result": result.Content}
@@ -114,10 +104,11 @@ func (p *geminiProvider) convertToGeminiHistory(messages []message.Message) []*g
114104 if err == nil {
115105 response = parsed
116106 }
107+
117108 var toolCall message.ToolCall
118− for _, msg := range messages {
119− if msg.Role == message.Assistant {
120− for _, call := range msg.ToolCalls() {
109+ for _, m := range messages {
110+ if m.Role == message.Assistant {
111+ for _, call := range m.ToolCalls() {
121112 if call.ID == result.ToolCallID {
122113 toolCall = call
123114 break
@@ -140,186 +131,358 @@ func (p *geminiProvider) convertToGeminiHistory(messages []message.Message) []*g
140131 return history
141132}
142133
143−func (p *geminiProvider) extractTokenUsage(resp *genai.GenerateContentResponse) TokenUsage {
144− if resp == nil || resp.UsageMetadata == nil {
145− return TokenUsage{}
146− }
134+func (g *geminiClient) convertTools(tools []tools.BaseTool) []*genai.Tool {
135+ geminiTools := make([]*genai.Tool, 0, len(tools))
147136
148− return TokenUsage{
149− InputTokens: int64(resp.UsageMetadata.PromptTokenCount),
150− OutputTokens: int64(resp.UsageMetadata.CandidatesTokenCount),
151− CacheCreationTokens: 0, // Not directly provided by Gemini
152− CacheReadTokens: int64(resp.UsageMetadata.CachedContentTokenCount),
137+ for _, tool := range tools {
138+ info := tool.Info()
139+ declaration := &genai.FunctionDeclaration{
140+ Name: info.Name,
141+ Description: info.Description,
142+ Parameters: &genai.Schema{
143+ Type: genai.TypeObject,
144+ Properties: convertSchemaProperties(info.Parameters),
145+ Required: info.Required,
146+ },
147+ }
148+
149+ geminiTools = append(geminiTools, &genai.Tool{
150+ FunctionDeclarations: []*genai.FunctionDeclaration{declaration},
151+ })
153152 }
153+
154+ return geminiTools
154155}
155156
156−func (p *geminiProvider) SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
157− messages = cleanupMessages(messages)
158− model := p.client.GenerativeModel(p.model.APIModel)
159− model.SetMaxOutputTokens(p.maxTokens)
157+func (g *geminiClient) finishReason(reason genai.FinishReason) message.FinishReason {
158+ reasonStr := reason.String()
159+ switch {
160+ case reasonStr == "STOP":
161+ return message.FinishReasonEndTurn
162+ case reasonStr == "MAX_TOKENS":
163+ return message.FinishReasonMaxTokens
164+ case strings.Contains(reasonStr, "FUNCTION") || strings.Contains(reasonStr, "TOOL"):
165+ return message.FinishReasonToolUse
166+ default:
167+ return message.FinishReasonUnknown
168+ }
169+}
160170
161− model.SystemInstruction = genai.NewUserContent(genai.Text(p.systemMessage))
171+func (g *geminiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
172+ model := g.client.GenerativeModel(g.providerOptions.model.APIModel)
173+ model.SetMaxOutputTokens(int32(g.providerOptions.maxTokens))
162174
175+ // Convert tools
163176 if len(tools) > 0 {
164− declarations := p.convertToolsToGeminiFunctionDeclarations(tools)
165− for _, declaration := range declarations {
166− model.Tools = append(model.Tools, &genai.Tool{FunctionDeclarations: []*genai.FunctionDeclaration{declaration}})
167− }
177+ model.Tools = g.convertTools(tools)
168178 }
169179
170− chat := model.StartChat()
171− chat.History = p.convertToGeminiHistory(messages[:len(messages)-1]) // Exclude last message
180+ // Convert messages
181+ geminiMessages := g.convertMessages(messages)
172182
173− lastUserMsg := messages[len(messages)-1]
174− resp, err := chat.SendMessage(ctx, genai.Text(lastUserMsg.Content().String()))
175− if err != nil {
176− return nil, err
183+ cfg := config.Get()
184+ if cfg.Debug {
185+ jsonData, _ := json.Marshal(geminiMessages)
186+ logging.Debug("Prepared messages", "messages", string(jsonData))
177187 }
178188
179− var content string
180− var toolCalls []message.ToolCall
189+ attempts := 0
190+ for {
191+ attempts++
192+ chat := model.StartChat()
193+ chat.History = geminiMessages[:len(geminiMessages)-1] // All but last message
194+
195+ lastMsg := geminiMessages[len(geminiMessages)-1]
196+ var lastText string
197+ for _, part := range lastMsg.Parts {
198+ if text, ok := part.(genai.Text); ok {
199+ lastText = string(text)
200+ break
201+ }
202+ }
181203
182− if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
183− for _, part := range resp.Candidates[0].Content.Parts {
184− switch p := part.(type) {
185− case genai.Text:
186− content = string(p)
187− case genai.FunctionCall:
188− id := "call_" + uuid.New().String()
189− args, _ := json.Marshal(p.Args)
190− toolCalls = append(toolCalls, message.ToolCall{
191− ID: id,
192− Name: p.Name,
193− Input: string(args),
194− Type: "function",
195− })
204+ resp, err := chat.SendMessage(ctx, genai.Text(lastText))
205+ // If there is an error we are going to see if we can retry the call
206+ if err != nil {
207+ retry, after, retryErr := g.shouldRetry(attempts, err)
208+ if retryErr != nil {
209+ return nil, retryErr
196210 }
211+ if retry {
212+ logging.WarnPersist("Retrying due to rate limit... attempt %d of %d", logging.PersistTimeArg, time.Millisecond*time.Duration(after+100))
213+ select {
214+ case <-ctx.Done():
215+ return nil, ctx.Err()
216+ case <-time.After(time.Duration(after) * time.Millisecond):
217+ continue
218+ }
219+ }
220+ return nil, retryErr
197221 }
198− }
199222
200− tokenUsage := p.extractTokenUsage(resp)
223+ content := ""
224+ var toolCalls []message.ToolCall
225+
226+ if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
227+ for _, part := range resp.Candidates[0].Content.Parts {
228+ switch p := part.(type) {
229+ case genai.Text:
230+ content = string(p)
231+ case genai.FunctionCall:
232+ id := "call_" + uuid.New().String()
233+ args, _ := json.Marshal(p.Args)
234+ toolCalls = append(toolCalls, message.ToolCall{
235+ ID: id,
236+ Name: p.Name,
237+ Input: string(args),
238+ Type: "function",
239+ })
240+ }
241+ }
242+ }
201243
202− return &ProviderResponse{
203− Content: content,
204− ToolCalls: toolCalls,
205− Usage: tokenUsage,
206− }, nil
244+ return &ProviderResponse{
245+ Content: content,
246+ ToolCalls: toolCalls,
247+ Usage: g.usage(resp),
248+ FinishReason: g.finishReason(resp.Candidates[0].FinishReason),
249+ }, nil
250+ }
207251}
208252
209−func (p *geminiProvider) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (<-chan ProviderEvent, error) {
210− messages = cleanupMessages(messages)
211− model := p.client.GenerativeModel(p.model.APIModel)
212− model.SetMaxOutputTokens(p.maxTokens)
213−
214− model.SystemInstruction = genai.NewUserContent(genai.Text(p.systemMessage))
253+func (g *geminiClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
254+ model := g.client.GenerativeModel(g.providerOptions.model.APIModel)
255+ model.SetMaxOutputTokens(int32(g.providerOptions.maxTokens))
215256
257+ // Convert tools
216258 if len(tools) > 0 {
217− declarations := p.convertToolsToGeminiFunctionDeclarations(tools)
218− for _, declaration := range declarations {
219− model.Tools = append(model.Tools, &genai.Tool{FunctionDeclarations: []*genai.FunctionDeclaration{declaration}})
220− }
259+ model.Tools = g.convertTools(tools)
221260 }
222261
223− chat := model.StartChat()
224− chat.History = p.convertToGeminiHistory(messages[:len(messages)-1]) // Exclude last message
262+ // Convert messages
263+ geminiMessages := g.convertMessages(messages)
225264
226− lastUserMsg := messages[len(messages)-1]
227−
228− iter := chat.SendMessageStream(ctx, genai.Text(lastUserMsg.Content().String()))
265+ cfg := config.Get()
266+ if cfg.Debug {
267+ jsonData, _ := json.Marshal(geminiMessages)
268+ logging.Debug("Prepared messages", "messages", string(jsonData))
269+ }
229270
271+ attempts := 0
230272 eventChan := make(chan ProviderEvent)
231273
232274 go func() {
233275 defer close(eventChan)
234276
235− var finalResp *genai.GenerateContentResponse
236− currentContent := ""
237− toolCalls := []message.ToolCall{}
238−
239277 for {
240− resp, err := iter.Next()
241− if err == iterator.Done {
242− break
243− }
244− if err != nil {
245− eventChan <- ProviderEvent{
246− Type: EventError,
247− Error: err,
278+ attempts++
279+ chat := model.StartChat()
280+ chat.History = geminiMessages[:len(geminiMessages)-1] // All but last message
281+
282+ lastMsg := geminiMessages[len(geminiMessages)-1]
283+ var lastText string
284+ for _, part := range lastMsg.Parts {
285+ if text, ok := part.(genai.Text); ok {
286+ lastText = string(text)
287+ break
248288 }
249− return
250289 }
251290
252− finalResp = resp
291+ iter := chat.SendMessageStream(ctx, genai.Text(lastText))
253292
254− if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
255− for _, part := range resp.Candidates[0].Content.Parts {
256− switch p := part.(type) {
257− case genai.Text:
258− newText := string(p)
259− eventChan <- ProviderEvent{
260− Type: EventContentDelta,
261− Content: newText,
262− }
263− currentContent += newText
264− case genai.FunctionCall:
265− id := "call_" + uuid.New().String()
266− args, _ := json.Marshal(p.Args)
267− newCall := message.ToolCall{
268− ID: id,
269− Name: p.Name,
270− Input: string(args),
271− Type: "function",
272− }
293+ currentContent := ""
294+ toolCalls := []message.ToolCall{}
295+ var finalResp *genai.GenerateContentResponse
273296
274− isNew := true
275− for _, existing := range toolCalls {
276− if existing.Name == newCall.Name && existing.Input == newCall.Input {
277− isNew = false
278− break
297+ eventChan <- ProviderEvent{Type: EventContentStart}
298+
299+ for {
300+ resp, err := iter.Next()
301+ if err == iterator.Done {
302+ break
303+ }
304+ if err != nil {
305+ retry, after, retryErr := g.shouldRetry(attempts, err)
306+ if retryErr != nil {
307+ eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
308+ return
309+ }
310+ if retry {
311+ logging.WarnPersist("Retrying due to rate limit... attempt %d of %d", logging.PersistTimeArg, time.Millisecond*time.Duration(after+100))
312+ select {
313+ case <-ctx.Done():
314+ if ctx.Err() != nil {
315+ eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
279316 }
317+
318+ return
319+ case <-time.After(time.Duration(after) * time.Millisecond):
320+ break
280321 }
322+ } else {
323+ eventChan <- ProviderEvent{Type: EventError, Error: err}
324+ return
325+ }
326+ }
327+
328+ finalResp = resp
329+
330+ if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
331+ for _, part := range resp.Candidates[0].Content.Parts {
332+ switch p := part.(type) {
333+ case genai.Text:
334+ newText := string(p)
335+ delta := newText[len(currentContent):]
336+ if delta != "" {
337+ eventChan <- ProviderEvent{
338+ Type: EventContentDelta,
339+ Content: delta,
340+ }
341+ currentContent = newText
342+ }
343+ case genai.FunctionCall:
344+ id := "call_" + uuid.New().String()
345+ args, _ := json.Marshal(p.Args)
346+ newCall := message.ToolCall{
347+ ID: id,
348+ Name: p.Name,
349+ Input: string(args),
350+ Type: "function",
351+ }
281352
282− if isNew {
283− toolCalls = append(toolCalls, newCall)
353+ isNew := true
354+ for _, existing := range toolCalls {
355+ if existing.Name == newCall.Name && existing.Input == newCall.Input {
356+ isNew = false
357+ break
358+ }
359+ }
360+
361+ if isNew {
362+ toolCalls = append(toolCalls, newCall)
363+ }
284364 }
285365 }
286366 }
287367 }
288− }
289368
290− tokenUsage := p.extractTokenUsage(finalResp)
369+ eventChan <- ProviderEvent{Type: EventContentStop}
291370
292− eventChan <- ProviderEvent{
293− Type: EventComplete,
294− Response: &ProviderResponse{
295− Content: currentContent,
296− ToolCalls: toolCalls,
297− Usage: tokenUsage,
298− FinishReason: string(finalResp.Candidates[0].FinishReason.String()),
299− },
371+ if finalResp != nil {
372+ eventChan <- ProviderEvent{
373+ Type: EventComplete,
374+ Response: &ProviderResponse{
375+ Content: currentContent,
376+ ToolCalls: toolCalls,
377+ Usage: g.usage(finalResp),
378+ FinishReason: g.finishReason(finalResp.Candidates[0].FinishReason),
379+ },
380+ }
381+ return
382+ }
383+
384+ // If we get here, we need to retry
385+ if attempts > maxRetries {
386+ eventChan <- ProviderEvent{
387+ Type: EventError,
388+ Error: fmt.Errorf("maximum retry attempts reached: %d retries", maxRetries),
389+ }
390+ return
391+ }
392+
393+ // Wait before retrying
394+ select {
395+ case <-ctx.Done():
396+ if ctx.Err() != nil {
397+ eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
398+ }
399+ return
400+ case <-time.After(time.Duration(2000*(1<<(attempts-1))) * time.Millisecond):
401+ continue
402+ }
300403 }
301404 }()
302405
303− return eventChan, nil
406+ return eventChan
304407}
305408
306−func (p *geminiProvider) convertToolsToGeminiFunctionDeclarations(tools []tools.BaseTool) []*genai.FunctionDeclaration {
307− declarations := make([]*genai.FunctionDeclaration, len(tools))
409+func (g *geminiClient) shouldRetry(attempts int, err error) (bool, int64, error) {
410+ // Check if error is a rate limit error
411+ if attempts > maxRetries {
412+ return false, 0, fmt.Errorf("maximum retry attempts reached for rate limit: %d retries", maxRetries)
413+ }
308414
309− for i, tool := range tools {
310− info := tool.Info()
311− declarations[i] = &genai.FunctionDeclaration{
312− Name: info.Name,
313− Description: info.Description,
314− Parameters: &genai.Schema{
315− Type: genai.TypeObject,
316− Properties: convertSchemaProperties(info.Parameters),
317− Required: info.Required,
318− },
415+ // Gemini doesn't have a standard error type we can check against
416+ // So we'll check the error message for rate limit indicators
417+ if errors.Is(err, io.EOF) {
418+ return false, 0, err
419+ }
420+
421+ errMsg := err.Error()
422+ isRateLimit := false
423+
424+ // Check for common rate limit error messages
425+ if contains(errMsg, "rate limit", "quota exceeded", "too many requests") {
426+ isRateLimit = true
427+ }
428+
429+ if !isRateLimit {
430+ return false, 0, err
431+ }
432+
433+ // Calculate backoff with jitter
434+ backoffMs := 2000 * (1 << (attempts - 1))
435+ jitterMs := int(float64(backoffMs) * 0.2)
436+ retryMs := backoffMs + jitterMs
437+
438+ return true, int64(retryMs), nil
439+}
440+
441+func (g *geminiClient) toolCalls(resp *genai.GenerateContentResponse) []message.ToolCall {
442+ var toolCalls []message.ToolCall
443+
444+ if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
445+ for _, part := range resp.Candidates[0].Content.Parts {
446+ if funcCall, ok := part.(genai.FunctionCall); ok {
447+ id := "call_" + uuid.New().String()
448+ args, _ := json.Marshal(funcCall.Args)
449+ toolCalls = append(toolCalls, message.ToolCall{
450+ ID: id,
451+ Name: funcCall.Name,
452+ Input: string(args),
453+ Type: "function",
454+ })
455+ }
319456 }
320457 }
321458
322− return declarations
459+ return toolCalls
460+}
461+
462+func (g *geminiClient) usage(resp *genai.GenerateContentResponse) TokenUsage {
463+ if resp == nil || resp.UsageMetadata == nil {
464+ return TokenUsage{}
465+ }
466+
467+ return TokenUsage{
468+ InputTokens: int64(resp.UsageMetadata.PromptTokenCount),
469+ OutputTokens: int64(resp.UsageMetadata.CandidatesTokenCount),
470+ CacheCreationTokens: 0, // Not directly provided by Gemini
471+ CacheReadTokens: int64(resp.UsageMetadata.CachedContentTokenCount),
472+ }
473+}
474+
475+func WithGeminiDisableCache() GeminiOption {
476+ return func(options *geminiOptions) {
477+ options.disableCache = true
478+ }
479+}
480+
481+// Helper functions
482+func parseJsonToMap(jsonStr string) (map[string]interface{}, error) {
483+ var result map[string]interface{}
484+ err := json.Unmarshal([]byte(jsonStr), &result)
485+ return result, err
323486}
324487
325488func convertSchemaProperties(parameters map[string]interface{}) map[string]*genai.Schema {
@@ -396,8 +559,12 @@ func mapJSONTypeToGenAI(jsonType string) genai.Type {
396559 }
397560}
398561
399−func parseJsonToMap(jsonStr string) (map[string]interface{}, error) {
400− var result map[string]interface{}
401− err := json.Unmarshal([]byte(jsonStr), &result)
402− return result, err
562+func contains(s string, substrs ...string) bool {
563+ for _, substr := range substrs {
564+ if strings.Contains(strings.ToLower(s), strings.ToLower(substr)) {
565+ return true
566+ }
567+ }
568+ return false
403569}
570+
internal/llm/provider/openai.go+241−160
@@ -2,89 +2,65 @@ package provider
22
33import (
44 "context"
5+ "encoding/json"
56 "errors"
7+ "fmt"
8+ "io"
9+ "time"
610
7− "github.com/kujtimiihoxha/termai/internal/llm/models"
11+ "github.com/kujtimiihoxha/termai/internal/config"
812 "github.com/kujtimiihoxha/termai/internal/llm/tools"
13+ "github.com/kujtimiihoxha/termai/internal/logging"
914 "github.com/kujtimiihoxha/termai/internal/message"
1015 "github.com/openai/openai-go"
1116 "github.com/openai/openai-go/option"
1217)
1318
14−type openaiProvider struct {
15− client openai.Client
16− model models.Model
17− maxTokens int64
18− baseURL string
19− apiKey string
20− systemMessage string
19+type openaiOptions struct {
20+ baseURL string
21+ disableCache bool
2122}
2223
23−type OpenAIOption func(*openaiProvider)
24+type OpenAIOption func(*openaiOptions)
2425
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−
34− clientOpts := []option.RequestOption{
35− option.WithAPIKey(provider.apiKey),
36− }
37− if provider.baseURL != "" {
38− clientOpts = append(clientOpts, option.WithBaseURL(provider.baseURL))
39− }
40−
41− provider.client = openai.NewClient(clientOpts...)
42− if provider.systemMessage == "" {
43− return nil, errors.New("system message is required")
44− }
45−
46− return provider, nil
26+type openaiClient struct {
27+ providerOptions providerClientOptions
28+ options openaiOptions
29+ client openai.Client
4730}
4831
49−func WithOpenAISystemMessage(message string) OpenAIOption {
50− return func(p *openaiProvider) {
51− p.systemMessage = message
52− }
53−}
32+type OpenAIClient ProviderClient
5433
55−func WithOpenAIMaxTokens(maxTokens int64) OpenAIOption {
56− return func(p *openaiProvider) {
57− p.maxTokens = maxTokens
34+func newOpenAIClient(opts providerClientOptions) OpenAIClient {
35+ openaiOpts := openaiOptions{}
36+ for _, o := range opts.openaiOptions {
37+ o(&openaiOpts)
5838 }
59−}
6039
61−func WithOpenAIModel(model models.Model) OpenAIOption {
62− return func(p *openaiProvider) {
63− p.model = model
40+ openaiClientOptions := []option.RequestOption{}
41+ if opts.apiKey != "" {
42+ openaiClientOptions = append(openaiClientOptions, option.WithAPIKey(opts.apiKey))
6443 }
65−}
66−
67−func WithOpenAIBaseURL(baseURL string) OpenAIOption {
68− return func(p *openaiProvider) {
69− p.baseURL = baseURL
44+ if openaiOpts.baseURL != "" {
45+ openaiClientOptions = append(openaiClientOptions, option.WithBaseURL(openaiOpts.baseURL))
7046 }
71−}
7247
73−func WithOpenAIKey(apiKey string) OpenAIOption {
74− return func(p *openaiProvider) {
75− p.apiKey = apiKey
48+ client := openai.NewClient(openaiClientOptions...)
49+ return &openaiClient{
50+ providerOptions: opts,
51+ options: openaiOpts,
52+ client: client,
7653 }
7754}
7855
79−func (p *openaiProvider) convertToOpenAIMessages(messages []message.Message) []openai.ChatCompletionMessageParamUnion {
80− var chatMessages []openai.ChatCompletionMessageParamUnion
81−
82− chatMessages = append(chatMessages, openai.SystemMessage(p.systemMessage))
56+func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessages []openai.ChatCompletionMessageParamUnion) {
57+ // Add system message first
58+ openaiMessages = append(openaiMessages, openai.SystemMessage(o.providerOptions.systemMessage))
8359
8460 for _, msg := range messages {
8561 switch msg.Role {
8662 case message.User:
87− chatMessages = append(chatMessages, openai.UserMessage(msg.Content().String()))
63+ openaiMessages = append(openaiMessages, openai.UserMessage(msg.Content().String()))
8864
8965 case message.Assistant:
9066 assistantMsg := openai.ChatCompletionAssistantMessageParam{
@@ -111,23 +87,23 @@ func (p *openaiProvider) convertToOpenAIMessages(messages []message.Message) []o
11187 }
11288 }
11389
114− chatMessages = append(chatMessages, openai.ChatCompletionMessageParamUnion{
90+ openaiMessages = append(openaiMessages, openai.ChatCompletionMessageParamUnion{
11591 OfAssistant: &assistantMsg,
11692 })
11793
11894 case message.Tool:
11995 for _, result := range msg.ToolResults() {
120− chatMessages = append(chatMessages,
96+ openaiMessages = append(openaiMessages,
12197 openai.ToolMessage(result.Content, result.ToolCallID),
12298 )
12399 }
124100 }
125101 }
126102
127− return chatMessages
103+ return
128104}
129105
130−func (p *openaiProvider) convertToOpenAITools(tools []tools.BaseTool) []openai.ChatCompletionToolParam {
106+func (o *openaiClient) convertTools(tools []tools.BaseTool) []openai.ChatCompletionToolParam {
131107 openaiTools := make([]openai.ChatCompletionToolParam, len(tools))
132108
133109 for i, tool := range tools {
@@ -148,133 +124,238 @@ func (p *openaiProvider) convertToOpenAITools(tools []tools.BaseTool) []openai.C
148124 return openaiTools
149125}
150126
151−func (p *openaiProvider) extractTokenUsage(usage openai.CompletionUsage) TokenUsage {
152− cachedTokens := int64(0)
153−
154− cachedTokens = usage.PromptTokensDetails.CachedTokens
155− inputTokens := usage.PromptTokens - cachedTokens
156−
157− return TokenUsage{
158− InputTokens: inputTokens,
159− OutputTokens: usage.CompletionTokens,
160− CacheCreationTokens: 0, // OpenAI doesn't provide this directly
161− CacheReadTokens: cachedTokens,
127+func (o *openaiClient) finishReason(reason string) message.FinishReason {
128+ switch reason {
129+ case "stop":
130+ return message.FinishReasonEndTurn
131+ case "length":
132+ return message.FinishReasonMaxTokens
133+ case "tool_calls":
134+ return message.FinishReasonToolUse
135+ default:
136+ return message.FinishReasonUnknown
162137 }
163138}
164139
165−func (p *openaiProvider) SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
166− messages = cleanupMessages(messages)
167− chatMessages := p.convertToOpenAIMessages(messages)
168− openaiTools := p.convertToOpenAITools(tools)
169−
170− params := openai.ChatCompletionNewParams{
171− Model: openai.ChatModel(p.model.APIModel),
172− Messages: chatMessages,
173− MaxTokens: openai.Int(p.maxTokens),
174− Tools: openaiTools,
175− }
176−
177− response, err := p.client.Chat.Completions.New(ctx, params)
178− if err != nil {
179− return nil, err
140+func (o *openaiClient) preparedParams(messages []openai.ChatCompletionMessageParamUnion, tools []openai.ChatCompletionToolParam) openai.ChatCompletionNewParams {
141+ return openai.ChatCompletionNewParams{
142+ Model: openai.ChatModel(o.providerOptions.model.APIModel),
143+ Messages: messages,
144+ MaxTokens: openai.Int(o.providerOptions.maxTokens),
145+ Tools: tools,
180146 }
147+}
181148
182− content := ""
183− if response.Choices[0].Message.Content != "" {
184− content = response.Choices[0].Message.Content
149+func (o *openaiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) {
150+ params := o.preparedParams(o.convertMessages(messages), o.convertTools(tools))
151+ cfg := config.Get()
152+ if cfg.Debug {
153+ jsonData, _ := json.Marshal(params)
154+ logging.Debug("Prepared messages", "messages", string(jsonData))
185155 }
186−
187− var toolCalls []message.ToolCall
188− if len(response.Choices[0].Message.ToolCalls) > 0 {
189− toolCalls = make([]message.ToolCall, len(response.Choices[0].Message.ToolCalls))
190− for i, call := range response.Choices[0].Message.ToolCalls {
191− toolCalls[i] = message.ToolCall{
192− ID: call.ID,
193− Name: call.Function.Name,
194− Input: call.Function.Arguments,
195− Type: "function",
156+ attempts := 0
157+ for {
158+ attempts++
159+ openaiResponse, err := o.client.Chat.Completions.New(
160+ ctx,
161+ params,
162+ )
163+ // If there is an error we are going to see if we can retry the call
164+ if err != nil {
165+ retry, after, retryErr := o.shouldRetry(attempts, err)
166+ if retryErr != nil {
167+ return nil, retryErr
196168 }
169+ if retry {
170+ logging.WarnPersist("Retrying due to rate limit... attempt %d of %d", logging.PersistTimeArg, time.Millisecond*time.Duration(after+100))
171+ select {
172+ case <-ctx.Done():
173+ return nil, ctx.Err()
174+ case <-time.After(time.Duration(after) * time.Millisecond):
175+ continue
176+ }
177+ }
178+ return nil, retryErr
197179 }
198− }
199180
200− tokenUsage := p.extractTokenUsage(response.Usage)
181+ content := ""
182+ if openaiResponse.Choices[0].Message.Content != "" {
183+ content = openaiResponse.Choices[0].Message.Content
184+ }
201185
202− return &ProviderResponse{
203− Content: content,
204− ToolCalls: toolCalls,
205− Usage: tokenUsage,
206− }, nil
186+ return &ProviderResponse{
187+ Content: content,
188+ ToolCalls: o.toolCalls(*openaiResponse),
189+ Usage: o.usage(*openaiResponse),
190+ FinishReason: o.finishReason(string(openaiResponse.Choices[0].FinishReason)),
191+ }, nil
192+ }
207193}
208194
209−func (p *openaiProvider) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (<-chan ProviderEvent, error) {
210− messages = cleanupMessages(messages)
211− chatMessages := p.convertToOpenAIMessages(messages)
212− openaiTools := p.convertToOpenAITools(tools)
213−
214− params := openai.ChatCompletionNewParams{
215− Model: openai.ChatModel(p.model.APIModel),
216− Messages: chatMessages,
217− MaxTokens: openai.Int(p.maxTokens),
218− Tools: openaiTools,
219− StreamOptions: openai.ChatCompletionStreamOptionsParam{
220− IncludeUsage: openai.Bool(true),
221− },
195+func (o *openaiClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
196+ params := o.preparedParams(o.convertMessages(messages), o.convertTools(tools))
197+ params.StreamOptions = openai.ChatCompletionStreamOptionsParam{
198+ IncludeUsage: openai.Bool(true),
222199 }
223200
224− stream := p.client.Chat.Completions.NewStreaming(ctx, params)
201+ cfg := config.Get()
202+ if cfg.Debug {
203+ jsonData, _ := json.Marshal(params)
204+ logging.Debug("Prepared messages", "messages", string(jsonData))
205+ }
225206
207+ attempts := 0
226208 eventChan := make(chan ProviderEvent)
227209
228− toolCalls := make([]message.ToolCall, 0)
229210 go func() {
230− defer close(eventChan)
231−
232− acc := openai.ChatCompletionAccumulator{}
233− currentContent := ""
234−
235− for stream.Next() {
236− chunk := stream.Current()
237− acc.AddChunk(chunk)
238−
239− if tool, ok := acc.JustFinishedToolCall(); ok {
240− toolCalls = append(toolCalls, message.ToolCall{
241− ID: tool.Id,
242− Name: tool.Name,
243− Input: tool.Arguments,
244− Type: "function",
245− })
246− }
211+ for {
212+ attempts++
213+ openaiStream := o.client.Chat.Completions.NewStreaming(
214+ ctx,
215+ params,
216+ )
217+
218+ acc := openai.ChatCompletionAccumulator{}
219+ currentContent := ""
220+ toolCalls := make([]message.ToolCall, 0)
221+
222+ for openaiStream.Next() {
223+ chunk := openaiStream.Current()
224+ acc.AddChunk(chunk)
225+
226+ if tool, ok := acc.JustFinishedToolCall(); ok {
227+ toolCalls = append(toolCalls, message.ToolCall{
228+ ID: tool.Id,
229+ Name: tool.Name,
230+ Input: tool.Arguments,
231+ Type: "function",
232+ })
233+ }
247234
248− for _, choice := range chunk.Choices {
249− if choice.Delta.Content != "" {
250− eventChan <- ProviderEvent{
251− Type: EventContentDelta,
252− Content: choice.Delta.Content,
235+ for _, choice := range chunk.Choices {
236+ if choice.Delta.Content != "" {
237+ eventChan <- ProviderEvent{
238+ Type: EventContentDelta,
239+ Content: choice.Delta.Content,
240+ }
241+ currentContent += choice.Delta.Content
253242 }
254− currentContent += choice.Delta.Content
255243 }
256244 }
257− }
258245
259− if err := stream.Err(); err != nil {
260− eventChan <- ProviderEvent{
261− Type: EventError,
262− Error: err,
246+ err := openaiStream.Err()
247+ if err == nil || errors.Is(err, io.EOF) {
248+ // Stream completed successfully
249+ eventChan <- ProviderEvent{
250+ Type: EventComplete,
251+ Response: &ProviderResponse{
252+ Content: currentContent,
253+ ToolCalls: toolCalls,
254+ Usage: o.usage(acc.ChatCompletion),
255+ FinishReason: o.finishReason(string(acc.ChatCompletion.Choices[0].FinishReason)),
256+ },
257+ }
258+ close(eventChan)
259+ return
263260 }
261+
262+ // If there is an error we are going to see if we can retry the call
263+ retry, after, retryErr := o.shouldRetry(attempts, err)
264+ if retryErr != nil {
265+ eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
266+ close(eventChan)
267+ return
268+ }
269+ if retry {
270+ logging.WarnPersist("Retrying due to rate limit... attempt %d of %d", logging.PersistTimeArg, time.Millisecond*time.Duration(after+100))
271+ select {
272+ case <-ctx.Done():
273+ // context cancelled
274+ if ctx.Err() == nil {
275+ eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
276+ }
277+ close(eventChan)
278+ return
279+ case <-time.After(time.Duration(after) * time.Millisecond):
280+ continue
281+ }
282+ }
283+ eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
284+ close(eventChan)
264285 return
265286 }
287+ }()
266288
267− tokenUsage := p.extractTokenUsage(acc.Usage)
289+ return eventChan
290+}
268291
269− eventChan <- ProviderEvent{
270− Type: EventComplete,
271− Response: &ProviderResponse{
272− Content: currentContent,
273− ToolCalls: toolCalls,
274− Usage: tokenUsage,
275− },
292+func (o *openaiClient) shouldRetry(attempts int, err error) (bool, int64, error) {
293+ var apierr *openai.Error
294+ if !errors.As(err, &apierr) {
295+ return false, 0, err
296+ }
297+
298+ if apierr.StatusCode != 429 && apierr.StatusCode != 500 {
299+ return false, 0, err
300+ }
301+
302+ if attempts > maxRetries {
303+ return false, 0, fmt.Errorf("maximum retry attempts reached for rate limit: %d retries", maxRetries)
304+ }
305+
306+ retryMs := 0
307+ retryAfterValues := apierr.Response.Header.Values("Retry-After")
308+
309+ backoffMs := 2000 * (1 << (attempts - 1))
310+ jitterMs := int(float64(backoffMs) * 0.2)
311+ retryMs = backoffMs + jitterMs
312+ if len(retryAfterValues) > 0 {
313+ if _, err := fmt.Sscanf(retryAfterValues[0], "%d", &retryMs); err == nil {
314+ retryMs = retryMs * 1000
276315 }
277− }()
316+ }
317+ return true, int64(retryMs), nil
318+}
278319
279− return eventChan, nil
320+func (o *openaiClient) toolCalls(completion openai.ChatCompletion) []message.ToolCall {
321+ var toolCalls []message.ToolCall
322+
323+ if len(completion.Choices) > 0 && len(completion.Choices[0].Message.ToolCalls) > 0 {
324+ for _, call := range completion.Choices[0].Message.ToolCalls {
325+ toolCall := message.ToolCall{
326+ ID: call.ID,
327+ Name: call.Function.Name,
328+ Input: call.Function.Arguments,
329+ Type: "function",
330+ }
331+ toolCalls = append(toolCalls, toolCall)
332+ }
333+ }
334+
335+ return toolCalls
280336}
337+
338+func (o *openaiClient) usage(completion openai.ChatCompletion) TokenUsage {
339+ cachedTokens := completion.Usage.PromptTokensDetails.CachedTokens
340+ inputTokens := completion.Usage.PromptTokens - cachedTokens
341+
342+ return TokenUsage{
343+ InputTokens: inputTokens,
344+ OutputTokens: completion.Usage.CompletionTokens,
345+ CacheCreationTokens: 0, // OpenAI doesn't provide this directly
346+ CacheReadTokens: cachedTokens,
347+ }
348+}
349+
350+func WithOpenAIBaseURL(baseURL string) OpenAIOption {
351+ return func(options *openaiOptions) {
352+ options.baseURL = baseURL
353+ }
354+}
355+
356+func WithOpenAIDisableCache() OpenAIOption {
357+ return func(options *openaiOptions) {
358+ options.disableCache = true
359+ }
360+}
361+
internal/llm/provider/provider.go+131−38
@@ -2,14 +2,17 @@ package provider
22
33import (
44 "context"
5+ "fmt"
56
7+ "github.com/kujtimiihoxha/termai/internal/llm/models"
68 "github.com/kujtimiihoxha/termai/internal/llm/tools"
79 "github.com/kujtimiihoxha/termai/internal/message"
810)
911
10−// EventType represents the type of streaming event
1112type EventType string
1213
14+const maxRetries = 8
15+
1316const (
1417 EventContentStart EventType = "content_start"
1518 EventContentDelta EventType = "content_delta"
@@ -18,7 +21,6 @@ const (
1821 EventComplete EventType = "complete"
1922 EventError EventType = "error"
2023 EventWarning EventType = "warning"
21− EventInfo EventType = "info"
2224)
2325
2426type TokenUsage struct {
@@ -32,61 +34,152 @@ type ProviderResponse struct {
3234 Content string
3335 ToolCalls []message.ToolCall
3436 Usage TokenUsage
35− FinishReason string
37+ FinishReason message.FinishReason
3638}
3739
3840type ProviderEvent struct {
39− Type EventType
41+ Type EventType
42+
4043 Content string
4144 Thinking string
42− ToolCall *message.ToolCall
43− Error error
4445 Response *ProviderResponse
4546
46− // Used for giving users info on e.x retry
47− Info string
47+ Error error
4848}
49−
5049type Provider interface {
5150 SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error)
5251
53− StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (<-chan ProviderEvent, error)
52+ StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent
53+
54+ Model() models.Model
55+}
56+
57+type providerClientOptions struct {
58+ apiKey string
59+ model models.Model
60+ maxTokens int64
61+ systemMessage string
62+
63+ anthropicOptions []AnthropicOption
64+ openaiOptions []OpenAIOption
65+ geminiOptions []GeminiOption
66+ bedrockOptions []BedrockOption
67+}
68+
69+type ProviderClientOption func(*providerClientOptions)
70+
71+type ProviderClient interface {
72+ send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error)
73+ stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent
74+}
75+
76+type baseProvider[C ProviderClient] struct {
77+ options providerClientOptions
78+ client C
79+}
80+
81+func NewProvider(providerName models.ModelProvider, opts ...ProviderClientOption) (Provider, error) {
82+ clientOptions := providerClientOptions{}
83+ for _, o := range opts {
84+ o(&clientOptions)
85+ }
86+ switch providerName {
87+ case models.ProviderAnthropic:
88+ return &baseProvider[AnthropicClient]{
89+ options: clientOptions,
90+ client: newAnthropicClient(clientOptions),
91+ }, nil
92+ case models.ProviderOpenAI:
93+ return &baseProvider[OpenAIClient]{
94+ options: clientOptions,
95+ client: newOpenAIClient(clientOptions),
96+ }, nil
97+ case models.ProviderGemini:
98+ return &baseProvider[GeminiClient]{
99+ options: clientOptions,
100+ client: newGeminiClient(clientOptions),
101+ }, nil
102+ case models.ProviderBedrock:
103+ return &baseProvider[BedrockClient]{
104+ options: clientOptions,
105+ client: newBedrockClient(clientOptions),
106+ }, nil
107+ case models.ProviderMock:
108+ // TODO: implement mock client for test
109+ panic("not implemented")
110+ }
111+ return nil, fmt.Errorf("provider not supported: %s", providerName)
54112}
55113
56−func cleanupMessages(messages []message.Message) []message.Message {
57− // First pass: filter out canceled messages
58− var cleanedMessages []message.Message
114+func (p *baseProvider[C]) cleanMessages(messages []message.Message) (cleaned []message.Message) {
59115 for _, msg := range messages {
60− if msg.FinishReason() != "canceled" || len(msg.ToolCalls()) > 0 {
61− // if there are toolCalls this means we want to return it to the LLM telling it that those tools have been
62− // cancelled
63− cleanedMessages = append(cleanedMessages, msg)
116+ // The message has no content
117+ if len(msg.Parts) == 0 {
118+ continue
64119 }
120+ cleaned = append(cleaned, msg)
65121 }
122+ return
123+}
66124
67− // Second pass: filter out tool messages without a corresponding tool call
68− var result []message.Message
69− toolMessageIDs := make(map[string]bool)
125+func (p *baseProvider[C]) SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
126+ messages = p.cleanMessages(messages)
127+ return p.client.send(ctx, messages, tools)
128+}
70129
71− for _, msg := range cleanedMessages {
72− if msg.Role == message.Assistant {
73− for _, toolCall := range msg.ToolCalls() {
74− toolMessageIDs[toolCall.ID] = true // Mark as referenced
75− }
76− }
130+func (p *baseProvider[C]) Model() models.Model {
131+ return p.options.model
132+}
133+
134+func (p *baseProvider[C]) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
135+ messages = p.cleanMessages(messages)
136+ return p.client.stream(ctx, messages, tools)
137+}
138+
139+func WithAPIKey(apiKey string) ProviderClientOption {
140+ return func(options *providerClientOptions) {
141+ options.apiKey = apiKey
77142 }
143+}
78144
79− // Keep only messages that aren't unreferenced tool messages
80− for _, msg := range cleanedMessages {
81− if msg.Role == message.Tool {
82− for _, toolCall := range msg.ToolResults() {
83− if referenced, exists := toolMessageIDs[toolCall.ToolCallID]; exists && referenced {
84− result = append(result, msg)
85− }
86− }
87− } else {
88− result = append(result, msg)
89− }
145+func WithModel(model models.Model) ProviderClientOption {
146+ return func(options *providerClientOptions) {
147+ options.model = model
148+ }
149+}
150+
151+func WithMaxTokens(maxTokens int64) ProviderClientOption {
152+ return func(options *providerClientOptions) {
153+ options.maxTokens = maxTokens
154+ }
155+}
156+
157+func WithSystemMessage(systemMessage string) ProviderClientOption {
158+ return func(options *providerClientOptions) {
159+ options.systemMessage = systemMessage
160+ }
161+}
162+
163+func WithAnthropicOptions(anthropicOptions ...AnthropicOption) ProviderClientOption {
164+ return func(options *providerClientOptions) {
165+ options.anthropicOptions = anthropicOptions
166+ }
167+}
168+
169+func WithOpenAIOptions(openaiOptions ...OpenAIOption) ProviderClientOption {
170+ return func(options *providerClientOptions) {
171+ options.openaiOptions = openaiOptions
172+ }
173+}
174+
175+func WithGeminiOptions(geminiOptions ...GeminiOption) ProviderClientOption {
176+ return func(options *providerClientOptions) {
177+ options.geminiOptions = geminiOptions
178+ }
179+}
180+
181+func WithBedrockOptions(bedrockOptions ...BedrockOption) ProviderClientOption {
182+ return func(options *providerClientOptions) {
183+ options.bedrockOptions = bedrockOptions
90184 }
91− return result
92185}
internal/llm/tools/bash.go+4−3
@@ -23,7 +23,8 @@ type BashPermissionsParams struct {
2323}
2424
2525type BashResponseMetadata struct {
26− Took int64 `json:"took"`
26+ StartTime int64 `json:"start_time"`
27+ EndTime int64 `json:"end_time"`
2728}
2829type bashTool struct {
2930 permissions permission.Service
@@ -282,7 +283,6 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
282283 if err != nil {
283284 return ToolResponse{}, fmt.Errorf("error executing command: %w", err)
284285 }
285− took := time.Since(startTime).Milliseconds()
286286
287287 stdout = truncateOutput(stdout)
288288 stderr = truncateOutput(stderr)
@@ -311,7 +311,8 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
311311 }
312312
313313 metadata := BashResponseMetadata{
314− Took: took,
314+ StartTime: startTime.UnixMilli(),
315+ EndTime: time.Now().UnixMilli(),
315316 }
316317 if stdout == "" {
317318 return WithResponseMetadata(NewTextResponse("no output"), metadata), nil
internal/llm/tools/bash_test.go−31
@@ -8,8 +8,6 @@ import (
88 "testing"
99 "time"
1010
11− "github.com/kujtimiihoxha/termai/internal/permission"
12− "github.com/kujtimiihoxha/termai/internal/pubsub"
1311 "github.com/stretchr/testify/assert"
1412 "github.com/stretchr/testify/require"
1513)
@@ -340,32 +338,3 @@ func TestCountLines(t *testing.T) {
340338 })
341339 }
342340}
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/edit.go+72−3
@@ -11,6 +11,7 @@ import (
1111
1212 "github.com/kujtimiihoxha/termai/internal/config"
1313 "github.com/kujtimiihoxha/termai/internal/diff"
14+ "github.com/kujtimiihoxha/termai/internal/history"
1415 "github.com/kujtimiihoxha/termai/internal/lsp"
1516 "github.com/kujtimiihoxha/termai/internal/permission"
1617)
@@ -35,6 +36,7 @@ type EditResponseMetadata struct {
3536type editTool struct {
3637 lspClients map[string]*lsp.Client
3738 permissions permission.Service
39+ files history.Service
3840}
3941
4042const (
@@ -88,10 +90,11 @@ When making edits:
8890Remember: 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.`
8991)
9092
91−func NewEditTool(lspClients map[string]*lsp.Client, permissions permission.Service) BaseTool {
93+func NewEditTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service) BaseTool {
9294 return &editTool{
9395 lspClients: lspClients,
9496 permissions: permissions,
97+ files: files,
9598 }
9699}
97100
@@ -153,6 +156,11 @@ func (e *editTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
153156 if err != nil {
154157 return response, nil
155158 }
159+ if response.IsError {
160+ // Return early if there was an error during content replacement
161+ // This prevents unnecessary LSP diagnostics processing
162+ return response, nil
163+ }
156164
157165 waitForLspDiagnostics(ctx, params.FilePath, e.lspClients)
158166 text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
@@ -208,6 +216,20 @@ func (e *editTool) createNewFile(ctx context.Context, filePath, content string)
208216 return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
209217 }
210218
219+ // File can't be in the history so we create a new file history
220+ _, err = e.files.Create(ctx, sessionID, filePath, "")
221+ if err != nil {
222+ // Log error but don't fail the operation
223+ return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
224+ }
225+
226+ // Add the new content to the file history
227+ _, err = e.files.CreateVersion(ctx, sessionID, filePath, content)
228+ if err != nil {
229+ // Log error but don't fail the operation
230+ fmt.Printf("Error creating file history version: %v\n", err)
231+ }
232+
211233 recordFileWrite(filePath)
212234 recordFileRead(filePath)
213235
@@ -298,6 +320,29 @@ func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string
298320 if err != nil {
299321 return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
300322 }
323+
324+ // Check if file exists in history
325+ file, err := e.files.GetByPathAndSession(ctx, filePath, sessionID)
326+ if err != nil {
327+ _, err = e.files.Create(ctx, sessionID, filePath, oldContent)
328+ if err != nil {
329+ // Log error but don't fail the operation
330+ return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
331+ }
332+ }
333+ if file.Content != oldContent {
334+ // User Manually changed the content store an intermediate version
335+ _, err = e.files.CreateVersion(ctx, sessionID, filePath, oldContent)
336+ if err != nil {
337+ fmt.Printf("Error creating file history version: %v\n", err)
338+ }
339+ }
340+ // Store the new version
341+ _, err = e.files.CreateVersion(ctx, sessionID, filePath, "")
342+ if err != nil {
343+ fmt.Printf("Error creating file history version: %v\n", err)
344+ }
345+
301346 recordFileWrite(filePath)
302347 recordFileRead(filePath)
303348
@@ -356,6 +401,9 @@ func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newS
356401
357402 newContent := oldContent[:index] + newString + oldContent[index+len(oldString):]
358403
404+ if oldContent == newContent {
405+ return NewTextErrorResponse("new content is the same as old content. No changes made."), nil
406+ }
359407 sessionID, messageID := GetContextValues(ctx)
360408
361409 if sessionID == "" || messageID == "" {
@@ -374,8 +422,7 @@ func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newS
374422 Description: fmt.Sprintf("Replace content in file %s", filePath),
375423 Params: EditPermissionsParams{
376424 FilePath: filePath,
377−
378− Diff: diff,
425+ Diff: diff,
379426 },
380427 },
381428 )
@@ -388,6 +435,28 @@ func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newS
388435 return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
389436 }
390437
438+ // Check if file exists in history
439+ file, err := e.files.GetByPathAndSession(ctx, filePath, sessionID)
440+ if err != nil {
441+ _, err = e.files.Create(ctx, sessionID, filePath, oldContent)
442+ if err != nil {
443+ // Log error but don't fail the operation
444+ return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
445+ }
446+ }
447+ if file.Content != oldContent {
448+ // User Manually changed the content store an intermediate version
449+ _, err = e.files.CreateVersion(ctx, sessionID, filePath, oldContent)
450+ if err != nil {
451+ fmt.Printf("Error creating file history version: %v\n", err)
452+ }
453+ }
454+ // Store the new version
455+ _, err = e.files.CreateVersion(ctx, sessionID, filePath, newContent)
456+ if err != nil {
457+ fmt.Printf("Error creating file history version: %v\n", err)
458+ }
459+
391460 recordFileWrite(filePath)
392461 recordFileRead(filePath)
393462
internal/llm/tools/edit_test.go+15−15
@@ -14,7 +14,7 @@ import (
1414)
1515
1616func TestEditTool_Info(t *testing.T) {
17− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
17+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
1818 info := tool.Info()
1919
2020 assert.Equal(t, EditToolName, info.Name)
@@ -34,7 +34,7 @@ func TestEditTool_Run(t *testing.T) {
3434 defer os.RemoveAll(tempDir)
3535
3636 t.Run("creates a new file successfully", func(t *testing.T) {
37− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
37+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
3838
3939 filePath := filepath.Join(tempDir, "new_file.txt")
4040 content := "This is a test content"
@@ -64,7 +64,7 @@ func TestEditTool_Run(t *testing.T) {
6464 })
6565
6666 t.Run("creates file with nested directories", func(t *testing.T) {
67− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
67+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
6868
6969 filePath := filepath.Join(tempDir, "nested/dirs/new_file.txt")
7070 content := "Content in nested directory"
@@ -94,7 +94,7 @@ func TestEditTool_Run(t *testing.T) {
9494 })
9595
9696 t.Run("fails to create file that already exists", func(t *testing.T) {
97− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
97+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
9898
9999 // Create a file first
100100 filePath := filepath.Join(tempDir, "existing_file.txt")
@@ -123,7 +123,7 @@ func TestEditTool_Run(t *testing.T) {
123123 })
124124
125125 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))
126+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
127127
128128 // Create a directory
129129 dirPath := filepath.Join(tempDir, "test_dir")
@@ -151,7 +151,7 @@ func TestEditTool_Run(t *testing.T) {
151151 })
152152
153153 t.Run("replaces content successfully", func(t *testing.T) {
154− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
154+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
155155
156156 // Create a file first
157157 filePath := filepath.Join(tempDir, "replace_content.txt")
@@ -191,7 +191,7 @@ func TestEditTool_Run(t *testing.T) {
191191 })
192192
193193 t.Run("deletes content successfully", func(t *testing.T) {
194− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
194+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
195195
196196 // Create a file first
197197 filePath := filepath.Join(tempDir, "delete_content.txt")
@@ -230,7 +230,7 @@ func TestEditTool_Run(t *testing.T) {
230230 })
231231
232232 t.Run("handles invalid parameters", func(t *testing.T) {
233− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
233+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
234234
235235 call := ToolCall{
236236 Name: EditToolName,
@@ -243,7 +243,7 @@ func TestEditTool_Run(t *testing.T) {
243243 })
244244
245245 t.Run("handles missing file_path", func(t *testing.T) {
246− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
246+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
247247
248248 params := EditParams{
249249 FilePath: "",
@@ -265,7 +265,7 @@ func TestEditTool_Run(t *testing.T) {
265265 })
266266
267267 t.Run("handles file not found", func(t *testing.T) {
268− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
268+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
269269
270270 filePath := filepath.Join(tempDir, "non_existent_file.txt")
271271 params := EditParams{
@@ -288,7 +288,7 @@ func TestEditTool_Run(t *testing.T) {
288288 })
289289
290290 t.Run("handles old_string not found in file", func(t *testing.T) {
291− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
291+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
292292
293293 // Create a file first
294294 filePath := filepath.Join(tempDir, "content_not_found.txt")
@@ -320,7 +320,7 @@ func TestEditTool_Run(t *testing.T) {
320320 })
321321
322322 t.Run("handles multiple occurrences of old_string", func(t *testing.T) {
323− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
323+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
324324
325325 // Create a file with duplicate content
326326 filePath := filepath.Join(tempDir, "duplicate_content.txt")
@@ -352,7 +352,7 @@ func TestEditTool_Run(t *testing.T) {
352352 })
353353
354354 t.Run("handles file modified since last read", func(t *testing.T) {
355− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
355+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
356356
357357 // Create a file
358358 filePath := filepath.Join(tempDir, "modified_file.txt")
@@ -394,7 +394,7 @@ func TestEditTool_Run(t *testing.T) {
394394 })
395395
396396 t.Run("handles file not read before editing", func(t *testing.T) {
397− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
397+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
398398
399399 // Create a file
400400 filePath := filepath.Join(tempDir, "not_read_file.txt")
@@ -423,7 +423,7 @@ func TestEditTool_Run(t *testing.T) {
423423 })
424424
425425 t.Run("handles permission denied", func(t *testing.T) {
426− tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(false))
426+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(false), newMockFileHistoryService())
427427
428428 // Create a file
429429 filePath := filepath.Join(tempDir, "permission_denied.txt")
internal/llm/tools/file.go−10
@@ -3,8 +3,6 @@ package tools
33import (
44 "sync"
55 "time"
6−
7− "github.com/kujtimiihoxha/termai/internal/config"
86)
97
108// File record to track when files were read/written
@@ -19,14 +17,6 @@ var (
1917 fileRecordMutex sync.RWMutex
2018)
2119
22−func removeWorkingDirectoryPrefix(path string) string {
23− wd := config.WorkingDirectory()
24− if len(path) > len(wd) && path[:len(wd)] == wd {
25− return path[len(wd)+1:]
26− }
27− return path
28−}
29−
3020func recordFileRead(path string) {
3121 fileRecordMutex.Lock()
3222 defer fileRecordMutex.Unlock()
internal/llm/tools/glob.go+2−2
@@ -63,7 +63,7 @@ type GlobParams struct {
6363 Path string `json:"path"`
6464}
6565
66−type GlobMetadata struct {
66+type GlobResponseMetadata struct {
6767 NumberOfFiles int `json:"number_of_files"`
6868 Truncated bool `json:"truncated"`
6969}
@@ -124,7 +124,7 @@ func (g *globTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
124124
125125 return WithResponseMetadata(
126126 NewTextResponse(output),
127− GlobMetadata{
127+ GlobResponseMetadata{
128128 NumberOfFiles: len(files),
129129 Truncated: truncated,
130130 },
internal/llm/tools/grep.go+2−2
@@ -27,7 +27,7 @@ type grepMatch struct {
2727 modTime time.Time
2828}
2929
30−type GrepMetadata struct {
30+type GrepResponseMetadata struct {
3131 NumberOfMatches int `json:"number_of_matches"`
3232 Truncated bool `json:"truncated"`
3333}
@@ -134,7 +134,7 @@ func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
134134
135135 return WithResponseMetadata(
136136 NewTextResponse(output),
137− GrepMetadata{
137+ GrepResponseMetadata{
138138 NumberOfMatches: len(matches),
139139 Truncated: truncated,
140140 },
internal/llm/tools/ls.go+2−2
@@ -23,7 +23,7 @@ type TreeNode struct {
2323 Children []*TreeNode `json:"children,omitempty"`
2424}
2525
26−type LSMetadata struct {
26+type LSResponseMetadata struct {
2727 NumberOfFiles int `json:"number_of_files"`
2828 Truncated bool `json:"truncated"`
2929}
@@ -121,7 +121,7 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
121121
122122 return WithResponseMetadata(
123123 NewTextResponse(output),
124− LSMetadata{
124+ LSResponseMetadata{
125125 NumberOfFiles: len(files),
126126 Truncated: truncated,
127127 },
internal/llm/tools/mocks_test.gonew file+246
@@ -0,0 +1,246 @@
1+package tools
2+
3+import (
4+ "context"
5+ "fmt"
6+ "sort"
7+ "strconv"
8+ "strings"
9+ "time"
10+
11+ "github.com/google/uuid"
12+ "github.com/kujtimiihoxha/termai/internal/history"
13+ "github.com/kujtimiihoxha/termai/internal/permission"
14+ "github.com/kujtimiihoxha/termai/internal/pubsub"
15+)
16+
17+// Mock permission service for testing
18+type mockPermissionService struct {
19+ *pubsub.Broker[permission.PermissionRequest]
20+ allow bool
21+}
22+
23+func (m *mockPermissionService) GrantPersistant(permission permission.PermissionRequest) {
24+ // Not needed for tests
25+}
26+
27+func (m *mockPermissionService) Grant(permission permission.PermissionRequest) {
28+ // Not needed for tests
29+}
30+
31+func (m *mockPermissionService) Deny(permission permission.PermissionRequest) {
32+ // Not needed for tests
33+}
34+
35+func (m *mockPermissionService) Request(opts permission.CreatePermissionRequest) bool {
36+ return m.allow
37+}
38+
39+func newMockPermissionService(allow bool) permission.Service {
40+ return &mockPermissionService{
41+ Broker: pubsub.NewBroker[permission.PermissionRequest](),
42+ allow: allow,
43+ }
44+}
45+
46+type mockFileHistoryService struct {
47+ *pubsub.Broker[history.File]
48+ files map[string]history.File // ID -> File
49+ timeNow func() int64
50+}
51+
52+// Create implements history.Service.
53+func (m *mockFileHistoryService) Create(ctx context.Context, sessionID string, path string, content string) (history.File, error) {
54+ return m.createWithVersion(ctx, sessionID, path, content, history.InitialVersion)
55+}
56+
57+// CreateVersion implements history.Service.
58+func (m *mockFileHistoryService) CreateVersion(ctx context.Context, sessionID string, path string, content string) (history.File, error) {
59+ var files []history.File
60+ for _, file := range m.files {
61+ if file.Path == path {
62+ files = append(files, file)
63+ }
64+ }
65+
66+ if len(files) == 0 {
67+ // No previous versions, create initial
68+ return m.Create(ctx, sessionID, path, content)
69+ }
70+
71+ // Sort files by CreatedAt in descending order
72+ sort.Slice(files, func(i, j int) bool {
73+ return files[i].CreatedAt > files[j].CreatedAt
74+ })
75+
76+ // Get the latest version
77+ latestFile := files[0]
78+ latestVersion := latestFile.Version
79+
80+ // Generate the next version
81+ var nextVersion string
82+ if latestVersion == history.InitialVersion {
83+ nextVersion = "v1"
84+ } else if strings.HasPrefix(latestVersion, "v") {
85+ versionNum, err := strconv.Atoi(latestVersion[1:])
86+ if err != nil {
87+ // If we can't parse the version, just use a timestamp-based version
88+ nextVersion = fmt.Sprintf("v%d", latestFile.CreatedAt)
89+ } else {
90+ nextVersion = fmt.Sprintf("v%d", versionNum+1)
91+ }
92+ } else {
93+ // If the version format is unexpected, use a timestamp-based version
94+ nextVersion = fmt.Sprintf("v%d", latestFile.CreatedAt)
95+ }
96+
97+ return m.createWithVersion(ctx, sessionID, path, content, nextVersion)
98+}
99+
100+func (m *mockFileHistoryService) createWithVersion(_ context.Context, sessionID, path, content, version string) (history.File, error) {
101+ now := m.timeNow()
102+ file := history.File{
103+ ID: uuid.New().String(),
104+ SessionID: sessionID,
105+ Path: path,
106+ Content: content,
107+ Version: version,
108+ CreatedAt: now,
109+ UpdatedAt: now,
110+ }
111+
112+ m.files[file.ID] = file
113+ m.Publish(pubsub.CreatedEvent, file)
114+ return file, nil
115+}
116+
117+// Delete implements history.Service.
118+func (m *mockFileHistoryService) Delete(ctx context.Context, id string) error {
119+ file, ok := m.files[id]
120+ if !ok {
121+ return fmt.Errorf("file not found: %s", id)
122+ }
123+
124+ delete(m.files, id)
125+ m.Publish(pubsub.DeletedEvent, file)
126+ return nil
127+}
128+
129+// DeleteSessionFiles implements history.Service.
130+func (m *mockFileHistoryService) DeleteSessionFiles(ctx context.Context, sessionID string) error {
131+ files, err := m.ListBySession(ctx, sessionID)
132+ if err != nil {
133+ return err
134+ }
135+
136+ for _, file := range files {
137+ err = m.Delete(ctx, file.ID)
138+ if err != nil {
139+ return err
140+ }
141+ }
142+
143+ return nil
144+}
145+
146+// Get implements history.Service.
147+func (m *mockFileHistoryService) Get(ctx context.Context, id string) (history.File, error) {
148+ file, ok := m.files[id]
149+ if !ok {
150+ return history.File{}, fmt.Errorf("file not found: %s", id)
151+ }
152+ return file, nil
153+}
154+
155+// GetByPathAndSession implements history.Service.
156+func (m *mockFileHistoryService) GetByPathAndSession(ctx context.Context, path string, sessionID string) (history.File, error) {
157+ var latestFile history.File
158+ var found bool
159+ var latestTime int64
160+
161+ for _, file := range m.files {
162+ if file.Path == path && file.SessionID == sessionID {
163+ if !found || file.CreatedAt > latestTime {
164+ latestFile = file
165+ latestTime = file.CreatedAt
166+ found = true
167+ }
168+ }
169+ }
170+
171+ if !found {
172+ return history.File{}, fmt.Errorf("file not found: %s for session %s", path, sessionID)
173+ }
174+ return latestFile, nil
175+}
176+
177+// ListBySession implements history.Service.
178+func (m *mockFileHistoryService) ListBySession(ctx context.Context, sessionID string) ([]history.File, error) {
179+ var files []history.File
180+ for _, file := range m.files {
181+ if file.SessionID == sessionID {
182+ files = append(files, file)
183+ }
184+ }
185+
186+ // Sort by CreatedAt in descending order
187+ sort.Slice(files, func(i, j int) bool {
188+ return files[i].CreatedAt > files[j].CreatedAt
189+ })
190+
191+ return files, nil
192+}
193+
194+// ListLatestSessionFiles implements history.Service.
195+func (m *mockFileHistoryService) ListLatestSessionFiles(ctx context.Context, sessionID string) ([]history.File, error) {
196+ // Map to track the latest file for each path
197+ latestFiles := make(map[string]history.File)
198+
199+ for _, file := range m.files {
200+ if file.SessionID == sessionID {
201+ existing, ok := latestFiles[file.Path]
202+ if !ok || file.CreatedAt > existing.CreatedAt {
203+ latestFiles[file.Path] = file
204+ }
205+ }
206+ }
207+
208+ // Convert map to slice
209+ var result []history.File
210+ for _, file := range latestFiles {
211+ result = append(result, file)
212+ }
213+
214+ // Sort by CreatedAt in descending order
215+ sort.Slice(result, func(i, j int) bool {
216+ return result[i].CreatedAt > result[j].CreatedAt
217+ })
218+
219+ return result, nil
220+}
221+
222+// Subscribe implements history.Service.
223+func (m *mockFileHistoryService) Subscribe(ctx context.Context) <-chan pubsub.Event[history.File] {
224+ return m.Broker.Subscribe(ctx)
225+}
226+
227+// Update implements history.Service.
228+func (m *mockFileHistoryService) Update(ctx context.Context, file history.File) (history.File, error) {
229+ _, ok := m.files[file.ID]
230+ if !ok {
231+ return history.File{}, fmt.Errorf("file not found: %s", file.ID)
232+ }
233+
234+ file.UpdatedAt = m.timeNow()
235+ m.files[file.ID] = file
236+ m.Publish(pubsub.UpdatedEvent, file)
237+ return file, nil
238+}
239+
240+func newMockFileHistoryService() history.Service {
241+ return &mockFileHistoryService{
242+ Broker: pubsub.NewBroker[history.File](),
243+ files: make(map[string]history.File),
244+ timeNow: func() int64 { return time.Now().Unix() },
245+ }
246+}
internal/llm/tools/shell/shell.go+11−1
@@ -83,11 +83,21 @@ func newPersistentShell(cwd string) *PersistentShell {
8383 commandQueue: make(chan *commandExecution, 10),
8484 }
8585
86− go shell.processCommands()
86+ go func() {
87+ defer func() {
88+ if r := recover(); r != nil {
89+ fmt.Fprintf(os.Stderr, "Panic in shell command processor: %v\n", r)
90+ shell.isAlive = false
91+ close(shell.commandQueue)
92+ }
93+ }()
94+ shell.processCommands()
95+ }()
8796
8897 go func() {
8998 err := cmd.Wait()
9099 if err != nil {
100+ // Log the error if needed
91101 }
92102 shell.isAlive = false
93103 close(shell.commandQueue)
internal/llm/tools/sourcegraph.go+1−1
@@ -18,7 +18,7 @@ type SourcegraphParams struct {
1818 Timeout int `json:"timeout,omitempty"`
1919}
2020
21−type SourcegraphMetadata struct {
21+type SourcegraphResponseMetadata struct {
2222 NumberOfMatches int `json:"number_of_matches"`
2323 Truncated bool `json:"truncated"`
2424}
internal/llm/tools/tools.go+7−2
@@ -14,12 +14,17 @@ type ToolInfo struct {
1414
1515type toolResponseType string
1616
17+type (
18+ sessionIDContextKey string
19+ messageIDContextKey string
20+)
21+
1722const (
1823 ToolResponseTypeText toolResponseType = "text"
1924 ToolResponseTypeImage toolResponseType = "image"
2025
21− SessionIDContextKey = "session_id"
22− MessageIDContextKey = "message_id"
26+ SessionIDContextKey sessionIDContextKey = "session_id"
27+ MessageIDContextKey messageIDContextKey = "message_id"
2328)
2429
2530type ToolResponse struct {
internal/llm/tools/write.go+26−1
@@ -10,6 +10,7 @@ import (
1010
1111 "github.com/kujtimiihoxha/termai/internal/config"
1212 "github.com/kujtimiihoxha/termai/internal/diff"
13+ "github.com/kujtimiihoxha/termai/internal/history"
1314 "github.com/kujtimiihoxha/termai/internal/lsp"
1415 "github.com/kujtimiihoxha/termai/internal/permission"
1516)
@@ -27,6 +28,7 @@ type WritePermissionsParams struct {
2728type writeTool struct {
2829 lspClients map[string]*lsp.Client
2930 permissions permission.Service
31+ files history.Service
3032}
3133
3234type WriteResponseMetadata struct {
@@ -67,10 +69,11 @@ TIPS:
6769- Always include descriptive comments when making changes to existing code`
6870)
6971
70−func NewWriteTool(lspClients map[string]*lsp.Client, permissions permission.Service) BaseTool {
72+func NewWriteTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service) BaseTool {
7173 return &writeTool{
7274 lspClients: lspClients,
7375 permissions: permissions,
76+ files: files,
7477 }
7578}
7679
@@ -176,6 +179,28 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
176179 return ToolResponse{}, fmt.Errorf("error writing file: %w", err)
177180 }
178181
182+ // Check if file exists in history
183+ file, err := w.files.GetByPathAndSession(ctx, filePath, sessionID)
184+ if err != nil {
185+ _, err = w.files.Create(ctx, sessionID, filePath, oldContent)
186+ if err != nil {
187+ // Log error but don't fail the operation
188+ return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
189+ }
190+ }
191+ if file.Content != oldContent {
192+ // User Manually changed the content store an intermediate version
193+ _, err = w.files.CreateVersion(ctx, sessionID, filePath, oldContent)
194+ if err != nil {
195+ fmt.Printf("Error creating file history version: %v\n", err)
196+ }
197+ }
198+ // Store the new version
199+ _, err = w.files.CreateVersion(ctx, sessionID, filePath, params.Content)
200+ if err != nil {
201+ fmt.Printf("Error creating file history version: %v\n", err)
202+ }
203+
179204 recordFileWrite(filePath)
180205 recordFileRead(filePath)
181206 waitForLspDiagnostics(ctx, filePath, w.lspClients)
internal/llm/tools/write_test.go+11−11
@@ -14,7 +14,7 @@ import (
1414)
1515
1616func TestWriteTool_Info(t *testing.T) {
17− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
17+ tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
1818 info := tool.Info()
1919
2020 assert.Equal(t, WriteToolName, info.Name)
@@ -32,7 +32,7 @@ func TestWriteTool_Run(t *testing.T) {
3232 defer os.RemoveAll(tempDir)
3333
3434 t.Run("creates a new file successfully", func(t *testing.T) {
35− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
35+ tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
3636
3737 filePath := filepath.Join(tempDir, "new_file.txt")
3838 content := "This is a test content"
@@ -61,7 +61,7 @@ func TestWriteTool_Run(t *testing.T) {
6161 })
6262
6363 t.Run("creates file with nested directories", func(t *testing.T) {
64− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
64+ tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
6565
6666 filePath := filepath.Join(tempDir, "nested/dirs/new_file.txt")
6767 content := "Content in nested directory"
@@ -90,7 +90,7 @@ func TestWriteTool_Run(t *testing.T) {
9090 })
9191
9292 t.Run("updates existing file", func(t *testing.T) {
93− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
93+ tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
9494
9595 // Create a file first
9696 filePath := filepath.Join(tempDir, "existing_file.txt")
@@ -127,7 +127,7 @@ func TestWriteTool_Run(t *testing.T) {
127127 })
128128
129129 t.Run("handles invalid parameters", func(t *testing.T) {
130− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
130+ tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
131131
132132 call := ToolCall{
133133 Name: WriteToolName,
@@ -140,7 +140,7 @@ func TestWriteTool_Run(t *testing.T) {
140140 })
141141
142142 t.Run("handles missing file_path", func(t *testing.T) {
143− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
143+ tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
144144
145145 params := WriteParams{
146146 FilePath: "",
@@ -161,7 +161,7 @@ func TestWriteTool_Run(t *testing.T) {
161161 })
162162
163163 t.Run("handles missing content", func(t *testing.T) {
164− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
164+ tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
165165
166166 params := WriteParams{
167167 FilePath: filepath.Join(tempDir, "file.txt"),
@@ -182,7 +182,7 @@ func TestWriteTool_Run(t *testing.T) {
182182 })
183183
184184 t.Run("handles writing to a directory path", func(t *testing.T) {
185− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
185+ tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
186186
187187 // Create a directory
188188 dirPath := filepath.Join(tempDir, "test_dir")
@@ -208,7 +208,7 @@ func TestWriteTool_Run(t *testing.T) {
208208 })
209209
210210 t.Run("handles permission denied", func(t *testing.T) {
211− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(false))
211+ tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(false), newMockFileHistoryService())
212212
213213 filePath := filepath.Join(tempDir, "permission_denied.txt")
214214 params := WriteParams{
@@ -234,7 +234,7 @@ func TestWriteTool_Run(t *testing.T) {
234234 })
235235
236236 t.Run("detects file modified since last read", func(t *testing.T) {
237− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
237+ tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
238238
239239 // Create a file
240240 filePath := filepath.Join(tempDir, "modified_file.txt")
@@ -275,7 +275,7 @@ func TestWriteTool_Run(t *testing.T) {
275275 })
276276
277277 t.Run("skips writing when content is identical", func(t *testing.T) {
278− tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
278+ tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
279279
280280 // Create a file
281281 filePath := filepath.Join(tempDir, "identical_content.txt")
internal/logging/logger.go+40−1
@@ -1,6 +1,12 @@
11package logging
22
3−import "log/slog"
3+import (
4+ "fmt"
5+ "log/slog"
6+ "os"
7+ "runtime/debug"
8+ "time"
9+)
410
511func Info(msg string, args ...any) {
612 slog.Info(msg, args...)
@@ -37,3 +43,36 @@ func ErrorPersist(msg string, args ...any) {
3743 args = append(args, persistKeyArg, true)
3844 slog.Error(msg, args...)
3945}
46+
47+// RecoverPanic is a common function to handle panics gracefully.
48+// It logs the error, creates a panic log file with stack trace,
49+// and executes an optional cleanup function before returning.
50+func RecoverPanic(name string, cleanup func()) {
51+ if r := recover(); r != nil {
52+ // Log the panic
53+ ErrorPersist(fmt.Sprintf("Panic in %s: %v", name, r))
54+
55+ // Create a timestamped panic log file
56+ timestamp := time.Now().Format("20060102-150405")
57+ filename := fmt.Sprintf("opencode-panic-%s-%s.log", name, timestamp)
58+
59+ file, err := os.Create(filename)
60+ if err != nil {
61+ ErrorPersist(fmt.Sprintf("Failed to create panic log: %v", err))
62+ } else {
63+ defer file.Close()
64+
65+ // Write panic information and stack trace
66+ fmt.Fprintf(file, "Panic in %s: %v\n\n", name, r)
67+ fmt.Fprintf(file, "Time: %s\n\n", time.Now().Format(time.RFC3339))
68+ fmt.Fprintf(file, "Stack Trace:\n%s\n", debug.Stack())
69+
70+ InfoPersist(fmt.Sprintf("Panic details written to %s", filename))
71+ }
72+
73+ // Execute cleanup function if provided
74+ if cleanup != nil {
75+ cleanup()
76+ }
77+ }
78+}
internal/lsp/client.go+9−4
@@ -97,7 +97,12 @@ func NewClient(ctx context.Context, command string, args ...string) (*Client, er
9797 }()
9898
9999 // Start message handling loop
100− go client.handleMessages()
100+ go func() {
101+ defer logging.RecoverPanic("LSP-message-handler", func() {
102+ logging.ErrorPersist("LSP message handler crashed, LSP functionality may be impaired")
103+ })
104+ client.handleMessages()
105+ }()
101106
102107 return client, nil
103108}
@@ -374,7 +379,7 @@ func (c *Client) CloseFile(ctx context.Context, filepath string) error {
374379 },
375380 }
376381
377− if cnf.Debug {
382+ if cnf.DebugLSP {
378383 logging.Debug("Closing file", "file", filepath)
379384 }
380385 if err := c.Notify(ctx, "textDocument/didClose", params); err != nil {
@@ -413,12 +418,12 @@ func (c *Client) CloseAllFiles(ctx context.Context) {
413418 // Then close them all
414419 for _, filePath := range filesToClose {
415420 err := c.CloseFile(ctx, filePath)
416− if err != nil && cnf.Debug {
421+ if err != nil && cnf.DebugLSP {
417422 logging.Warn("Error closing file", "file", filePath, "error", err)
418423 }
419424 }
420425
421− if cnf.Debug {
426+ if cnf.DebugLSP {
422427 logging.Debug("Closed all files", "files", filesToClose)
423428 }
424429}
internal/lsp/handlers.go+1−1
@@ -88,7 +88,7 @@ func HandleServerMessage(params json.RawMessage) {
8888 Message string `json:"message"`
8989 }
9090 if err := json.Unmarshal(params, &msg); err == nil {
91− if cnf.Debug {
91+ if cnf.DebugLSP {
9292 logging.Debug("Server message", "type", msg.Type, "message", msg.Message)
9393 }
9494 }
internal/lsp/transport.go+14−14
@@ -20,7 +20,7 @@ func WriteMessage(w io.Writer, msg *Message) error {
2020 }
2121 cnf := config.Get()
2222
23− if cnf.Debug {
23+ if cnf.DebugLSP {
2424 logging.Debug("Sending message to server", "method", msg.Method, "id", msg.ID)
2525 }
2626
@@ -49,7 +49,7 @@ func ReadMessage(r *bufio.Reader) (*Message, error) {
4949 }
5050 line = strings.TrimSpace(line)
5151
52− if cnf.Debug {
52+ if cnf.DebugLSP {
5353 logging.Debug("Received header", "line", line)
5454 }
5555
@@ -65,7 +65,7 @@ func ReadMessage(r *bufio.Reader) (*Message, error) {
6565 }
6666 }
6767
68− if cnf.Debug {
68+ if cnf.DebugLSP {
6969 logging.Debug("Content-Length", "length", contentLength)
7070 }
7171
@@ -76,7 +76,7 @@ func ReadMessage(r *bufio.Reader) (*Message, error) {
7676 return nil, fmt.Errorf("failed to read content: %w", err)
7777 }
7878
79− if cnf.Debug {
79+ if cnf.DebugLSP {
8080 logging.Debug("Received content", "content", string(content))
8181 }
8282
@@ -95,7 +95,7 @@ func (c *Client) handleMessages() {
9595 for {
9696 msg, err := ReadMessage(c.stdout)
9797 if err != nil {
98− if cnf.Debug {
98+ if cnf.DebugLSP {
9999 logging.Error("Error reading message", "error", err)
100100 }
101101 return
@@ -103,7 +103,7 @@ func (c *Client) handleMessages() {
103103
104104 // Handle server->client request (has both Method and ID)
105105 if msg.Method != "" && msg.ID != 0 {
106− if cnf.Debug {
106+ if cnf.DebugLSP {
107107 logging.Debug("Received request from server", "method", msg.Method, "id", msg.ID)
108108 }
109109
@@ -157,11 +157,11 @@ func (c *Client) handleMessages() {
157157 c.notificationMu.RUnlock()
158158
159159 if ok {
160− if cnf.Debug {
160+ if cnf.DebugLSP {
161161 logging.Debug("Handling notification", "method", msg.Method)
162162 }
163163 go handler(msg.Params)
164− } else if cnf.Debug {
164+ } else if cnf.DebugLSP {
165165 logging.Debug("No handler for notification", "method", msg.Method)
166166 }
167167 continue
@@ -174,12 +174,12 @@ func (c *Client) handleMessages() {
174174 c.handlersMu.RUnlock()
175175
176176 if ok {
177− if cnf.Debug {
177+ if cnf.DebugLSP {
178178 logging.Debug("Received response for request", "id", msg.ID)
179179 }
180180 ch <- msg
181181 close(ch)
182− } else if cnf.Debug {
182+ } else if cnf.DebugLSP {
183183 logging.Debug("No handler for response", "id", msg.ID)
184184 }
185185 }
@@ -191,7 +191,7 @@ func (c *Client) Call(ctx context.Context, method string, params any, result any
191191 cnf := config.Get()
192192 id := c.nextID.Add(1)
193193
194− if cnf.Debug {
194+ if cnf.DebugLSP {
195195 logging.Debug("Making call", "method", method, "id", id)
196196 }
197197
@@ -217,14 +217,14 @@ func (c *Client) Call(ctx context.Context, method string, params any, result any
217217 return fmt.Errorf("failed to send request: %w", err)
218218 }
219219
220− if cnf.Debug {
220+ if cnf.DebugLSP {
221221 logging.Debug("Request sent", "method", method, "id", id)
222222 }
223223
224224 // Wait for response
225225 resp := <-ch
226226
227− if cnf.Debug {
227+ if cnf.DebugLSP {
228228 logging.Debug("Received response", "id", id)
229229 }
230230
@@ -250,7 +250,7 @@ func (c *Client) Call(ctx context.Context, method string, params any, result any
250250// Notify sends a notification (a request without an ID that doesn't expect a response)
251251func (c *Client) Notify(ctx context.Context, method string, params any) error {
252252 cnf := config.Get()
253− if cnf.Debug {
253+ if cnf.DebugLSP {
254254 logging.Debug("Sending notification", "method", method)
255255 }
256256
internal/lsp/watcher/watcher.go+9−9
@@ -50,7 +50,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
5050 w.registrations = append(w.registrations, watchers...)
5151
5252 // Print detailed registration information for debugging
53− if cnf.Debug {
53+ if cnf.DebugLSP {
5454 logging.Debug("Adding file watcher registrations",
5555 "id", id,
5656 "watchers", len(watchers),
@@ -116,7 +116,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
116116 // Skip directories that should be excluded
117117 if d.IsDir() {
118118 if path != w.workspacePath && shouldExcludeDir(path) {
119− if cnf.Debug {
119+ if cnf.DebugLSP {
120120 logging.Debug("Skipping excluded directory", "path", path)
121121 }
122122 return filepath.SkipDir
@@ -136,7 +136,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
136136 })
137137
138138 elapsedTime := time.Since(startTime)
139− if cnf.Debug {
139+ if cnf.DebugLSP {
140140 logging.Debug("Workspace scan complete",
141141 "filesOpened", filesOpened,
142142 "elapsedTime", elapsedTime.Seconds(),
@@ -144,7 +144,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
144144 )
145145 }
146146
147− if err != nil && cnf.Debug {
147+ if err != nil && cnf.DebugLSP {
148148 logging.Debug("Error scanning workspace for files to open", "error", err)
149149 }
150150 }()
@@ -175,7 +175,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
175175 // Skip excluded directories (except workspace root)
176176 if d.IsDir() && path != workspacePath {
177177 if shouldExcludeDir(path) {
178− if cnf.Debug {
178+ if cnf.DebugLSP {
179179 logging.Debug("Skipping excluded directory", "path", path)
180180 }
181181 return filepath.SkipDir
@@ -228,7 +228,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
228228 }
229229
230230 // Debug logging
231− if cnf.Debug {
231+ if cnf.DebugLSP {
232232 matched, kind := w.isPathWatched(event.Name)
233233 logging.Debug("File event",
234234 "path", event.Name,
@@ -491,7 +491,7 @@ func (w *WorkspaceWatcher) handleFileEvent(ctx context.Context, uri string, chan
491491// notifyFileEvent sends a didChangeWatchedFiles notification for a file event
492492func (w *WorkspaceWatcher) notifyFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) error {
493493 cnf := config.Get()
494− if cnf.Debug {
494+ if cnf.DebugLSP {
495495 logging.Debug("Notifying file event",
496496 "uri", uri,
497497 "changeType", changeType,
@@ -615,7 +615,7 @@ func shouldExcludeFile(filePath string) bool {
615615
616616 // Skip large files
617617 if info.Size() > maxFileSize {
618− if cnf.Debug {
618+ if cnf.DebugLSP {
619619 logging.Debug("Skipping large file",
620620 "path", filePath,
621621 "size", info.Size(),
@@ -648,7 +648,7 @@ func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
648648 // Check if this path should be watched according to server registrations
649649 if watched, _ := w.isPathWatched(path); watched {
650650 // Don't need to check if it's already open - the client.OpenFile handles that
651− if err := w.client.OpenFile(ctx, path); err != nil && cnf.Debug {
651+ if err := w.client.OpenFile(ctx, path); err != nil && cnf.DebugLSP {
652652 logging.Error("Error opening file", "path", path, "error", err)
653653 }
654654 }
internal/message/content.go+26−4
@@ -2,6 +2,7 @@ package message
22
33import (
44 "encoding/base64"
5+ "slices"
56 "time"
67
78 "github.com/kujtimiihoxha/termai/internal/llm/models"
@@ -16,6 +17,20 @@ const (
1617 Tool MessageRole = "tool"
1718)
1819
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+
1934type ContentPart interface {
2035 isPart()
2136}
@@ -83,8 +98,8 @@ type ToolResult struct {
8398func (ToolResult) isPart() {}
8499
85100type Finish struct {
86− Reason string `json:"reason"`
87− Time int64 `json:"time"`
101+ Reason FinishReason `json:"reason"`
102+ Time int64 `json:"time"`
88103}
89104
90105func (Finish) isPart() {}
@@ -176,7 +191,7 @@ func (m *Message) FinishPart() *Finish {
176191 return nil
177192}
178193
179−func (m *Message) FinishReason() string {
194+func (m *Message) FinishReason() FinishReason {
180195 for _, part := range m.Parts {
181196 if c, ok := part.(Finish); ok {
182197 return c.Reason
@@ -246,7 +261,14 @@ func (m *Message) SetToolResults(tr []ToolResult) {
246261 }
247262}
248263
249−func (m *Message) AddFinish(reason string) {
264+func (m *Message) AddFinish(reason FinishReason) {
265+ // remove any existing finish part
266+ for i, part := range m.Parts {
267+ if _, ok := part.(Finish); ok {
268+ m.Parts = slices.Delete(m.Parts, i, i+1)
269+ break
270+ }
271+ }
250272 m.Parts = append(m.Parts, Finish{Reason: reason, Time: time.Now().Unix()})
251273}
252274
internal/pubsub/broker.go+1−1
@@ -5,7 +5,7 @@ import (
55 "sync"
66)
77
8−const bufferSize = 1024 * 1024
8+const bufferSize = 1024
99
1010type Logger interface {
1111 Debug(msg string, args ...any)
internal/session/session.go+15
@@ -24,6 +24,7 @@ type Session struct {
2424type Service interface {
2525 pubsub.Suscriber[Session]
2626 Create(ctx context.Context, title string) (Session, error)
27+ CreateTitleSession(ctx context.Context, parentSessionID string) (Session, error)
2728 CreateTaskSession(ctx context.Context, toolCallID, parentSessionID, title string) (Session, error)
2829 Get(ctx context.Context, id string) (Session, error)
2930 List(ctx context.Context) ([]Session, error)
@@ -63,6 +64,20 @@ func (s *service) CreateTaskSession(ctx context.Context, toolCallID, parentSessi
6364 return session, nil
6465}
6566
67+func (s *service) CreateTitleSession(ctx context.Context, parentSessionID string) (Session, error) {
68+ dbSession, err := s.q.CreateSession(ctx, db.CreateSessionParams{
69+ ID: "title-" + parentSessionID,
70+ ParentSessionID: sql.NullString{String: parentSessionID, Valid: true},
71+ Title: "Generate a title",
72+ })
73+ if err != nil {
74+ return Session{}, err
75+ }
76+ session := s.fromDBItem(dbSession)
77+ s.Publish(pubsub.CreatedEvent, session)
78+ return session, nil
79+}
80+
6681func (s *service) Delete(ctx context.Context, id string) error {
6782 session, err := s.Get(ctx, id)
6883 if err != nil {
internal/tui/components/chat/chat.go−2
@@ -19,8 +19,6 @@ type SessionSelectedMsg = session.Session
1919
2020type SessionClearedMsg struct{}
2121
22−type AgentWorkingMsg bool
23−
2422type EditorFocusMsg bool
2523
2624func lspsConfigured(width int) string {
internal/tui/components/chat/editor.go+14−8
@@ -5,14 +5,17 @@ import (
55 "github.com/charmbracelet/bubbles/textarea"
66 tea "github.com/charmbracelet/bubbletea"
77 "github.com/charmbracelet/lipgloss"
8+ "github.com/kujtimiihoxha/termai/internal/app"
9+ "github.com/kujtimiihoxha/termai/internal/session"
810 "github.com/kujtimiihoxha/termai/internal/tui/layout"
911 "github.com/kujtimiihoxha/termai/internal/tui/styles"
1012 "github.com/kujtimiihoxha/termai/internal/tui/util"
1113)
1214
1315type editorCmp struct {
14− textarea textarea.Model
15− agentWorking bool
16+ app *app.App
17+ session session.Session
18+ textarea textarea.Model
1619}
1720
1821type focusedEditorKeyMaps struct {
@@ -32,7 +35,7 @@ var focusedKeyMaps = focusedEditorKeyMaps{
3235 ),
3336 Blur: key.NewBinding(
3437 key.WithKeys("esc"),
35− key.WithHelp("esc", "blur editor"),
38+ key.WithHelp("esc", "focus messages"),
3639 ),
3740}
3841
@@ -52,7 +55,7 @@ func (m *editorCmp) Init() tea.Cmd {
5255}
5356
5457func (m *editorCmp) send() tea.Cmd {
55− if m.agentWorking {
58+ if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
5659 return util.ReportWarn("Agent is working, please wait...")
5760 }
5861
@@ -66,7 +69,6 @@ func (m *editorCmp) send() tea.Cmd {
6669 util.CmdHandler(SendMsg{
6770 Text: value,
6871 }),
69− util.CmdHandler(AgentWorkingMsg(true)),
7072 util.CmdHandler(EditorFocusMsg(false)),
7173 )
7274}
@@ -74,8 +76,11 @@ func (m *editorCmp) send() tea.Cmd {
7476func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
7577 var cmd tea.Cmd
7678 switch msg := msg.(type) {
77− case AgentWorkingMsg:
78− m.agentWorking = bool(msg)
79+ case SessionSelectedMsg:
80+ if msg.ID != m.session.ID {
81+ m.session = msg
82+ }
83+ return m, nil
7984 case tea.KeyMsg:
8085 // if the key does not match any binding, return
8186 if m.textarea.Focused() && key.Matches(msg, focusedKeyMaps.Send) {
@@ -122,7 +127,7 @@ func (m *editorCmp) BindingKeys() []key.Binding {
122127 return bindings
123128}
124129
125−func NewEditorCmp() tea.Model {
130+func NewEditorCmp(app *app.App) tea.Model {
126131 ti := textarea.New()
127132 ti.Prompt = " "
128133 ti.ShowLineNumbers = false
@@ -138,6 +143,7 @@ func NewEditorCmp() tea.Model {
138143 ti.CharLimit = -1
139144 ti.Focus()
140145 return &editorCmp{
146+ app: app,
141147 textarea: ti,
142148 }
143149}
internal/tui/components/chat/messages.go+179−26
@@ -6,7 +6,9 @@ import (
66 "fmt"
77 "math"
88 "strings"
9+ "time"
910
11+ "github.com/charmbracelet/bubbles/key"
1012 "github.com/charmbracelet/bubbles/spinner"
1113 "github.com/charmbracelet/bubbles/viewport"
1214 tea "github.com/charmbracelet/bubbletea"
@@ -17,9 +19,11 @@ import (
1719 "github.com/kujtimiihoxha/termai/internal/llm/agent"
1820 "github.com/kujtimiihoxha/termai/internal/llm/models"
1921 "github.com/kujtimiihoxha/termai/internal/llm/tools"
22+ "github.com/kujtimiihoxha/termai/internal/logging"
2023 "github.com/kujtimiihoxha/termai/internal/message"
2124 "github.com/kujtimiihoxha/termai/internal/pubsub"
2225 "github.com/kujtimiihoxha/termai/internal/session"
26+ "github.com/kujtimiihoxha/termai/internal/tui/layout"
2327 "github.com/kujtimiihoxha/termai/internal/tui/styles"
2428 "github.com/kujtimiihoxha/termai/internal/tui/util"
2529)
@@ -32,6 +36,9 @@ const (
3236 toolMessageType
3337)
3438
39+// messagesTickMsg is a message sent by the timer to refresh messages
40+type messagesTickMsg time.Time
41+
3542type uiMessage struct {
3643 ID string
3744 messageType uiMessageType
@@ -52,24 +59,34 @@ type messagesCmp struct {
5259 renderer *glamour.TermRenderer
5360 focusRenderer *glamour.TermRenderer
5461 cachedContent map[string]string
55− agentWorking bool
5662 spinner spinner.Model
5763 needsRerender bool
58− lastViewport string
5964}
6065
6166func (m *messagesCmp) Init() tea.Cmd {
62− return tea.Batch(m.viewport.Init())
67+ return tea.Batch(m.viewport.Init(), m.spinner.Tick, m.tickMessages())
68+}
69+
70+func (m *messagesCmp) tickMessages() tea.Cmd {
71+ return tea.Tick(time.Second, func(t time.Time) tea.Msg {
72+ return messagesTickMsg(t)
73+ })
6374}
6475
6576func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
6677 var cmds []tea.Cmd
6778 switch msg := msg.(type) {
68− case AgentWorkingMsg:
69− m.agentWorking = bool(msg)
70− if m.agentWorking {
71− cmds = append(cmds, m.spinner.Tick)
79+ case messagesTickMsg:
80+ // Refresh messages if we have an active session
81+ if m.session.ID != "" {
82+ messages, err := m.app.Messages.List(context.Background(), m.session.ID)
83+ if err == nil {
84+ m.messages = messages
85+ m.needsRerender = true
86+ }
7287 }
88+ // Continue ticking
89+ cmds = append(cmds, m.tickMessages())
7390 case EditorFocusMsg:
7491 m.writingMode = bool(msg)
7592 case SessionSelectedMsg:
@@ -84,6 +101,7 @@ func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
84101 m.messages = make([]message.Message, 0)
85102 m.currentMsgID = ""
86103 m.needsRerender = true
104+ m.cachedContent = make(map[string]string)
87105 return m, nil
88106
89107 case tea.KeyMsg:
@@ -104,6 +122,12 @@ func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
104122 }
105123
106124 if !messageExists {
125+ // If we have messages, ensure the previous last message is not cached
126+ if len(m.messages) > 0 {
127+ lastMsgID := m.messages[len(m.messages)-1].ID
128+ delete(m.cachedContent, lastMsgID)
129+ }
130+
107131 m.messages = append(m.messages, msg.Payload)
108132 delete(m.cachedContent, m.currentMsgID)
109133 m.currentMsgID = msg.Payload.ID
@@ -112,36 +136,40 @@ func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
112136 }
113137 for _, v := range m.messages {
114138 for _, c := range v.ToolCalls() {
115− // the message is being added to the session of a tool called
116139 if c.ID == msg.Payload.SessionID {
117140 m.needsRerender = true
118141 }
119142 }
120143 }
121144 } else if msg.Type == pubsub.UpdatedEvent && msg.Payload.SessionID == m.session.ID {
145+ logging.Debug("Message", "finish", msg.Payload.FinishReason())
122146 for i, v := range m.messages {
123147 if v.ID == msg.Payload.ID {
124− if !m.messages[i].IsFinished() && msg.Payload.IsFinished() && msg.Payload.FinishReason() == "end_turn" || msg.Payload.FinishReason() == "canceled" {
125− cmds = append(cmds, util.CmdHandler(AgentWorkingMsg(false)))
126− }
127148 m.messages[i] = msg.Payload
128149 delete(m.cachedContent, msg.Payload.ID)
150+
151+ // If this is the last message, ensure it's not cached
152+ if i == len(m.messages)-1 {
153+ delete(m.cachedContent, msg.Payload.ID)
154+ }
155+
129156 m.needsRerender = true
130157 break
131158 }
132159 }
133160 }
134161 }
135− if m.agentWorking {
136− u, cmd := m.spinner.Update(msg)
137− m.spinner = u
138− cmds = append(cmds, cmd)
139− }
162+
140163 oldPos := m.viewport.YPosition
141164 u, cmd := m.viewport.Update(msg)
142165 m.viewport = u
143166 m.needsRerender = m.needsRerender || m.viewport.YPosition != oldPos
144167 cmds = append(cmds, cmd)
168+
169+ spinner, cmd := m.spinner.Update(msg)
170+ m.spinner = spinner
171+ cmds = append(cmds, cmd)
172+
145173 if m.needsRerender {
146174 m.renderView()
147175 if len(m.messages) > 0 {
@@ -157,10 +185,21 @@ func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
157185 return m, tea.Batch(cmds...)
158186}
159187
188+func (m *messagesCmp) IsAgentWorking() bool {
189+ return m.app.CoderAgent.IsSessionBusy(m.session.ID)
190+}
191+
160192func (m *messagesCmp) renderSimpleMessage(msg message.Message, info ...string) string {
161− if v, ok := m.cachedContent[msg.ID]; ok {
162− return v
193+ // Check if this is the last message in the list
194+ isLastMessage := len(m.messages) > 0 && m.messages[len(m.messages)-1].ID == msg.ID
195+
196+ // Only use cache for non-last messages
197+ if !isLastMessage {
198+ if v, ok := m.cachedContent[msg.ID]; ok {
199+ return v
200+ }
163201 }
202+
164203 style := styles.BaseStyle.
165204 Width(m.width).
166205 BorderLeft(true).
@@ -191,7 +230,12 @@ func (m *messagesCmp) renderSimpleMessage(msg message.Message, info ...string) s
191230 parts...,
192231 ),
193232 )
194− m.cachedContent[msg.ID] = rendered
233+
234+ // Only cache if it's not the last message
235+ if !isLastMessage {
236+ m.cachedContent[msg.ID] = rendered
237+ }
238+
195239 return rendered
196240}
197241
@@ -207,32 +251,71 @@ func formatTimeDifference(unixTime1, unixTime2 int64) string {
207251 return fmt.Sprintf("%dm%ds", minutes, seconds)
208252}
209253
254+func (m *messagesCmp) findToolResponse(callID string) *message.ToolResult {
255+ for _, v := range m.messages {
256+ for _, c := range v.ToolResults() {
257+ if c.ToolCallID == callID {
258+ return &c
259+ }
260+ }
261+ }
262+ return nil
263+}
264+
210265func (m *messagesCmp) renderToolCall(toolCall message.ToolCall, isNested bool) string {
211266 key := ""
212267 value := ""
268+ result := styles.BaseStyle.Foreground(styles.PrimaryColor).Render(m.spinner.View() + " waiting for response...")
269+
270+ response := m.findToolResponse(toolCall.ID)
271+ if response != nil && response.IsError {
272+ // Clean up error message for display by removing newlines
273+ // This ensures error messages display properly in the UI
274+ errMsg := strings.ReplaceAll(response.Content, "\n", " ")
275+ result = styles.BaseStyle.Foreground(styles.Error).Render(ansi.Truncate(errMsg, 40, "..."))
276+ } else if response != nil {
277+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render("Done")
278+ }
213279 switch toolCall.Name {
214280 // TODO: add result data to the tools
215281 case agent.AgentToolName:
216282 key = "Task"
217283 var params agent.AgentParams
218284 json.Unmarshal([]byte(toolCall.Input), &params)
219− value = params.Prompt
220− // TODO: handle nested calls
285+ value = strings.ReplaceAll(params.Prompt, "\n", " ")
286+ if response != nil && !response.IsError {
287+ firstRow := strings.ReplaceAll(response.Content, "\n", " ")
288+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(ansi.Truncate(firstRow, 40, "..."))
289+ }
221290 case tools.BashToolName:
222291 key = "Bash"
223292 var params tools.BashParams
224293 json.Unmarshal([]byte(toolCall.Input), &params)
225294 value = params.Command
295+ if response != nil && !response.IsError {
296+ metadata := tools.BashResponseMetadata{}
297+ json.Unmarshal([]byte(response.Metadata), &metadata)
298+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("Took %s", formatTimeDifference(metadata.StartTime, metadata.EndTime)))
299+ }
300+
226301 case tools.EditToolName:
227302 key = "Edit"
228303 var params tools.EditParams
229304 json.Unmarshal([]byte(toolCall.Input), &params)
230305 value = params.FilePath
306+ if response != nil && !response.IsError {
307+ metadata := tools.EditResponseMetadata{}
308+ json.Unmarshal([]byte(response.Metadata), &metadata)
309+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d Additions %d Removals", metadata.Additions, metadata.Removals))
310+ }
231311 case tools.FetchToolName:
232312 key = "Fetch"
233313 var params tools.FetchParams
234314 json.Unmarshal([]byte(toolCall.Input), &params)
235315 value = params.URL
316+ if response != nil && !response.IsError {
317+ result = styles.BaseStyle.Foreground(styles.Error).Render(response.Content)
318+ }
236319 case tools.GlobToolName:
237320 key = "Glob"
238321 var params tools.GlobParams
@@ -241,6 +324,15 @@ func (m *messagesCmp) renderToolCall(toolCall message.ToolCall, isNested bool) s
241324 params.Path = "."
242325 }
243326 value = fmt.Sprintf("%s (%s)", params.Pattern, params.Path)
327+ if response != nil && !response.IsError {
328+ metadata := tools.GlobResponseMetadata{}
329+ json.Unmarshal([]byte(response.Metadata), &metadata)
330+ if metadata.Truncated {
331+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found (truncated)", metadata.NumberOfFiles))
332+ } else {
333+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found", metadata.NumberOfFiles))
334+ }
335+ }
244336 case tools.GrepToolName:
245337 key = "Grep"
246338 var params tools.GrepParams
@@ -249,19 +341,46 @@ func (m *messagesCmp) renderToolCall(toolCall message.ToolCall, isNested bool) s
249341 params.Path = "."
250342 }
251343 value = fmt.Sprintf("%s (%s)", params.Pattern, params.Path)
344+ if response != nil && !response.IsError {
345+ metadata := tools.GrepResponseMetadata{}
346+ json.Unmarshal([]byte(response.Metadata), &metadata)
347+ if metadata.Truncated {
348+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found (truncated)", metadata.NumberOfMatches))
349+ } else {
350+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found", metadata.NumberOfMatches))
351+ }
352+ }
252353 case tools.LSToolName:
253− key = "Ls"
354+ key = "ls"
254355 var params tools.LSParams
255356 json.Unmarshal([]byte(toolCall.Input), &params)
256357 if params.Path == "" {
257358 params.Path = "."
258359 }
259360 value = params.Path
361+ if response != nil && !response.IsError {
362+ metadata := tools.LSResponseMetadata{}
363+ json.Unmarshal([]byte(response.Metadata), &metadata)
364+ if metadata.Truncated {
365+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found (truncated)", metadata.NumberOfFiles))
366+ } else {
367+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found", metadata.NumberOfFiles))
368+ }
369+ }
260370 case tools.SourcegraphToolName:
261371 key = "Sourcegraph"
262372 var params tools.SourcegraphParams
263373 json.Unmarshal([]byte(toolCall.Input), &params)
264374 value = params.Query
375+ if response != nil && !response.IsError {
376+ metadata := tools.SourcegraphResponseMetadata{}
377+ json.Unmarshal([]byte(response.Metadata), &metadata)
378+ if metadata.Truncated {
379+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d matches found (truncated)", metadata.NumberOfMatches))
380+ } else {
381+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d matches found", metadata.NumberOfMatches))
382+ }
383+ }
265384 case tools.ViewToolName:
266385 key = "View"
267386 var params tools.ViewParams
@@ -272,6 +391,12 @@ func (m *messagesCmp) renderToolCall(toolCall message.ToolCall, isNested bool) s
272391 var params tools.WriteParams
273392 json.Unmarshal([]byte(toolCall.Input), &params)
274393 value = params.FilePath
394+ if response != nil && !response.IsError {
395+ metadata := tools.WriteResponseMetadata{}
396+ json.Unmarshal([]byte(response.Metadata), &metadata)
397+
398+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d Additions %d Removals", metadata.Additions, metadata.Removals))
399+ }
275400 default:
276401 key = toolCall.Name
277402 var params map[string]any
@@ -300,14 +425,15 @@ func (m *messagesCmp) renderToolCall(toolCall message.ToolCall, isNested bool) s
300425 )
301426 if !isNested {
302427 value = valyeStyle.
303− Width(m.width - lipgloss.Width(keyValye) - 2).
304428 Render(
305429 ansi.Truncate(
306− value,
307− m.width-lipgloss.Width(keyValye)-2,
430+ value+" ",
431+ m.width-lipgloss.Width(keyValye)-2-lipgloss.Width(result),
308432 "...",
309433 ),
310434 )
435+ value += result
436+
311437 } else {
312438 keyValye = keyStyle.Render(
313439 fmt.Sprintf(" └ %s: ", key),
@@ -409,6 +535,27 @@ func (m *messagesCmp) renderView() {
409535 m.uiMessages = make([]uiMessage, 0)
410536 pos := 0
411537
538+ // If we have messages, ensure the last message is not cached
539+ // This ensures we always render the latest content for the most recent message
540+ // which may be actively updating (e.g., during generation)
541+ if len(m.messages) > 0 {
542+ lastMsgID := m.messages[len(m.messages)-1].ID
543+ delete(m.cachedContent, lastMsgID)
544+ }
545+
546+ // Limit cache to 10 messages
547+ if len(m.cachedContent) > 15 {
548+ // Create a list of keys to delete (oldest messages first)
549+ keys := make([]string, 0, len(m.cachedContent))
550+ for k := range m.cachedContent {
551+ keys = append(keys, k)
552+ }
553+ // Delete oldest messages until we have 10 or fewer
554+ for i := 0; i < len(keys)-15; i++ {
555+ delete(m.cachedContent, keys[i])
556+ }
557+ }
558+
412559 for _, v := range m.messages {
413560 switch v.Role {
414561 case message.User:
@@ -487,7 +634,7 @@ func (m *messagesCmp) View() string {
487634func (m *messagesCmp) help() string {
488635 text := ""
489636
490− if m.agentWorking {
637+ if m.IsAgentWorking() {
491638 text += styles.BaseStyle.Foreground(styles.PrimaryColor).Bold(true).Render(
492639 fmt.Sprintf("%s %s ", m.spinner.View(), "Generating..."),
493640 )
@@ -562,9 +709,15 @@ func (m *messagesCmp) SetSession(session session.Session) tea.Cmd {
562709 m.messages = messages
563710 m.currentMsgID = m.messages[len(m.messages)-1].ID
564711 m.needsRerender = true
712+ m.cachedContent = make(map[string]string)
565713 return nil
566714}
567715
716+func (m *messagesCmp) BindingKeys() []key.Binding {
717+ bindings := layout.KeyMapToSlice(m.viewport.KeyMap)
718+ return bindings
719+}
720+
568721func NewMessagesCmp(app *app.App) tea.Model {
569722 focusRenderer, _ := glamour.NewTermRenderer(
570723 glamour.WithStyles(styles.MarkdownTheme(true)),
internal/tui/components/chat/sidebar.go+165−11
@@ -1,10 +1,15 @@
11package chat
22
33import (
4+ "context"
45 "fmt"
6+ "strings"
57
68 tea "github.com/charmbracelet/bubbletea"
79 "github.com/charmbracelet/lipgloss"
10+ "github.com/kujtimiihoxha/termai/internal/config"
11+ "github.com/kujtimiihoxha/termai/internal/diff"
12+ "github.com/kujtimiihoxha/termai/internal/history"
813 "github.com/kujtimiihoxha/termai/internal/pubsub"
914 "github.com/kujtimiihoxha/termai/internal/session"
1015 "github.com/kujtimiihoxha/termai/internal/tui/styles"
@@ -13,9 +18,33 @@ import (
1318type sidebarCmp struct {
1419 width, height int
1520 session session.Session
21+ history history.Service
22+ modFiles map[string]struct {
23+ additions int
24+ removals int
25+ }
1626}
1727
1828func (m *sidebarCmp) Init() tea.Cmd {
29+ if m.history != nil {
30+ ctx := context.Background()
31+ // Subscribe to file events
32+ filesCh := m.history.Subscribe(ctx)
33+
34+ // Initialize the modified files map
35+ m.modFiles = make(map[string]struct {
36+ additions int
37+ removals int
38+ })
39+
40+ // Load initial files and calculate diffs
41+ m.loadModifiedFiles(ctx)
42+
43+ // Return a command that will send file events to the Update method
44+ return func() tea.Msg {
45+ return <-filesCh
46+ }
47+ }
1948 return nil
2049}
2150
@@ -27,6 +56,13 @@ func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
2756 m.session = msg.Payload
2857 }
2958 }
59+ case pubsub.Event[history.File]:
60+ if msg.Payload.SessionID == m.session.ID {
61+ // When a file changes, reload all modified files
62+ // This ensures we have the complete and accurate list
63+ ctx := context.Background()
64+ m.loadModifiedFiles(ctx)
65+ }
3066 }
3167 return m, nil
3268}
@@ -86,18 +122,28 @@ func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) stri
86122
87123func (m *sidebarCmp) modifiedFiles() string {
88124 modifiedFiles := styles.BaseStyle.Width(m.width).Foreground(styles.PrimaryColor).Bold(true).Render("Modified Files:")
89− files := []struct {
90− path string
91− additions int
92− removals int
93− }{
94− {"file1.txt", 10, 5},
95− {"file2.txt", 20, 0},
96− {"file3.txt", 0, 15},
125+
126+ // If no modified files, show a placeholder message
127+ if m.modFiles == nil || len(m.modFiles) == 0 {
128+ message := "No modified files"
129+ remainingWidth := m.width - lipgloss.Width(modifiedFiles)
130+ if remainingWidth > 0 {
131+ message += strings.Repeat(" ", remainingWidth)
132+ }
133+ return styles.BaseStyle.
134+ Width(m.width).
135+ Render(
136+ lipgloss.JoinVertical(
137+ lipgloss.Top,
138+ modifiedFiles,
139+ styles.BaseStyle.Foreground(styles.ForgroundDim).Render(message),
140+ ),
141+ )
97142 }
143+
98144 var fileViews []string
99− for _, file := range files {
100− fileViews = append(fileViews, m.modifiedFile(file.path, file.additions, file.removals))
145+ for path, stats := range m.modFiles {
146+ fileViews = append(fileViews, m.modifiedFile(path, stats.additions, stats.removals))
101147 }
102148
103149 return styles.BaseStyle.
@@ -123,8 +169,116 @@ func (m *sidebarCmp) GetSize() (int, int) {
123169 return m.width, m.height
124170}
125171
126−func NewSidebarCmp(session session.Session) tea.Model {
172+func NewSidebarCmp(session session.Session, history history.Service) tea.Model {
127173 return &sidebarCmp{
128174 session: session,
175+ history: history,
176+ }
177+}
178+
179+func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) {
180+ if m.history == nil || m.session.ID == "" {
181+ return
182+ }
183+
184+ // Get all latest files for this session
185+ latestFiles, err := m.history.ListLatestSessionFiles(ctx, m.session.ID)
186+ if err != nil {
187+ return
188+ }
189+
190+ // Get all files for this session (to find initial versions)
191+ allFiles, err := m.history.ListBySession(ctx, m.session.ID)
192+ if err != nil {
193+ return
194+ }
195+
196+ // Process each latest file
197+ for _, file := range latestFiles {
198+ // Skip if this is the initial version (no changes to show)
199+ if file.Version == history.InitialVersion {
200+ continue
201+ }
202+
203+ // Find the initial version for this specific file
204+ var initialVersion history.File
205+ for _, v := range allFiles {
206+ if v.Path == file.Path && v.Version == history.InitialVersion {
207+ initialVersion = v
208+ break
209+ }
210+ }
211+
212+ // Skip if we can't find the initial version
213+ if initialVersion.ID == "" {
214+ continue
215+ }
216+
217+ // Calculate diff between initial and latest version
218+ _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
219+
220+ // Only add to modified files if there are changes
221+ if additions > 0 || removals > 0 {
222+ // Remove working directory prefix from file path
223+ displayPath := file.Path
224+ workingDir := config.WorkingDirectory()
225+ displayPath = strings.TrimPrefix(displayPath, workingDir)
226+ displayPath = strings.TrimPrefix(displayPath, "/")
227+
228+ m.modFiles[displayPath] = struct {
229+ additions int
230+ removals int
231+ }{
232+ additions: additions,
233+ removals: removals,
234+ }
235+ }
236+ }
237+}
238+
239+func (m *sidebarCmp) processFileChanges(ctx context.Context, file history.File) {
240+ // Skip if not the latest version
241+ if file.Version == history.InitialVersion {
242+ return
243+ }
244+
245+ // Get all versions of this file
246+ fileVersions, err := m.history.ListBySession(ctx, m.session.ID)
247+ if err != nil {
248+ return
249+ }
250+
251+ // Find the initial version
252+ var initialVersion history.File
253+ for _, v := range fileVersions {
254+ if v.Path == file.Path && v.Version == history.InitialVersion {
255+ initialVersion = v
256+ break
257+ }
258+ }
259+
260+ // Skip if we can't find the initial version
261+ if initialVersion.ID == "" {
262+ return
263+ }
264+
265+ // Calculate diff between initial and latest version
266+ _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
267+
268+ // Only add to modified files if there are changes
269+ if additions > 0 || removals > 0 {
270+ // Remove working directory prefix from file path
271+ displayPath := file.Path
272+ workingDir := config.WorkingDirectory()
273+ displayPath = strings.TrimPrefix(displayPath, workingDir)
274+ displayPath = strings.TrimPrefix(displayPath, "/")
275+
276+ m.modFiles[displayPath] = struct {
277+ additions int
278+ removals int
279+ }{
280+ additions: additions,
281+ removals: removals,
282+ }
129283 }
130284}
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+73−17
@@ -1,21 +1,25 @@
11package core
22
33import (
4+ "fmt"
5+ "strings"
46 "time"
57
68 tea "github.com/charmbracelet/bubbletea"
79 "github.com/charmbracelet/lipgloss"
810 "github.com/kujtimiihoxha/termai/internal/config"
911 "github.com/kujtimiihoxha/termai/internal/llm/models"
12+ "github.com/kujtimiihoxha/termai/internal/lsp"
13+ "github.com/kujtimiihoxha/termai/internal/lsp/protocol"
1014 "github.com/kujtimiihoxha/termai/internal/tui/styles"
1115 "github.com/kujtimiihoxha/termai/internal/tui/util"
12− "github.com/kujtimiihoxha/termai/internal/version"
1316)
1417
1518type statusCmp struct {
1619 info util.InfoMsg
1720 width int
1821 messageTTL time.Duration
22+ lspClients map[string]*lsp.Client
1923}
2024
2125// clearMessageCmd is a command that clears status messages after a timeout
@@ -47,20 +51,18 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
4751 return m, nil
4852}
4953
50−var (
51− versionWidget = styles.Padded.Background(styles.DarkGrey).Foreground(styles.Text).Render(version.Version)
52− helpWidget = styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render("? help")
53−)
54+var helpWidget = styles.Padded.Background(styles.ForgroundMid).Foreground(styles.BackgroundDarker).Bold(true).Render("ctrl+? help")
5455
5556func (m statusCmp) View() string {
56− status := styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render("? help")
57+ status := helpWidget
58+ diagnostics := styles.Padded.Background(styles.BackgroundDarker).Render(m.projectDiagnostics())
5759 if m.info.Msg != "" {
5860 infoStyle := styles.Padded.
5961 Foreground(styles.Base).
60− Width(m.availableFooterMsgWidth())
62+ Width(m.availableFooterMsgWidth(diagnostics))
6163 switch m.info.Type {
6264 case util.InfoTypeInfo:
63− infoStyle = infoStyle.Background(styles.Blue)
65+ infoStyle = infoStyle.Background(styles.BorderColor)
6466 case util.InfoTypeWarn:
6567 infoStyle = infoStyle.Background(styles.Peach)
6668 case util.InfoTypeError:
@@ -68,7 +70,7 @@ func (m statusCmp) View() string {
6870 }
6971 // Truncate message if it's longer than available width
7072 msg := m.info.Msg
71− availWidth := m.availableFooterMsgWidth() - 10
73+ availWidth := m.availableFooterMsgWidth(diagnostics) - 10
7274 if len(msg) > availWidth && availWidth > 0 {
7375 msg = msg[:availWidth] + "..."
7476 }
@@ -76,27 +78,81 @@ func (m statusCmp) View() string {
7678 } else {
7779 status += styles.Padded.
7880 Foreground(styles.Base).
79− Background(styles.LightGrey).
80− Width(m.availableFooterMsgWidth()).
81+ Background(styles.BackgroundDim).
82+ Width(m.availableFooterMsgWidth(diagnostics)).
8183 Render("")
8284 }
85+ status += diagnostics
8386 status += m.model()
84− status += versionWidget
8587 return status
8688}
8789
88−func (m statusCmp) availableFooterMsgWidth() int {
89− // -2 to accommodate padding
90− return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(versionWidget)-lipgloss.Width(m.model()))
90+func (m *statusCmp) projectDiagnostics() string {
91+ errorDiagnostics := []protocol.Diagnostic{}
92+ warnDiagnostics := []protocol.Diagnostic{}
93+ hintDiagnostics := []protocol.Diagnostic{}
94+ infoDiagnostics := []protocol.Diagnostic{}
95+ for _, client := range m.lspClients {
96+ for _, d := range client.GetDiagnostics() {
97+ for _, diag := range d {
98+ switch diag.Severity {
99+ case protocol.SeverityError:
100+ errorDiagnostics = append(errorDiagnostics, diag)
101+ case protocol.SeverityWarning:
102+ warnDiagnostics = append(warnDiagnostics, diag)
103+ case protocol.SeverityHint:
104+ hintDiagnostics = append(hintDiagnostics, diag)
105+ case protocol.SeverityInformation:
106+ infoDiagnostics = append(infoDiagnostics, diag)
107+ }
108+ }
109+ }
110+ }
111+
112+ if len(errorDiagnostics) == 0 && len(warnDiagnostics) == 0 && len(hintDiagnostics) == 0 && len(infoDiagnostics) == 0 {
113+ return "No diagnostics"
114+ }
115+
116+ diagnostics := []string{}
117+
118+ if len(errorDiagnostics) > 0 {
119+ errStr := lipgloss.NewStyle().Foreground(styles.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, len(errorDiagnostics)))
120+ diagnostics = append(diagnostics, errStr)
121+ }
122+ if len(warnDiagnostics) > 0 {
123+ warnStr := lipgloss.NewStyle().Foreground(styles.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, len(warnDiagnostics)))
124+ diagnostics = append(diagnostics, warnStr)
125+ }
126+ if len(hintDiagnostics) > 0 {
127+ hintStr := lipgloss.NewStyle().Foreground(styles.Text).Render(fmt.Sprintf("%s %d", styles.HintIcon, len(hintDiagnostics)))
128+ diagnostics = append(diagnostics, hintStr)
129+ }
130+ if len(infoDiagnostics) > 0 {
131+ infoStr := lipgloss.NewStyle().Foreground(styles.Peach).Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics)))
132+ diagnostics = append(diagnostics, infoStr)
133+ }
134+
135+ return strings.Join(diagnostics, " ")
136+}
137+
138+func (m statusCmp) availableFooterMsgWidth(diagnostics string) int {
139+ return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics))
91140}
92141
93142func (m statusCmp) model() string {
94− model := models.SupportedModels[config.Get().Model.Coder]
143+ cfg := config.Get()
144+
145+ coder, ok := cfg.Agents[config.AgentCoder]
146+ if !ok {
147+ return "Unknown"
148+ }
149+ model := models.SupportedModels[coder.Model]
95150 return styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render(model.Name)
96151}
97152
98−func NewStatusCmp() tea.Model {
153+func NewStatusCmp(lspClients map[string]*lsp.Client) tea.Model {
99154 return &statusCmp{
100155 messageTTL: 10 * time.Second,
156+ lspClients: lspClients,
101157 }
102158}
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/termai/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 = 80
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 = 12 - 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/permission.go+332−350
@@ -12,12 +12,9 @@ import (
1212 "github.com/kujtimiihoxha/termai/internal/diff"
1313 "github.com/kujtimiihoxha/termai/internal/llm/tools"
1414 "github.com/kujtimiihoxha/termai/internal/permission"
15− "github.com/kujtimiihoxha/termai/internal/tui/components/core"
1615 "github.com/kujtimiihoxha/termai/internal/tui/layout"
1716 "github.com/kujtimiihoxha/termai/internal/tui/styles"
1817 "github.com/kujtimiihoxha/termai/internal/tui/util"
19−
20− "github.com/charmbracelet/huh"
2118)
2219
2320type PermissionAction string
@@ -35,69 +32,64 @@ type PermissionResponseMsg struct {
3532 Action PermissionAction
3633}
3734
38−// PermissionDialog interface for permission dialog component
39−type PermissionDialog interface {
35+// PermissionDialogCmp interface for permission dialog component
36+type PermissionDialogCmp interface {
4037 tea.Model
41− layout.Sizeable
4238 layout.Bindings
39+ SetPermissions(permission permission.PermissionRequest)
4340}
4441
45−type keyMap struct {
46− ChangeFocus key.Binding
42+type permissionsMapping struct {
43+ LeftRight key.Binding
44+ EnterSpace key.Binding
45+ Allow key.Binding
46+ AllowSession key.Binding
47+ Deny key.Binding
48+ Tab key.Binding
4749}
4850
49−var keyMapValue = keyMap{
50− ChangeFocus: key.NewBinding(
51+var permissionsKeys = permissionsMapping{
52+ LeftRight: key.NewBinding(
53+ key.WithKeys("left", "right"),
54+ key.WithHelp("←/→", "switch options"),
55+ ),
56+ EnterSpace: key.NewBinding(
57+ key.WithKeys("enter", " "),
58+ key.WithHelp("enter/space", "confirm"),
59+ ),
60+ Allow: key.NewBinding(
61+ key.WithKeys("a"),
62+ key.WithHelp("a", "allow"),
63+ ),
64+ AllowSession: key.NewBinding(
65+ key.WithKeys("A"),
66+ key.WithHelp("A", "allow for session"),
67+ ),
68+ Deny: key.NewBinding(
69+ key.WithKeys("d"),
70+ key.WithHelp("d", "deny"),
71+ ),
72+ Tab: key.NewBinding(
5173 key.WithKeys("tab"),
52− key.WithHelp("tab", "change focus"),
74+ key.WithHelp("tab", "switch options"),
5375 ),
5476}
5577
5678// permissionDialogCmp is the implementation of PermissionDialog
5779type permissionDialogCmp struct {
58− form *huh.Form
5980 width int
6081 height int
6182 permission permission.PermissionRequest
6283 windowSize tea.WindowSizeMsg
63− r *glamour.TermRenderer
6484 contentViewPort viewport.Model
65− isViewportFocus bool
66− selectOption *huh.Select[string]
67−}
85+ selectedOption int // 0: Allow, 1: Allow for session, 2: Deny
6886
69−// formatDiff formats a diff string with colors for additions and deletions
70−func formatDiff(diffText string) string {
71− lines := strings.Split(diffText, "\n")
72− var formattedLines []string
73−
74− // Define styles for different line types
75− addStyle := lipgloss.NewStyle().Foreground(styles.Green)
76− removeStyle := lipgloss.NewStyle().Foreground(styles.Red)
77− headerStyle := lipgloss.NewStyle().Bold(true).Foreground(styles.Blue)
78− contextStyle := lipgloss.NewStyle().Foreground(styles.SubText0)
79−
80− // Process each line
81− for _, line := range lines {
82− if strings.HasPrefix(line, "+") {
83− formattedLines = append(formattedLines, addStyle.Render(line))
84− } else if strings.HasPrefix(line, "-") {
85− formattedLines = append(formattedLines, removeStyle.Render(line))
86− } else if strings.HasPrefix(line, "Changes:") || strings.HasPrefix(line, " ...") {
87− formattedLines = append(formattedLines, headerStyle.Render(line))
88− } else if strings.HasPrefix(line, " ") {
89− formattedLines = append(formattedLines, contextStyle.Render(line))
90− } else {
91− formattedLines = append(formattedLines, line)
92− }
93− }
94−
95− // Join all formatted lines
96− return strings.Join(formattedLines, "\n")
87+ diffCache map[string]string
88+ markdownCache map[string]string
9789}
9890
9991func (p *permissionDialogCmp) Init() tea.Cmd {
100− return nil
92+ return p.contentViewPort.Init()
10193}
10294
10395func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -106,373 +98,363 @@ func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
10698 switch msg := msg.(type) {
10799 case tea.WindowSizeMsg:
108100 p.windowSize = msg
101+ p.SetSize()
102+ p.markdownCache = make(map[string]string)
103+ p.diffCache = make(map[string]string)
109104 case tea.KeyMsg:
110− if key.Matches(msg, keyMapValue.ChangeFocus) {
111− p.isViewportFocus = !p.isViewportFocus
112− if p.isViewportFocus {
113− p.selectOption.Blur()
114− // Add a visual indicator for focus change
115− cmds = append(cmds, tea.Batch(
116− util.ReportInfo("Viewing content - use arrow keys to scroll"),
117− ))
118− } else {
119− p.selectOption.Focus()
120− // Add a visual indicator for focus change
121− cmds = append(cmds, tea.Batch(
122− util.CmdHandler(util.ReportInfo("Select an action")),
123− ))
124− }
125− return p, tea.Batch(cmds...)
126− }
127− }
128−
129− if p.isViewportFocus {
130− viewPort, cmd := p.contentViewPort.Update(msg)
131− p.contentViewPort = viewPort
132− cmds = append(cmds, cmd)
133− } else {
134− form, cmd := p.form.Update(msg)
135− if f, ok := form.(*huh.Form); ok {
136− p.form = f
105+ switch {
106+ case key.Matches(msg, permissionsKeys.LeftRight) || key.Matches(msg, permissionsKeys.Tab):
107+ // Change selected option
108+ p.selectedOption = (p.selectedOption + 1) % 3
109+ return p, nil
110+ case key.Matches(msg, permissionsKeys.EnterSpace):
111+ // Select current option
112+ return p, p.selectCurrentOption()
113+ case key.Matches(msg, permissionsKeys.Allow):
114+ // Select Allow
115+ return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionAllow, Permission: p.permission})
116+ case key.Matches(msg, permissionsKeys.AllowSession):
117+ // Select Allow for session
118+ return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionAllowForSession, Permission: p.permission})
119+ case key.Matches(msg, permissionsKeys.Deny):
120+ // Select Deny
121+ return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionDeny, Permission: p.permission})
122+ default:
123+ // Pass other keys to viewport
124+ viewPort, cmd := p.contentViewPort.Update(msg)
125+ p.contentViewPort = viewPort
137126 cmds = append(cmds, cmd)
138127 }
139−
140− if p.form.State == huh.StateCompleted {
141− // Get the selected action
142− action := p.form.GetString("action")
143−
144− // Close the dialog and return the response
145− return p, tea.Batch(
146− util.CmdHandler(core.DialogCloseMsg{}),
147− util.CmdHandler(PermissionResponseMsg{Action: PermissionAction(action), Permission: p.permission}),
148− )
149− }
150128 }
129+
151130 return p, tea.Batch(cmds...)
152131}
153132
154−func (p *permissionDialogCmp) render() string {
155− keyStyle := lipgloss.NewStyle().Bold(true).Foreground(styles.Rosewater)
156− valueStyle := lipgloss.NewStyle().Foreground(styles.Peach)
133+func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
134+ var action PermissionAction
157135
158− form := p.form.View()
159−
160− headerParts := []string{
161− lipgloss.JoinHorizontal(lipgloss.Left, keyStyle.Render("Tool:"), " ", valueStyle.Render(p.permission.ToolName)),
162− " ",
163− lipgloss.JoinHorizontal(lipgloss.Left, keyStyle.Render("Path:"), " ", valueStyle.Render(p.permission.Path)),
164− " ",
136+ switch p.selectedOption {
137+ case 0:
138+ action = PermissionAllow
139+ case 1:
140+ action = PermissionAllowForSession
141+ case 2:
142+ action = PermissionDeny
165143 }
166144
167− // Create the header content first so it can be used in all cases
168− headerContent := lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
169−
170− r, _ := glamour.NewTermRenderer(
171− glamour.WithStyles(styles.CatppuccinMarkdownStyle()),
172− glamour.WithWordWrap(p.width-10),
173− glamour.WithEmoji(),
174− )
175−
176− // Handle different tool types
177− switch p.permission.ToolName {
178− case tools.BashToolName:
179− pr := p.permission.Params.(tools.BashPermissionsParams)
180− headerParts = append(headerParts, keyStyle.Render("Command:"))
181− content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
182−
183− renderedContent, _ := r.Render(content)
184− p.contentViewPort.Width = p.width - 2 - 2
185−
186− // Calculate content height dynamically based on content
187− contentLines := len(strings.Split(renderedContent, "\n"))
188− // Set a reasonable min/max for the viewport height
189− minContentHeight := 3
190− maxContentHeight := p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
191−
192− // Add some padding to the content lines
193− contentHeight := contentLines + 2
194− contentHeight = max(contentHeight, minContentHeight)
195− contentHeight = min(contentHeight, maxContentHeight)
196− p.contentViewPort.Height = contentHeight
197−
198− p.contentViewPort.SetContent(renderedContent)
145+ return util.CmdHandler(PermissionResponseMsg{Action: action, Permission: p.permission})
146+}
199147
200− // Style the viewport
201− var contentBorder lipgloss.Border
202− var borderColor lipgloss.TerminalColor
148+func (p *permissionDialogCmp) renderButtons() string {
149+ allowStyle := styles.BaseStyle
150+ allowSessionStyle := styles.BaseStyle
151+ denyStyle := styles.BaseStyle
152+ spacerStyle := styles.BaseStyle.Background(styles.Background)
153+
154+ // Style the selected button
155+ switch p.selectedOption {
156+ case 0:
157+ allowStyle = allowStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
158+ allowSessionStyle = allowSessionStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
159+ denyStyle = denyStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
160+ case 1:
161+ allowStyle = allowStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
162+ allowSessionStyle = allowSessionStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
163+ denyStyle = denyStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
164+ case 2:
165+ allowStyle = allowStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
166+ allowSessionStyle = allowSessionStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
167+ denyStyle = denyStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
168+ }
203169
204− if p.isViewportFocus {
205− contentBorder = lipgloss.DoubleBorder()
206− borderColor = styles.Blue
207− } else {
208− contentBorder = lipgloss.RoundedBorder()
209− borderColor = styles.Flamingo
210− }
170+ allowButton := allowStyle.Padding(0, 1).Render("Allow (a)")
171+ allowSessionButton := allowSessionStyle.Padding(0, 1).Render("Allow for session (A)")
172+ denyButton := denyStyle.Padding(0, 1).Render("Deny (d)")
173+
174+ content := lipgloss.JoinHorizontal(
175+ lipgloss.Left,
176+ allowButton,
177+ spacerStyle.Render(" "),
178+ allowSessionButton,
179+ spacerStyle.Render(" "),
180+ denyButton,
181+ spacerStyle.Render(" "),
182+ )
211183
212− contentStyle := lipgloss.NewStyle().
213− MarginTop(1).
214− Padding(0, 1).
215− Border(contentBorder).
216− BorderForeground(borderColor)
184+ remainingWidth := p.width - lipgloss.Width(content)
185+ if remainingWidth > 0 {
186+ content = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + content
187+ }
188+ return content
189+}
217190
218− if p.isViewportFocus {
219− contentStyle = contentStyle.BorderBackground(styles.Surface0)
220− }
191+func (p *permissionDialogCmp) renderHeader() string {
192+ toolKey := styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("Tool")
193+ toolValue := styles.BaseStyle.
194+ Foreground(styles.Forground).
195+ Width(p.width - lipgloss.Width(toolKey)).
196+ Render(fmt.Sprintf(": %s", p.permission.ToolName))
221197
222− contentFinal := contentStyle.Render(p.contentViewPort.View())
198+ pathKey := styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("Path")
199+ pathValue := styles.BaseStyle.
200+ Foreground(styles.Forground).
201+ Width(p.width - lipgloss.Width(pathKey)).
202+ Render(fmt.Sprintf(": %s", p.permission.Path))
223203
224− return lipgloss.JoinVertical(
225− lipgloss.Top,
226− headerContent,
227− contentFinal,
228− form,
229− )
204+ headerParts := []string{
205+ lipgloss.JoinHorizontal(
206+ lipgloss.Left,
207+ toolKey,
208+ toolValue,
209+ ),
210+ styles.BaseStyle.Render(strings.Repeat(" ", p.width)),
211+ lipgloss.JoinHorizontal(
212+ lipgloss.Left,
213+ pathKey,
214+ pathValue,
215+ ),
216+ styles.BaseStyle.Render(strings.Repeat(" ", p.width)),
217+ }
230218
219+ // Add tool-specific header information
220+ switch p.permission.ToolName {
221+ case tools.BashToolName:
222+ headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("Command"))
231223 case tools.EditToolName:
232− pr := p.permission.Params.(tools.EditPermissionsParams)
233− headerParts = append(headerParts, keyStyle.Render("Update"))
234− // Recreate header content with the updated headerParts
235− headerContent = lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
236−
237− // Format the diff with colors
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− diff, err := diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
246− if err != nil {
247− diff = fmt.Sprintf("Error formatting diff: %v", err)
248− }
249− p.contentViewPort.SetContent(diff)
224+ headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("Diff"))
225+ case tools.WriteToolName:
226+ headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("Diff"))
227+ case tools.FetchToolName:
228+ headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("URL"))
229+ }
250230
251− // Style the viewport
252− var contentBorder lipgloss.Border
253− var borderColor lipgloss.TerminalColor
231+ return lipgloss.NewStyle().Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
232+}
254233
255− if p.isViewportFocus {
256− contentBorder = lipgloss.DoubleBorder()
257− borderColor = styles.Blue
258− } else {
259− contentBorder = lipgloss.RoundedBorder()
260− borderColor = styles.Flamingo
261− }
234+func (p *permissionDialogCmp) renderBashContent() string {
235+ if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
236+ content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
262237
263− contentStyle := lipgloss.NewStyle().
264− MarginTop(1).
265− Padding(0, 1).
266− Border(contentBorder).
267− BorderForeground(borderColor)
238+ // Use the cache for markdown rendering
239+ renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
240+ r, _ := glamour.NewTermRenderer(
241+ glamour.WithStyles(styles.MarkdownTheme(true)),
242+ glamour.WithWordWrap(p.width-10),
243+ )
244+ s, err := r.Render(content)
245+ return styles.ForceReplaceBackgroundWithLipgloss(s, styles.Background), err
246+ })
247+
248+ finalContent := styles.BaseStyle.
249+ Width(p.contentViewPort.Width).
250+ Render(renderedContent)
251+ p.contentViewPort.SetContent(finalContent)
252+ return p.styleViewport()
253+ }
254+ return ""
255+}
268256
269− if p.isViewportFocus {
270− contentStyle = contentStyle.BorderBackground(styles.Surface0)
271− }
257+func (p *permissionDialogCmp) renderEditContent() string {
258+ if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
259+ diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
260+ return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
261+ })
272262
273− contentFinal := contentStyle.Render(p.contentViewPort.View())
263+ p.contentViewPort.SetContent(diff)
264+ return p.styleViewport()
265+ }
266+ return ""
267+}
274268
275− return lipgloss.JoinVertical(
276− lipgloss.Top,
277− headerContent,
278− contentFinal,
279− form,
280− )
269+func (p *permissionDialogCmp) renderWriteContent() string {
270+ if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
271+ // Use the cache for diff rendering
272+ diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
273+ return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
274+ })
281275
282− case tools.WriteToolName:
283− pr := p.permission.Params.(tools.WritePermissionsParams)
284− headerParts = append(headerParts, keyStyle.Render("Content"))
285− // Recreate header content with the updated headerParts
286− headerContent = lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
287−
288− // Set up viewport for the content
289− p.contentViewPort.Width = p.width - 2 - 2
290−
291− // Calculate content height dynamically based on window size
292− maxContentHeight := p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
293− p.contentViewPort.Height = maxContentHeight
294− diff, err := diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
295− if err != nil {
296− diff = fmt.Sprintf("Error formatting diff: %v", err)
297− }
298276 p.contentViewPort.SetContent(diff)
277+ return p.styleViewport()
278+ }
279+ return ""
280+}
299281
300− // Style the viewport
301− var contentBorder lipgloss.Border
302− var borderColor lipgloss.TerminalColor
282+func (p *permissionDialogCmp) renderFetchContent() string {
283+ if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
284+ content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
303285
304− if p.isViewportFocus {
305− contentBorder = lipgloss.DoubleBorder()
306− borderColor = styles.Blue
307− } else {
308− contentBorder = lipgloss.RoundedBorder()
309− borderColor = styles.Flamingo
310− }
311−
312− contentStyle := lipgloss.NewStyle().
313− MarginTop(1).
314− Padding(0, 1).
315− Border(contentBorder).
316− BorderForeground(borderColor)
286+ // Use the cache for markdown rendering
287+ renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
288+ r, _ := glamour.NewTermRenderer(
289+ glamour.WithStyles(styles.MarkdownTheme(true)),
290+ glamour.WithWordWrap(p.width-10),
291+ )
292+ s, err := r.Render(content)
293+ return styles.ForceReplaceBackgroundWithLipgloss(s, styles.Background), err
294+ })
317295
318− if p.isViewportFocus {
319− contentStyle = contentStyle.BorderBackground(styles.Surface0)
320− }
296+ p.contentViewPort.SetContent(renderedContent)
297+ return p.styleViewport()
298+ }
299+ return ""
300+}
321301
322− contentFinal := contentStyle.Render(p.contentViewPort.View())
302+func (p *permissionDialogCmp) renderDefaultContent() string {
303+ content := p.permission.Description
323304
324− return lipgloss.JoinVertical(
325− lipgloss.Top,
326− headerContent,
327− contentFinal,
328− form,
305+ // Use the cache for markdown rendering
306+ renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
307+ r, _ := glamour.NewTermRenderer(
308+ glamour.WithStyles(styles.CatppuccinMarkdownStyle()),
309+ glamour.WithWordWrap(p.width-10),
329310 )
311+ s, err := r.Render(content)
312+ return styles.ForceReplaceBackgroundWithLipgloss(s, styles.Background), err
313+ })
330314
331− case tools.FetchToolName:
332− pr := p.permission.Params.(tools.FetchPermissionsParams)
333− headerParts = append(headerParts, keyStyle.Render("URL: "+pr.URL))
334− content := p.permission.Description
315+ p.contentViewPort.SetContent(renderedContent)
335316
336− renderedContent, _ := r.Render(content)
337− p.contentViewPort.Width = p.width - 2 - 2
338− p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
339− p.contentViewPort.SetContent(renderedContent)
317+ if renderedContent == "" {
318+ return ""
319+ }
340320
341− // Style the viewport
342− contentStyle := lipgloss.NewStyle().
343− MarginTop(1).
344− Padding(0, 1).
345− Border(lipgloss.RoundedBorder()).
346− BorderForeground(styles.Flamingo)
321+ return p.styleViewport()
322+}
347323
348− contentFinal := contentStyle.Render(p.contentViewPort.View())
349− if renderedContent == "" {
350− contentFinal = ""
351− }
324+func (p *permissionDialogCmp) styleViewport() string {
325+ contentStyle := lipgloss.NewStyle().
326+ Background(styles.Background)
352327
353− return lipgloss.JoinVertical(
354− lipgloss.Top,
355− headerContent,
356− contentFinal,
357− form,
358− )
328+ return contentStyle.Render(p.contentViewPort.View())
329+}
359330
331+func (p *permissionDialogCmp) render() string {
332+ title := styles.BaseStyle.
333+ Bold(true).
334+ Width(p.width - 4).
335+ Foreground(styles.PrimaryColor).
336+ Render("Permission Required")
337+ // Render header
338+ headerContent := p.renderHeader()
339+ // Render buttons
340+ buttons := p.renderButtons()
341+
342+ // Calculate content height dynamically based on window size
343+ p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(buttons) - 2 - lipgloss.Height(title)
344+ p.contentViewPort.Width = p.width - 4
345+
346+ // Render content based on tool type
347+ var contentFinal string
348+ switch p.permission.ToolName {
349+ case tools.BashToolName:
350+ contentFinal = p.renderBashContent()
351+ case tools.EditToolName:
352+ contentFinal = p.renderEditContent()
353+ case tools.WriteToolName:
354+ contentFinal = p.renderWriteContent()
355+ case tools.FetchToolName:
356+ contentFinal = p.renderFetchContent()
360357 default:
361− content := p.permission.Description
362−
363− renderedContent, _ := r.Render(content)
364− p.contentViewPort.Width = p.width - 2 - 2
365− p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
366− p.contentViewPort.SetContent(renderedContent)
367−
368− // Style the viewport
369− contentStyle := lipgloss.NewStyle().
370− MarginTop(1).
371− Padding(0, 1).
372− Border(lipgloss.RoundedBorder()).
373− BorderForeground(styles.Flamingo)
358+ contentFinal = p.renderDefaultContent()
359+ }
374360
375− contentFinal := contentStyle.Render(p.contentViewPort.View())
376− if renderedContent == "" {
377− contentFinal = ""
378− }
361+ content := lipgloss.JoinVertical(
362+ lipgloss.Top,
363+ title,
364+ styles.BaseStyle.Render(strings.Repeat(" ", lipgloss.Width(title))),
365+ headerContent,
366+ contentFinal,
367+ buttons,
368+ )
379369
380− return lipgloss.JoinVertical(
381− lipgloss.Top,
382− headerContent,
383− contentFinal,
384− form,
370+ return styles.BaseStyle.
371+ Padding(1, 0, 0, 1).
372+ Border(lipgloss.RoundedBorder()).
373+ BorderBackground(styles.Background).
374+ BorderForeground(styles.ForgroundDim).
375+ Width(p.width).
376+ Height(p.height).
377+ Render(
378+ content,
385379 )
386− }
387380}
388381
389382func (p *permissionDialogCmp) View() string {
390383 return p.render()
391384}
392385
393−func (p *permissionDialogCmp) GetSize() (int, int) {
394− return p.width, p.height
386+func (p *permissionDialogCmp) BindingKeys() []key.Binding {
387+ return layout.KeyMapToSlice(helpKeys)
395388}
396389
397−func (p *permissionDialogCmp) SetSize(width int, height int) {
398− p.width = width
399− p.height = height
400− p.form = p.form.WithWidth(width)
390+func (p *permissionDialogCmp) SetSize() {
391+ if p.permission.ID == "" {
392+ return
393+ }
394+ switch p.permission.ToolName {
395+ case tools.BashToolName:
396+ p.width = int(float64(p.windowSize.Width) * 0.4)
397+ p.height = int(float64(p.windowSize.Height) * 0.3)
398+ case tools.EditToolName:
399+ p.width = int(float64(p.windowSize.Width) * 0.8)
400+ p.height = int(float64(p.windowSize.Height) * 0.8)
401+ case tools.WriteToolName:
402+ p.width = int(float64(p.windowSize.Width) * 0.8)
403+ p.height = int(float64(p.windowSize.Height) * 0.8)
404+ case tools.FetchToolName:
405+ p.width = int(float64(p.windowSize.Width) * 0.4)
406+ p.height = int(float64(p.windowSize.Height) * 0.3)
407+ default:
408+ p.width = int(float64(p.windowSize.Width) * 0.7)
409+ p.height = int(float64(p.windowSize.Height) * 0.5)
410+ }
401411}
402412
403−func (p *permissionDialogCmp) BindingKeys() []key.Binding {
404− return p.form.KeyBinds()
413+func (p *permissionDialogCmp) SetPermissions(permission permission.PermissionRequest) {
414+ p.permission = permission
415+ p.SetSize()
405416}
406417
407−func newPermissionDialogCmp(permission permission.PermissionRequest) PermissionDialog {
408− // Create a note field for displaying the content
418+// Helper to get or set cached diff content
419+func (c *permissionDialogCmp) GetOrSetDiff(key string, generator func() (string, error)) string {
420+ if cached, ok := c.diffCache[key]; ok {
421+ return cached
422+ }
409423
410− // Create select field for the permission options
411− selectOption := huh.NewSelect[string]().
412− Key("action").
413− Options(
414− huh.NewOption("Allow", string(PermissionAllow)),
415− huh.NewOption("Allow for this session", string(PermissionAllowForSession)),
416− huh.NewOption("Deny", string(PermissionDeny)),
417− ).
418− Title("Select an action")
424+ content, err := generator()
425+ if err != nil {
426+ return fmt.Sprintf("Error formatting diff: %v", err)
427+ }
419428
420− // Apply theme
421− theme := styles.HuhTheme()
429+ c.diffCache[key] = content
422430
423− // Setup form width and height
424− form := huh.NewForm(huh.NewGroup(selectOption)).
425− WithShowHelp(false).
426− WithTheme(theme).
427− WithShowErrors(false)
431+ return content
432+}
428433
429− // Focus the form for immediate interaction
430− selectOption.Focus()
434+// Helper to get or set cached markdown content
435+func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
436+ if cached, ok := c.markdownCache[key]; ok {
437+ return cached
438+ }
431439
432− return &permissionDialogCmp{
433− permission: permission,
434− form: form,
435− selectOption: selectOption,
440+ content, err := generator()
441+ if err != nil {
442+ return fmt.Sprintf("Error rendering markdown: %v", err)
436443 }
437−}
438444
439−// NewPermissionDialogCmd creates a new permission dialog command
440−func NewPermissionDialogCmd(permission permission.PermissionRequest) tea.Cmd {
441− permDialog := newPermissionDialogCmp(permission)
442−
443− // Create the dialog layout
444− dialogPane := layout.NewSinglePane(
445− permDialog.(*permissionDialogCmp),
446− layout.WithSinglePaneBordered(true),
447− layout.WithSinglePaneFocusable(true),
448− layout.WithSinglePaneActiveColor(styles.Warning),
449− layout.WithSinglePaneBorderText(map[layout.BorderPosition]string{
450− layout.TopMiddleBorder: " Permission Required ",
451− }),
452− )
445+ c.markdownCache[key] = content
453446
454− // Focus the dialog
455− dialogPane.Focus()
456− widthRatio := 0.7
457− heightRatio := 0.6
458− minWidth := 100
459− minHeight := 30
447+ return content
448+}
460449
461− // Make the dialog size more appropriate for different tools
462− switch permission.ToolName {
463− case tools.BashToolName:
464− // For bash commands, use a more compact dialog
465− widthRatio = 0.7
466− heightRatio = 0.4 // Reduced from 0.5
467− minWidth = 100
468− minHeight = 20 // Reduced from 30
450+func NewPermissionDialogCmp() PermissionDialogCmp {
451+ // Create viewport for content
452+ contentViewport := viewport.New(0, 0)
453+
454+ return &permissionDialogCmp{
455+ contentViewPort: contentViewport,
456+ selectedOption: 0, // Default to "Allow"
457+ diffCache: make(map[string]string),
458+ markdownCache: make(map[string]string),
469459 }
470− // Return the dialog command
471− return util.CmdHandler(core.DialogMsg{
472− Content: dialogPane,
473− WidthRatio: widthRatio,
474− HeightRatio: heightRatio,
475− MinWidth: minWidth,
476− MinHeight: minHeight,
477− })
478460}
internal/tui/components/dialog/quit.go+91−65
@@ -1,28 +1,58 @@
11package dialog
22
33import (
4+ "strings"
5+
46 "github.com/charmbracelet/bubbles/key"
57 tea "github.com/charmbracelet/bubbletea"
6− "github.com/kujtimiihoxha/termai/internal/tui/components/core"
8+ "github.com/charmbracelet/lipgloss"
79 "github.com/kujtimiihoxha/termai/internal/tui/layout"
810 "github.com/kujtimiihoxha/termai/internal/tui/styles"
911 "github.com/kujtimiihoxha/termai/internal/tui/util"
10−
11− "github.com/charmbracelet/huh"
1212)
1313
1414const question = "Are you sure you want to quit?"
1515
16+type CloseQuitMsg struct{}
17+
1618type QuitDialog interface {
1719 tea.Model
18− layout.Sizeable
1920 layout.Bindings
2021}
2122
2223type quitDialogCmp struct {
23− form *huh.Form
24− width int
25− height int
24+ selectedNo bool
25+}
26+
27+type helpMapping struct {
28+ LeftRight key.Binding
29+ EnterSpace key.Binding
30+ Yes key.Binding
31+ No key.Binding
32+ Tab key.Binding
33+}
34+
35+var helpKeys = helpMapping{
36+ LeftRight: key.NewBinding(
37+ key.WithKeys("left", "right"),
38+ key.WithHelp("←/→", "switch options"),
39+ ),
40+ EnterSpace: key.NewBinding(
41+ key.WithKeys("enter", " "),
42+ key.WithHelp("enter/space", "confirm"),
43+ ),
44+ Yes: key.NewBinding(
45+ key.WithKeys("y", "Y"),
46+ key.WithHelp("y/Y", "yes"),
47+ ),
48+ No: key.NewBinding(
49+ key.WithKeys("n", "N"),
50+ key.WithHelp("n/N", "no"),
51+ ),
52+ Tab: key.NewBinding(
53+ key.WithKeys("tab"),
54+ key.WithHelp("tab", "switch options"),
55+ ),
2656}
2757
2858func (q *quitDialogCmp) Init() tea.Cmd {
@@ -30,77 +60,73 @@ func (q *quitDialogCmp) Init() tea.Cmd {
3060}
3161
3262func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
33− var cmds []tea.Cmd
34− form, cmd := q.form.Update(msg)
35− if f, ok := form.(*huh.Form); ok {
36− q.form = f
37− cmds = append(cmds, cmd)
38− }
39−
40− if q.form.State == huh.StateCompleted {
41− v := q.form.GetBool("quit")
42− if v {
63+ switch msg := msg.(type) {
64+ case tea.KeyMsg:
65+ switch {
66+ case key.Matches(msg, helpKeys.LeftRight) || key.Matches(msg, helpKeys.Tab):
67+ q.selectedNo = !q.selectedNo
68+ return q, nil
69+ case key.Matches(msg, helpKeys.EnterSpace):
70+ if !q.selectedNo {
71+ return q, tea.Quit
72+ }
73+ return q, util.CmdHandler(CloseQuitMsg{})
74+ case key.Matches(msg, helpKeys.Yes):
4375 return q, tea.Quit
76+ case key.Matches(msg, helpKeys.No):
77+ return q, util.CmdHandler(CloseQuitMsg{})
4478 }
45− cmds = append(cmds, util.CmdHandler(core.DialogCloseMsg{}))
4679 }
47−
48− return q, tea.Batch(cmds...)
80+ return q, nil
4981}
5082
5183func (q *quitDialogCmp) View() string {
52− return q.form.View()
53−}
84+ yesStyle := styles.BaseStyle
85+ noStyle := styles.BaseStyle
86+ spacerStyle := styles.BaseStyle.Background(styles.Background)
87+
88+ if q.selectedNo {
89+ noStyle = noStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
90+ yesStyle = yesStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
91+ } else {
92+ yesStyle = yesStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
93+ noStyle = noStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
94+ }
5495
55−func (q *quitDialogCmp) GetSize() (int, int) {
56− return q.width, q.height
57−}
96+ yesButton := yesStyle.Padding(0, 1).Render("Yes")
97+ noButton := noStyle.Padding(0, 1).Render("No")
98+
99+ buttons := lipgloss.JoinHorizontal(lipgloss.Left, yesButton, spacerStyle.Render(" "), noButton)
100+
101+ width := lipgloss.Width(question)
102+ remainingWidth := width - lipgloss.Width(buttons)
103+ if remainingWidth > 0 {
104+ buttons = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + buttons
105+ }
58106
59−func (q *quitDialogCmp) SetSize(width int, height int) {
60− q.width = width
61− q.height = height
62− q.form = q.form.WithWidth(width).WithHeight(height)
107+ content := styles.BaseStyle.Render(
108+ lipgloss.JoinVertical(
109+ lipgloss.Center,
110+ question,
111+ "",
112+ buttons,
113+ ),
114+ )
115+
116+ return styles.BaseStyle.Padding(1, 2).
117+ Border(lipgloss.RoundedBorder()).
118+ BorderBackground(styles.Background).
119+ BorderForeground(styles.ForgroundDim).
120+ Width(lipgloss.Width(content) + 4).
121+ Render(content)
63122}
64123
65124func (q *quitDialogCmp) BindingKeys() []key.Binding {
66− return q.form.KeyBinds()
125+ return layout.KeyMapToSlice(helpKeys)
67126}
68127
69−func newQuitDialogCmp() QuitDialog {
70− confirm := huh.NewConfirm().
71− Title(question).
72− Affirmative("Yes!").
73− Key("quit").
74− Negative("No.")
75−
76− theme := styles.HuhTheme()
77− theme.Focused.FocusedButton = theme.Focused.FocusedButton.Background(styles.Warning)
78− theme.Blurred.FocusedButton = theme.Blurred.FocusedButton.Background(styles.Warning)
79− form := huh.NewForm(huh.NewGroup(confirm)).
80− WithShowHelp(false).
81− WithWidth(0).
82− WithHeight(0).
83− WithTheme(theme).
84− WithShowErrors(false)
85− confirm.Focus()
128+func NewQuitCmp() QuitDialog {
86129 return &quitDialogCmp{
87− form: form,
130+ selectedNo: true,
88131 }
89132}
90−
91−func NewQuitDialogCmd() tea.Cmd {
92− content := layout.NewSinglePane(
93− newQuitDialogCmp().(*quitDialogCmp),
94− layout.WithSinglePaneBordered(true),
95− layout.WithSinglePaneFocusable(true),
96− layout.WithSinglePaneActiveColor(styles.Warning),
97− )
98− content.Focus()
99− return util.CmdHandler(core.DialogMsg{
100− Content: content,
101− WidthRatio: 0.2,
102− HeightRatio: 0.1,
103− MinWidth: 40,
104− MinHeight: 5,
105− })
106−}
internal/tui/components/logs/details.go−2
@@ -16,10 +16,8 @@ import (
1616
1717type DetailComponent interface {
1818 tea.Model
19− layout.Focusable
2019 layout.Sizeable
2120 layout.Bindings
22− layout.Bordered
2321}
2422
2523type detailCmp struct {
internal/tui/components/logs/table.go−22
@@ -16,22 +16,14 @@ import (
1616
1717type TableComponent interface {
1818 tea.Model
19− layout.Focusable
2019 layout.Sizeable
2120 layout.Bindings
22− layout.Bordered
2321}
2422
2523type tableCmp struct {
2624 table table.Model
2725}
2826
29−func (i *tableCmp) BorderText() map[layout.BorderPosition]string {
30− return map[layout.BorderPosition]string{
31− layout.TopLeftBorder: "Logs",
32− }
33−}
34−
3527type selectedLogMsg logging.LogMessage
3628
3729func (i *tableCmp) Init() tea.Cmd {
@@ -74,20 +66,6 @@ func (i *tableCmp) View() string {
7466 return i.table.View()
7567}
7668
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−}
90−
9169func (i *tableCmp) GetSize() (int, int) {
9270 return i.table.Width(), i.table.Height()
9371}
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/tui/layout"
11− "github.com/kujtimiihoxha/termai/internal/tui/styles"
12− "github.com/kujtimiihoxha/termai/internal/tui/util"
13− "github.com/kujtimiihoxha/vimtea"
14− "golang.org/x/net/context"
15−)
16−
17−type EditorCmp interface {
18− tea.Model
19− layout.Focusable
20− layout.Sizeable
21− layout.Bordered
22− layout.Bindings
23−}
24−
25−type editorCmp struct {
26− app *app.App
27− editor vimtea.Editor
28− editorMode vimtea.EditorMode
29− sessionID string
30− focused bool
31− width int
32− height int
33− cancelMessage context.CancelFunc
34−}
35−
36−type editorKeyMap struct {
37− SendMessage key.Binding
38− SendMessageI key.Binding
39− CancelMessage key.Binding
40− InsertMode key.Binding
41− NormaMode key.Binding
42− VisualMode key.Binding
43− VisualLineMode key.Binding
44−}
45−
46−var editorKeyMapValue = editorKeyMap{
47− SendMessage: key.NewBinding(
48− key.WithKeys("enter"),
49− key.WithHelp("enter", "send message normal mode"),
50− ),
51− SendMessageI: key.NewBinding(
52− key.WithKeys("ctrl+s"),
53− key.WithHelp("ctrl+s", "send message insert mode"),
54− ),
55− CancelMessage: key.NewBinding(
56− key.WithKeys("ctrl+x"),
57− key.WithHelp("ctrl+x", "cancel current message"),
58− ),
59− InsertMode: key.NewBinding(
60− key.WithKeys("i"),
61− key.WithHelp("i", "insert mode"),
62− ),
63− NormaMode: key.NewBinding(
64− key.WithKeys("esc"),
65− key.WithHelp("esc", "normal mode"),
66− ),
67− VisualMode: key.NewBinding(
68− key.WithKeys("v"),
69− key.WithHelp("v", "visual mode"),
70− ),
71− VisualLineMode: key.NewBinding(
72− key.WithKeys("V"),
73− key.WithHelp("V", "visual line mode"),
74− ),
75−}
76−
77−func (m *editorCmp) Init() tea.Cmd {
78− return m.editor.Init()
79−}
80−
81−func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
82− switch msg := msg.(type) {
83− case vimtea.EditorModeMsg:
84− m.editorMode = msg.Mode
85− case SelectedSessionMsg:
86− if msg.SessionID != m.sessionID {
87− m.sessionID = msg.SessionID
88− }
89− }
90− if m.IsFocused() {
91− switch msg := msg.(type) {
92− case tea.KeyMsg:
93− switch {
94− case key.Matches(msg, editorKeyMapValue.SendMessage):
95− if m.editorMode == vimtea.ModeNormal {
96− return m, m.Send()
97− }
98− case key.Matches(msg, editorKeyMapValue.SendMessageI):
99− if m.editorMode == vimtea.ModeInsert {
100− return m, m.Send()
101− }
102− case key.Matches(msg, editorKeyMapValue.CancelMessage):
103− return m, m.Cancel()
104− }
105− }
106− u, cmd := m.editor.Update(msg)
107− m.editor = u.(vimtea.Editor)
108− return m, cmd
109− }
110− return m, nil
111−}
112−
113−func (m *editorCmp) Blur() tea.Cmd {
114− m.focused = false
115− return nil
116−}
117−
118−func (m *editorCmp) BorderText() map[layout.BorderPosition]string {
119− title := "New Message"
120− if m.focused {
121− title = lipgloss.NewStyle().Foreground(styles.Primary).Render(title)
122− }
123− return map[layout.BorderPosition]string{
124− layout.BottomLeftBorder: title,
125− }
126−}
127−
128−func (m *editorCmp) Focus() tea.Cmd {
129− m.focused = true
130− return m.editor.Tick()
131−}
132−
133−func (m *editorCmp) GetSize() (int, int) {
134− return m.width, m.height
135−}
136−
137−func (m *editorCmp) IsFocused() bool {
138− return m.focused
139−}
140−
141−func (m *editorCmp) SetSize(width int, height int) {
142− m.width = width
143− m.height = height
144− m.editor.SetSize(width, height)
145−}
146−
147−func (m *editorCmp) Cancel() tea.Cmd {
148− if m.cancelMessage == nil {
149− return util.ReportWarn("No message to cancel")
150− }
151−
152− m.cancelMessage()
153− m.cancelMessage = nil
154− return util.ReportWarn("Message cancelled")
155−}
156−
157−func (m *editorCmp) Send() tea.Cmd {
158− if m.cancelMessage != nil {
159− return util.ReportWarn("Assistant is still working on the previous message")
160− }
161−
162− messages, err := m.app.Messages.List(context.Background(), m.sessionID)
163− if err != nil {
164− return util.ReportError(err)
165− }
166− if hasUnfinishedMessages(messages) {
167− return util.ReportWarn("Assistant is still working on the previous message")
168− }
169−
170− content := strings.Join(m.editor.GetBuffer().Lines(), "\n")
171− if len(content) == 0 {
172− return util.ReportWarn("Message is empty")
173− }
174− ctx, cancel := context.WithCancel(context.Background())
175− m.cancelMessage = cancel
176− go func() {
177− defer cancel()
178− m.app.CoderAgent.Generate(ctx, m.sessionID, content)
179− m.cancelMessage = nil
180− }()
181−
182− return m.editor.Reset()
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−513
@@ -1,513 +0,0 @@
1−package repl
2−
3−import (
4− "context"
5− "encoding/json"
6− "fmt"
7− "sort"
8− "strings"
9−
10− "github.com/charmbracelet/bubbles/key"
11− "github.com/charmbracelet/bubbles/viewport"
12− tea "github.com/charmbracelet/bubbletea"
13− "github.com/charmbracelet/glamour"
14− "github.com/charmbracelet/lipgloss"
15− "github.com/kujtimiihoxha/termai/internal/app"
16− "github.com/kujtimiihoxha/termai/internal/llm/agent"
17− "github.com/kujtimiihoxha/termai/internal/lsp/protocol"
18− "github.com/kujtimiihoxha/termai/internal/message"
19− "github.com/kujtimiihoxha/termai/internal/pubsub"
20− "github.com/kujtimiihoxha/termai/internal/session"
21− "github.com/kujtimiihoxha/termai/internal/tui/layout"
22− "github.com/kujtimiihoxha/termai/internal/tui/styles"
23−)
24−
25−type MessagesCmp interface {
26− tea.Model
27− layout.Focusable
28− layout.Bordered
29− layout.Sizeable
30− layout.Bindings
31−}
32−
33−type messagesCmp struct {
34− app *app.App
35− messages []message.Message
36− selectedMsgIdx int // Index of the selected message
37− session session.Session
38− viewport viewport.Model
39− mdRenderer *glamour.TermRenderer
40− width int
41− height int
42− focused bool
43− cachedView string
44−}
45−
46−func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
47− switch msg := msg.(type) {
48− case pubsub.Event[message.Message]:
49− if msg.Type == pubsub.CreatedEvent {
50− if msg.Payload.SessionID == m.session.ID {
51− m.messages = append(m.messages, msg.Payload)
52− m.renderView()
53− m.viewport.GotoBottom()
54− }
55− for _, v := range m.messages {
56− for _, c := range v.ToolCalls() {
57− // the message is being added to the session of a tool called
58− if c.ID == msg.Payload.SessionID {
59− m.renderView()
60− m.viewport.GotoBottom()
61− }
62− }
63− }
64− } else if msg.Type == pubsub.UpdatedEvent && msg.Payload.SessionID == m.session.ID {
65− for i, v := range m.messages {
66− if v.ID == msg.Payload.ID {
67− m.messages[i] = msg.Payload
68− m.renderView()
69− if i == len(m.messages)-1 {
70− m.viewport.GotoBottom()
71− }
72− break
73− }
74− }
75− }
76− case pubsub.Event[session.Session]:
77− if msg.Type == pubsub.UpdatedEvent && m.session.ID == msg.Payload.ID {
78− m.session = msg.Payload
79− }
80− case SelectedSessionMsg:
81− m.session, _ = m.app.Sessions.Get(context.Background(), msg.SessionID)
82− m.messages, _ = m.app.Messages.List(context.Background(), m.session.ID)
83− m.renderView()
84− m.viewport.GotoBottom()
85− }
86− if m.focused {
87− u, cmd := m.viewport.Update(msg)
88− m.viewport = u
89− return m, cmd
90− }
91− return m, nil
92−}
93−
94−func borderColor(role message.MessageRole) lipgloss.TerminalColor {
95− switch role {
96− case message.Assistant:
97− return styles.Mauve
98− case message.User:
99− return styles.Rosewater
100− }
101− return styles.Blue
102−}
103−
104−func borderText(msgRole message.MessageRole, currentMessage int) map[layout.BorderPosition]string {
105− role := ""
106− icon := ""
107− switch msgRole {
108− case message.Assistant:
109− role = "Assistant"
110− icon = styles.BotIcon
111− case message.User:
112− role = "User"
113− icon = styles.UserIcon
114− }
115− return map[layout.BorderPosition]string{
116− layout.TopLeftBorder: lipgloss.NewStyle().
117− Padding(0, 1).
118− Bold(true).
119− Foreground(styles.Crust).
120− Background(borderColor(msgRole)).
121− Render(fmt.Sprintf("%s %s ", role, icon)),
122− layout.TopRightBorder: lipgloss.NewStyle().
123− Padding(0, 1).
124− Bold(true).
125− Foreground(styles.Crust).
126− Background(borderColor(msgRole)).
127− Render(fmt.Sprintf("#%d ", currentMessage)),
128− }
129−}
130−
131−func hasUnfinishedMessages(messages []message.Message) bool {
132− if len(messages) == 0 {
133− return false
134− }
135− for _, msg := range messages {
136− if !msg.IsFinished() {
137− return true
138− }
139− }
140− return false
141−}
142−
143−func (m *messagesCmp) renderMessageWithToolCall(content string, tools []message.ToolCall, futureMessages []message.Message) string {
144− allParts := []string{content}
145−
146− leftPaddingValue := 4
147− connectorStyle := lipgloss.NewStyle().
148− Foreground(styles.Peach).
149− Bold(true)
150−
151− toolCallStyle := lipgloss.NewStyle().
152− Border(lipgloss.RoundedBorder()).
153− BorderForeground(styles.Peach).
154− Width(m.width-leftPaddingValue-5).
155− Padding(0, 1)
156−
157− toolResultStyle := lipgloss.NewStyle().
158− Border(lipgloss.RoundedBorder()).
159− BorderForeground(styles.Green).
160− Width(m.width-leftPaddingValue-5).
161− Padding(0, 1)
162−
163− leftPadding := lipgloss.NewStyle().Padding(0, 0, 0, leftPaddingValue)
164−
165− runningStyle := lipgloss.NewStyle().
166− Foreground(styles.Peach).
167− Bold(true)
168−
169− renderTool := func(toolCall message.ToolCall) string {
170− toolHeader := lipgloss.NewStyle().
171− Bold(true).
172− Foreground(styles.Blue).
173− Render(fmt.Sprintf("%s %s", styles.ToolIcon, toolCall.Name))
174−
175− var paramLines []string
176− var args map[string]interface{}
177− var paramOrder []string
178−
179− json.Unmarshal([]byte(toolCall.Input), &args)
180−
181− for key := range args {
182− paramOrder = append(paramOrder, key)
183− }
184− sort.Strings(paramOrder)
185−
186− for _, name := range paramOrder {
187− value := args[name]
188− paramName := lipgloss.NewStyle().
189− Foreground(styles.Peach).
190− Bold(true).
191− Render(name)
192−
193− truncate := m.width - leftPaddingValue*2 - 10
194− if len(fmt.Sprintf("%v", value)) > truncate {
195− value = fmt.Sprintf("%v", value)[:truncate] + lipgloss.NewStyle().Foreground(styles.Blue).Render("... (truncated)")
196− }
197− paramValue := fmt.Sprintf("%v", value)
198− paramLines = append(paramLines, fmt.Sprintf(" %s: %s", paramName, paramValue))
199− }
200−
201− paramBlock := lipgloss.JoinVertical(lipgloss.Left, paramLines...)
202−
203− toolContent := lipgloss.JoinVertical(lipgloss.Left, toolHeader, paramBlock)
204− return toolCallStyle.Render(toolContent)
205− }
206−
207− findToolResult := func(toolCallID string, messages []message.Message) *message.ToolResult {
208− for _, msg := range messages {
209− if msg.Role == message.Tool {
210− for _, result := range msg.ToolResults() {
211− if result.ToolCallID == toolCallID {
212− return &result
213− }
214− }
215− }
216− }
217− return nil
218− }
219−
220− renderToolResult := func(result message.ToolResult) string {
221− resultHeader := lipgloss.NewStyle().
222− Bold(true).
223− Foreground(styles.Green).
224− Render(fmt.Sprintf("%s Result", styles.CheckIcon))
225−
226− // Use the same style for both header and border if it's an error
227− borderColor := styles.Green
228− if result.IsError {
229− resultHeader = lipgloss.NewStyle().
230− Bold(true).
231− Foreground(styles.Red).
232− Render(fmt.Sprintf("%s Error", styles.ErrorIcon))
233− borderColor = styles.Red
234− }
235−
236− truncate := 200
237− content := result.Content
238− if len(content) > truncate {
239− content = content[:truncate] + lipgloss.NewStyle().Foreground(styles.Blue).Render("... (truncated)")
240− }
241−
242− resultContent := lipgloss.JoinVertical(lipgloss.Left, resultHeader, content)
243− return toolResultStyle.BorderForeground(borderColor).Render(resultContent)
244− }
245−
246− connector := connectorStyle.Render("└─> Tool Calls:")
247− allParts = append(allParts, connector)
248−
249− for _, toolCall := range tools {
250− toolOutput := renderTool(toolCall)
251− allParts = append(allParts, leftPadding.Render(toolOutput))
252−
253− result := findToolResult(toolCall.ID, futureMessages)
254− if result != nil {
255−
256− resultOutput := renderToolResult(*result)
257− allParts = append(allParts, leftPadding.Render(resultOutput))
258−
259− } else if toolCall.Name == agent.AgentToolName {
260−
261− runningIndicator := runningStyle.Render(fmt.Sprintf("%s Running...", styles.SpinnerIcon))
262− allParts = append(allParts, leftPadding.Render(runningIndicator))
263− taskSessionMessages, _ := m.app.Messages.List(context.Background(), toolCall.ID)
264− for _, msg := range taskSessionMessages {
265− if msg.Role == message.Assistant {
266− for _, toolCall := range msg.ToolCalls() {
267− toolHeader := lipgloss.NewStyle().
268− Bold(true).
269− Foreground(styles.Blue).
270− Render(fmt.Sprintf("%s %s", styles.ToolIcon, toolCall.Name))
271−
272− var paramLines []string
273− var args map[string]interface{}
274− var paramOrder []string
275−
276− json.Unmarshal([]byte(toolCall.Input), &args)
277−
278− for key := range args {
279− paramOrder = append(paramOrder, key)
280− }
281− sort.Strings(paramOrder)
282−
283− for _, name := range paramOrder {
284− value := args[name]
285− paramName := lipgloss.NewStyle().
286− Foreground(styles.Peach).
287− Bold(true).
288− Render(name)
289−
290− truncate := 50
291− if len(fmt.Sprintf("%v", value)) > truncate {
292− value = fmt.Sprintf("%v", value)[:truncate] + lipgloss.NewStyle().Foreground(styles.Blue).Render("... (truncated)")
293− }
294− paramValue := fmt.Sprintf("%v", value)
295− paramLines = append(paramLines, fmt.Sprintf(" %s: %s", paramName, paramValue))
296− }
297−
298− paramBlock := lipgloss.JoinVertical(lipgloss.Left, paramLines...)
299− toolContent := lipgloss.JoinVertical(lipgloss.Left, toolHeader, paramBlock)
300− toolOutput := toolCallStyle.BorderForeground(styles.Teal).MaxWidth(m.width - leftPaddingValue*2 - 2).Render(toolContent)
301− allParts = append(allParts, lipgloss.NewStyle().Padding(0, 0, 0, leftPaddingValue*2).Render(toolOutput))
302− }
303− }
304− }
305−
306− } else {
307− runningIndicator := runningStyle.Render(fmt.Sprintf("%s Running...", styles.SpinnerIcon))
308− allParts = append(allParts, " "+runningIndicator)
309− }
310− }
311−
312− for _, msg := range futureMessages {
313− if msg.Content().String() != "" || msg.FinishReason() == "canceled" {
314− break
315− }
316−
317− for _, toolCall := range msg.ToolCalls() {
318− toolOutput := renderTool(toolCall)
319− allParts = append(allParts, " "+strings.ReplaceAll(toolOutput, "\n", "\n "))
320−
321− result := findToolResult(toolCall.ID, futureMessages)
322− if result != nil {
323− resultOutput := renderToolResult(*result)
324− allParts = append(allParts, " "+strings.ReplaceAll(resultOutput, "\n", "\n "))
325− } else {
326− runningIndicator := runningStyle.Render(fmt.Sprintf("%s Running...", styles.SpinnerIcon))
327− allParts = append(allParts, " "+runningIndicator)
328− }
329− }
330− }
331−
332− return lipgloss.JoinVertical(lipgloss.Left, allParts...)
333−}
334−
335−func (m *messagesCmp) renderView() {
336− stringMessages := make([]string, 0)
337− r, _ := glamour.NewTermRenderer(
338− glamour.WithStyles(styles.CatppuccinMarkdownStyle()),
339− glamour.WithWordWrap(m.width-20),
340− glamour.WithEmoji(),
341− )
342− textStyle := lipgloss.NewStyle().Width(m.width - 4)
343− currentMessage := 1
344− displayedMsgCount := 0 // Track the actual displayed messages count
345−
346− prevMessageWasUser := false
347− for inx, msg := range m.messages {
348− content := msg.Content().String()
349− if content != "" || prevMessageWasUser || msg.FinishReason() == "canceled" {
350− if msg.ReasoningContent().String() != "" && content == "" {
351− content = msg.ReasoningContent().String()
352− } else if content == "" {
353− content = "..."
354− }
355− if msg.FinishReason() == "canceled" {
356− content, _ = r.Render(content)
357− content += lipgloss.NewStyle().Padding(1, 0, 0, 1).Foreground(styles.Error).Render(styles.ErrorIcon + " Canceled")
358− } else {
359− content, _ = r.Render(content)
360− }
361−
362− isSelected := inx == m.selectedMsgIdx
363−
364− border := lipgloss.DoubleBorder()
365− activeColor := borderColor(msg.Role)
366−
367− if isSelected {
368− activeColor = styles.Primary // Use primary color for selected message
369− }
370−
371− content = layout.Borderize(
372− textStyle.Render(content),
373− layout.BorderOptions{
374− InactiveBorder: border,
375− ActiveBorder: border,
376− ActiveColor: activeColor,
377− InactiveColor: borderColor(msg.Role),
378− EmbeddedText: borderText(msg.Role, currentMessage),
379− },
380− )
381− if len(msg.ToolCalls()) > 0 {
382− content = m.renderMessageWithToolCall(content, msg.ToolCalls(), m.messages[inx+1:])
383− }
384− stringMessages = append(stringMessages, content)
385− currentMessage++
386− displayedMsgCount++
387− }
388− if msg.Role == message.User && msg.Content().String() != "" {
389− prevMessageWasUser = true
390− } else {
391− prevMessageWasUser = false
392− }
393− }
394− m.viewport.SetContent(lipgloss.JoinVertical(lipgloss.Top, stringMessages...))
395−}
396−
397−func (m *messagesCmp) View() string {
398− return lipgloss.NewStyle().Padding(1).Render(m.viewport.View())
399−}
400−
401−func (m *messagesCmp) BindingKeys() []key.Binding {
402− keys := layout.KeyMapToSlice(m.viewport.KeyMap)
403−
404− return keys
405−}
406−
407−func (m *messagesCmp) Blur() tea.Cmd {
408− m.focused = false
409− return nil
410−}
411−
412−func (m *messagesCmp) projectDiagnostics() string {
413− errorDiagnostics := []protocol.Diagnostic{}
414− warnDiagnostics := []protocol.Diagnostic{}
415− hintDiagnostics := []protocol.Diagnostic{}
416− infoDiagnostics := []protocol.Diagnostic{}
417− for _, client := range m.app.LSPClients {
418− for _, d := range client.GetDiagnostics() {
419− for _, diag := range d {
420− switch diag.Severity {
421− case protocol.SeverityError:
422− errorDiagnostics = append(errorDiagnostics, diag)
423− case protocol.SeverityWarning:
424− warnDiagnostics = append(warnDiagnostics, diag)
425− case protocol.SeverityHint:
426− hintDiagnostics = append(hintDiagnostics, diag)
427− case protocol.SeverityInformation:
428− infoDiagnostics = append(infoDiagnostics, diag)
429− }
430− }
431− }
432− }
433−
434− if len(errorDiagnostics) == 0 && len(warnDiagnostics) == 0 && len(hintDiagnostics) == 0 && len(infoDiagnostics) == 0 {
435− return "No diagnostics"
436− }
437−
438− diagnostics := []string{}
439−
440− if len(errorDiagnostics) > 0 {
441− errStr := lipgloss.NewStyle().Foreground(styles.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, len(errorDiagnostics)))
442− diagnostics = append(diagnostics, errStr)
443− }
444− if len(warnDiagnostics) > 0 {
445− warnStr := lipgloss.NewStyle().Foreground(styles.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, len(warnDiagnostics)))
446− diagnostics = append(diagnostics, warnStr)
447− }
448− if len(hintDiagnostics) > 0 {
449− hintStr := lipgloss.NewStyle().Foreground(styles.Text).Render(fmt.Sprintf("%s %d", styles.HintIcon, len(hintDiagnostics)))
450− diagnostics = append(diagnostics, hintStr)
451− }
452− if len(infoDiagnostics) > 0 {
453− infoStr := lipgloss.NewStyle().Foreground(styles.Peach).Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics)))
454− diagnostics = append(diagnostics, infoStr)
455− }
456−
457− return strings.Join(diagnostics, " ")
458−}
459−
460−func (m *messagesCmp) BorderText() map[layout.BorderPosition]string {
461− title := m.session.Title
462− titleWidth := m.width / 2
463− if len(title) > titleWidth {
464− title = title[:titleWidth] + "..."
465− }
466− if m.focused {
467− title = lipgloss.NewStyle().Foreground(styles.Primary).Render(title)
468− }
469− borderTest := map[layout.BorderPosition]string{
470− layout.TopLeftBorder: title,
471− layout.BottomRightBorder: m.projectDiagnostics(),
472− }
473− if hasUnfinishedMessages(m.messages) {
474− borderTest[layout.BottomLeftBorder] = lipgloss.NewStyle().Foreground(styles.Peach).Render("Thinking...")
475− } else {
476− borderTest[layout.BottomLeftBorder] = lipgloss.NewStyle().Foreground(styles.Text).Render("Sleeping " + styles.SleepIcon + " ")
477− }
478−
479− return borderTest
480−}
481−
482−func (m *messagesCmp) Focus() tea.Cmd {
483− m.focused = true
484− return nil
485−}
486−
487−func (m *messagesCmp) GetSize() (int, int) {
488− return m.width, m.height
489−}
490−
491−func (m *messagesCmp) IsFocused() bool {
492− return m.focused
493−}
494−
495−func (m *messagesCmp) SetSize(width int, height int) {
496− m.width = width
497− m.height = height
498− m.viewport.Width = width - 2 // padding
499− m.viewport.Height = height - 2 // padding
500− m.renderView()
501−}
502−
503−func (m *messagesCmp) Init() tea.Cmd {
504− return nil
505−}
506−
507−func NewMessagesCmp(app *app.App) MessagesCmp {
508− return &messagesCmp{
509− app: app,
510− messages: []message.Message{},
511− viewport: viewport.New(0, 0),
512− }
513−}
internal/tui/components/repl/sessions.godeleted−249
@@ -1,249 +0,0 @@
1−package repl
2−
3−import (
4− "context"
5− "fmt"
6− "strings"
7−
8− "github.com/charmbracelet/bubbles/key"
9− "github.com/charmbracelet/bubbles/list"
10− tea "github.com/charmbracelet/bubbletea"
11− "github.com/charmbracelet/lipgloss"
12− "github.com/kujtimiihoxha/termai/internal/app"
13− "github.com/kujtimiihoxha/termai/internal/pubsub"
14− "github.com/kujtimiihoxha/termai/internal/session"
15− "github.com/kujtimiihoxha/termai/internal/tui/layout"
16− "github.com/kujtimiihoxha/termai/internal/tui/styles"
17− "github.com/kujtimiihoxha/termai/internal/tui/util"
18−)
19−
20−type SessionsCmp interface {
21− tea.Model
22− layout.Sizeable
23− layout.Focusable
24− layout.Bordered
25− layout.Bindings
26−}
27−type sessionsCmp struct {
28− app *app.App
29− list list.Model
30− focused bool
31−}
32−
33−type listItem struct {
34− id, title, desc string
35−}
36−
37−func (i listItem) Title() string { return i.title }
38−func (i listItem) Description() string { return i.desc }
39−func (i listItem) FilterValue() string { return i.title }
40−
41−type InsertSessionsMsg struct {
42− sessions []session.Session
43−}
44−
45−type SelectedSessionMsg struct {
46− SessionID string
47−}
48−
49−type sessionsKeyMap struct {
50− Select key.Binding
51−}
52−
53−var sessionKeyMapValue = sessionsKeyMap{
54− Select: key.NewBinding(
55− key.WithKeys("enter", " "),
56− key.WithHelp("enter/space", "select session"),
57− ),
58−}
59−
60−func (i *sessionsCmp) Init() tea.Cmd {
61− existing, err := i.app.Sessions.List(context.Background())
62− if err != nil {
63− return util.ReportError(err)
64− }
65− if len(existing) == 0 || existing[0].MessageCount > 0 {
66− newSession, err := i.app.Sessions.Create(
67− context.Background(),
68− "New Session",
69− )
70− if err != nil {
71− return util.ReportError(err)
72− }
73− existing = append([]session.Session{newSession}, existing...)
74− }
75− return tea.Batch(
76− util.CmdHandler(InsertSessionsMsg{existing}),
77− util.CmdHandler(SelectedSessionMsg{existing[0].ID}),
78− )
79−}
80−
81−func (i *sessionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
82− switch msg := msg.(type) {
83− case InsertSessionsMsg:
84− items := make([]list.Item, len(msg.sessions))
85− for i, s := range msg.sessions {
86− items[i] = listItem{
87− id: s.ID,
88− title: s.Title,
89− desc: formatTokensAndCost(s.PromptTokens+s.CompletionTokens, s.Cost),
90− }
91− }
92− return i, i.list.SetItems(items)
93− case pubsub.Event[session.Session]:
94− if msg.Type == pubsub.CreatedEvent && msg.Payload.ParentSessionID == "" {
95− // Check if the session is already in the list
96− items := i.list.Items()
97− for _, item := range items {
98− s := item.(listItem)
99− if s.id == msg.Payload.ID {
100− return i, nil
101− }
102− }
103− // insert the new session at the top of the list
104− items = append([]list.Item{listItem{
105− id: msg.Payload.ID,
106− title: msg.Payload.Title,
107− desc: formatTokensAndCost(msg.Payload.PromptTokens+msg.Payload.CompletionTokens, msg.Payload.Cost),
108− }}, items...)
109− return i, i.list.SetItems(items)
110− } else if msg.Type == pubsub.UpdatedEvent {
111− // update the session in the list
112− items := i.list.Items()
113− for idx, item := range items {
114− s := item.(listItem)
115− if s.id == msg.Payload.ID {
116− s.title = msg.Payload.Title
117− s.desc = formatTokensAndCost(msg.Payload.PromptTokens+msg.Payload.CompletionTokens, msg.Payload.Cost)
118− items[idx] = s
119− break
120− }
121− }
122− return i, i.list.SetItems(items)
123− }
124−
125− case tea.KeyMsg:
126− switch {
127− case key.Matches(msg, sessionKeyMapValue.Select):
128− selected := i.list.SelectedItem()
129− if selected == nil {
130− return i, nil
131− }
132− return i, util.CmdHandler(SelectedSessionMsg{selected.(listItem).id})
133− }
134− }
135− if i.focused {
136− u, cmd := i.list.Update(msg)
137− i.list = u
138− return i, cmd
139− }
140− return i, nil
141−}
142−
143−func (i *sessionsCmp) View() string {
144− return i.list.View()
145−}
146−
147−func (i *sessionsCmp) Blur() tea.Cmd {
148− i.focused = false
149− return nil
150−}
151−
152−func (i *sessionsCmp) Focus() tea.Cmd {
153− i.focused = true
154− return nil
155−}
156−
157−func (i *sessionsCmp) GetSize() (int, int) {
158− return i.list.Width(), i.list.Height()
159−}
160−
161−func (i *sessionsCmp) IsFocused() bool {
162− return i.focused
163−}
164−
165−func (i *sessionsCmp) SetSize(width int, height int) {
166− i.list.SetSize(width, height)
167−}
168−
169−func (i *sessionsCmp) BorderText() map[layout.BorderPosition]string {
170− totalCount := len(i.list.Items())
171− itemsPerPage := i.list.Paginator.PerPage
172− currentPage := i.list.Paginator.Page
173−
174− current := min(currentPage*itemsPerPage+itemsPerPage, totalCount)
175−
176− pageInfo := fmt.Sprintf(
177− "%d-%d of %d",
178− currentPage*itemsPerPage+1,
179− current,
180− totalCount,
181− )
182−
183− title := "Sessions"
184− if i.focused {
185− title = lipgloss.NewStyle().Foreground(styles.Primary).Render(title)
186− }
187− return map[layout.BorderPosition]string{
188− layout.TopMiddleBorder: title,
189− layout.BottomMiddleBorder: pageInfo,
190− }
191−}
192−
193−func (i *sessionsCmp) BindingKeys() []key.Binding {
194− return append(layout.KeyMapToSlice(i.list.KeyMap), sessionKeyMapValue.Select)
195−}
196−
197−func formatTokensAndCost(tokens int64, cost float64) string {
198− // Format tokens in human-readable format (e.g., 110K, 1.2M)
199− var formattedTokens string
200− switch {
201− case tokens >= 1_000_000:
202− formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
203− case tokens >= 1_000:
204− formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
205− default:
206− formattedTokens = fmt.Sprintf("%d", tokens)
207− }
208−
209− // Remove .0 suffix if present
210− if strings.HasSuffix(formattedTokens, ".0K") {
211− formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
212− }
213− if strings.HasSuffix(formattedTokens, ".0M") {
214− formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
215− }
216−
217− // Format cost with $ symbol and 2 decimal places
218− formattedCost := fmt.Sprintf("$%.2f", cost)
219−
220− return fmt.Sprintf("Tokens: %s, Cost: %s", formattedTokens, formattedCost)
221−}
222−
223−func NewSessionsCmp(app *app.App) SessionsCmp {
224− listDelegate := list.NewDefaultDelegate()
225− defaultItemStyle := list.NewDefaultItemStyles()
226− defaultItemStyle.SelectedTitle = defaultItemStyle.SelectedTitle.BorderForeground(styles.Secondary).Foreground(styles.Primary)
227− defaultItemStyle.SelectedDesc = defaultItemStyle.SelectedDesc.BorderForeground(styles.Secondary).Foreground(styles.Primary)
228−
229− defaultStyle := list.DefaultStyles()
230− defaultStyle.FilterPrompt = defaultStyle.FilterPrompt.Foreground(styles.Secondary)
231− defaultStyle.FilterCursor = defaultStyle.FilterCursor.Foreground(styles.Flamingo)
232−
233− listDelegate.Styles = defaultItemStyle
234−
235− listComponent := list.New([]list.Item{}, listDelegate, 0, 0)
236− listComponent.FilterInput.PromptStyle = defaultStyle.FilterPrompt
237− listComponent.FilterInput.Cursor.Style = defaultStyle.FilterCursor
238− listComponent.SetShowTitle(false)
239− listComponent.SetShowPagination(false)
240− listComponent.SetShowHelp(false)
241− listComponent.SetShowStatusBar(false)
242− listComponent.DisableQuitKeybindings()
243−
244− return &sessionsCmp{
245− app: app,
246− list: listComponent,
247− focused: false,
248− }
249−}
internal/tui/layout/overlay.go+6−5
@@ -5,6 +5,7 @@ import (
55 "strings"
66
77 "github.com/charmbracelet/lipgloss"
8+ "github.com/kujtimiihoxha/termai/internal/tui/styles"
89 "github.com/kujtimiihoxha/termai/internal/tui/util"
910 "github.com/mattn/go-runewidth"
1011 "github.com/muesli/ansi"
@@ -45,13 +46,15 @@ func PlaceOverlay(
4546 if shadow {
4647 var shadowbg string = ""
4748 shadowchar := lipgloss.NewStyle().
48− Foreground(lipgloss.Color("#333333")).
49+ Background(styles.BackgroundDarker).
50+ Foreground(styles.Background).
4951 Render("░")
52+ bgchar := styles.BaseStyle.Render(" ")
5053 for i := 0; i <= fgHeight; i++ {
5154 if i == 0 {
52− shadowbg += " " + strings.Repeat(" ", fgWidth) + "\n"
55+ shadowbg += bgchar + strings.Repeat(bgchar, fgWidth) + "\n"
5356 } else {
54− shadowbg += " " + strings.Repeat(shadowchar, fgWidth) + "\n"
57+ shadowbg += bgchar + strings.Repeat(shadowchar, fgWidth) + "\n"
5558 }
5659 }
5760
@@ -159,8 +162,6 @@ func max(a, b int) int {
159162 return b
160163}
161164
162−
163−
164165type whitespace struct {
165166 style termenv.Style
166167 chars string
internal/tui/layout/split.go+1
@@ -10,6 +10,7 @@ import (
1010type SplitPaneLayout interface {
1111 tea.Model
1212 Sizeable
13+ Bindings
1314 SetLeftPanel(panel Container)
1415 SetRightPanel(panel Container)
1516 SetBottomPanel(panel Container)
internal/tui/page/chat.go+25−7
@@ -37,7 +37,6 @@ var keyMap = ChatKeyMap{
3737}
3838
3939func (p *chatPage) Init() tea.Cmd {
40− // TODO: remove
4140 cmds := []tea.Cmd{
4241 p.layout.Init(),
4342 }
@@ -48,9 +47,7 @@ func (p *chatPage) Init() tea.Cmd {
4847 cmd := p.setSidebar()
4948 cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(p.session)), cmd)
5049 }
51− return tea.Batch(
52− cmds...,
53− )
50+ return tea.Batch(cmds...)
5451}
5552
5653func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -68,6 +65,13 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
6865 p.session = session.Session{}
6966 p.clearSidebar()
7067 return p, util.CmdHandler(chat.SessionClearedMsg{})
68+ case key.Matches(msg, keyMap.Cancel):
69+ if p.session.ID != "" {
70+ // Cancel the current session's generation process
71+ // This allows users to interrupt long-running operations
72+ p.app.CoderAgent.Cancel(p.session.ID)
73+ return p, nil
74+ }
7175 }
7276 }
7377 u, cmd := p.layout.Update(msg)
@@ -80,7 +84,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
8084
8185func (p *chatPage) setSidebar() tea.Cmd {
8286 sidebarContainer := layout.NewContainer(
83− chat.NewSidebarCmp(p.session),
87+ chat.NewSidebarCmp(p.session, p.app.History),
8488 layout.WithPadding(1, 1, 1, 1),
8589 )
8690 p.layout.SetRightPanel(sidebarContainer)
@@ -111,14 +115,28 @@ func (p *chatPage) sendMessage(text string) tea.Cmd {
111115 cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
112116 }
113117
114− p.app.CoderAgent.Generate(context.Background(), p.session.ID, text)
118+ p.app.CoderAgent.Run(context.Background(), p.session.ID, text)
115119 return tea.Batch(cmds...)
116120}
117121
122+func (p *chatPage) SetSize(width, height int) {
123+ p.layout.SetSize(width, height)
124+}
125+
126+func (p *chatPage) GetSize() (int, int) {
127+ return p.layout.GetSize()
128+}
129+
118130func (p *chatPage) View() string {
119131 return p.layout.View()
120132}
121133
134+func (p *chatPage) BindingKeys() []key.Binding {
135+ bindings := layout.KeyMapToSlice(keyMap)
136+ bindings = append(bindings, p.layout.BindingKeys()...)
137+ return bindings
138+}
139+
122140func NewChatPage(app *app.App) tea.Model {
123141 messagesContainer := layout.NewContainer(
124142 chat.NewMessagesCmp(app),
@@ -126,7 +144,7 @@ func NewChatPage(app *app.App) tea.Model {
126144 )
127145
128146 editorContainer := layout.NewContainer(
129− chat.NewEditorCmp(),
147+ chat.NewEditorCmp(app),
130148 layout.WithBorder(true, false, false, false),
131149 )
132150 return &chatPage{
internal/tui/page/init.godeleted−308
@@ -1,308 +0,0 @@
1−package page
2−
3−import (
4− "fmt"
5− "os"
6− "path/filepath"
7− "strconv"
8−
9− "github.com/charmbracelet/bubbles/key"
10− tea "github.com/charmbracelet/bubbletea"
11− "github.com/charmbracelet/huh"
12− "github.com/charmbracelet/lipgloss"
13− "github.com/kujtimiihoxha/termai/internal/llm/models"
14− "github.com/kujtimiihoxha/termai/internal/tui/layout"
15− "github.com/kujtimiihoxha/termai/internal/tui/styles"
16− "github.com/kujtimiihoxha/termai/internal/tui/util"
17− "github.com/spf13/viper"
18−)
19−
20−var InitPage PageID = "init"
21−
22−type configSaved struct{}
23−
24−type initPage struct {
25− form *huh.Form
26− width int
27− height int
28− saved bool
29− errorMsg string
30− statusMsg string
31− modelOpts []huh.Option[string]
32− bigModel string
33− smallModel string
34− openAIKey string
35− anthropicKey string
36− groqKey string
37− maxTokens string
38− dataDir string
39− agent string
40−}
41−
42−func (i *initPage) Init() tea.Cmd {
43− return i.form.Init()
44−}
45−
46−func (i *initPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
47− var cmds []tea.Cmd
48−
49− switch msg := msg.(type) {
50− case tea.WindowSizeMsg:
51− i.width = msg.Width - 4 // Account for border
52− i.height = msg.Height - 4
53− i.form = i.form.WithWidth(i.width).WithHeight(i.height)
54− return i, nil
55−
56− case configSaved:
57− i.saved = true
58− i.statusMsg = "Configuration saved successfully. Press any key to continue."
59− return i, nil
60− }
61−
62− if i.saved {
63− switch msg.(type) {
64− case tea.KeyMsg:
65− return i, util.CmdHandler(PageChangeMsg{ID: ReplPage})
66− }
67− return i, nil
68− }
69−
70− // Process the form
71− form, cmd := i.form.Update(msg)
72− if f, ok := form.(*huh.Form); ok {
73− i.form = f
74− cmds = append(cmds, cmd)
75− }
76−
77− if i.form.State == huh.StateCompleted {
78− // Save configuration to file
79− configPath := filepath.Join(os.Getenv("HOME"), ".termai.yaml")
80− maxTokens, _ := strconv.Atoi(i.maxTokens)
81− config := map[string]any{
82− "models": map[string]string{
83− "big": i.bigModel,
84− "small": i.smallModel,
85− },
86− "providers": map[string]any{
87− "openai": map[string]string{
88− "key": i.openAIKey,
89− },
90− "anthropic": map[string]string{
91− "key": i.anthropicKey,
92− },
93− "groq": map[string]string{
94− "key": i.groqKey,
95− },
96− "common": map[string]int{
97− "max_tokens": maxTokens,
98− },
99− },
100− "data": map[string]string{
101− "dir": i.dataDir,
102− },
103− "agents": map[string]string{
104− "default": i.agent,
105− },
106− "log": map[string]string{
107− "level": "info",
108− },
109− }
110−
111− // Write config to viper
112− for k, v := range config {
113− viper.Set(k, v)
114− }
115−
116− // Save configuration
117− err := viper.WriteConfigAs(configPath)
118− if err != nil {
119− i.errorMsg = fmt.Sprintf("Failed to save configuration: %s", err)
120− return i, nil
121− }
122−
123− // Return to main page
124− return i, util.CmdHandler(configSaved{})
125− }
126−
127− return i, tea.Batch(cmds...)
128−}
129−
130−func (i *initPage) View() string {
131− if i.saved {
132− return lipgloss.NewStyle().
133− Width(i.width).
134− Height(i.height).
135− Align(lipgloss.Center, lipgloss.Center).
136− Render(lipgloss.JoinVertical(
137− lipgloss.Center,
138− lipgloss.NewStyle().Foreground(styles.Green).Render("✓ Configuration Saved"),
139− "",
140− lipgloss.NewStyle().Foreground(styles.Blue).Render(i.statusMsg),
141− ))
142− }
143−
144− view := i.form.View()
145− if i.errorMsg != "" {
146− errorBox := lipgloss.NewStyle().
147− Padding(1).
148− Border(lipgloss.RoundedBorder()).
149− BorderForeground(styles.Red).
150− Width(i.width - 4).
151− Render(i.errorMsg)
152− view = lipgloss.JoinVertical(lipgloss.Left, errorBox, view)
153− }
154− return view
155−}
156−
157−func (i *initPage) GetSize() (int, int) {
158− return i.width, i.height
159−}
160−
161−func (i *initPage) SetSize(width int, height int) {
162− i.width = width
163− i.height = height
164− i.form = i.form.WithWidth(width).WithHeight(height)
165−}
166−
167−func (i *initPage) BindingKeys() []key.Binding {
168− if i.saved {
169− return []key.Binding{
170− key.NewBinding(
171− key.WithKeys("enter", "space", "esc"),
172− key.WithHelp("any key", "continue"),
173− ),
174− }
175− }
176− return i.form.KeyBinds()
177−}
178−
179−func NewInitPage() tea.Model {
180− // Create model options
181− var modelOpts []huh.Option[string]
182− for id, model := range models.SupportedModels {
183− modelOpts = append(modelOpts, huh.NewOption(model.Name, string(id)))
184− }
185−
186− // Create agent options
187− agentOpts := []huh.Option[string]{
188− huh.NewOption("Coder", "coder"),
189− huh.NewOption("Assistant", "assistant"),
190− }
191−
192− // Init page with form
193− initModel := &initPage{
194− modelOpts: modelOpts,
195− bigModel: string(models.Claude37Sonnet),
196− smallModel: string(models.Claude37Sonnet),
197− maxTokens: "4000",
198− dataDir: ".termai",
199− agent: "coder",
200− }
201−
202− // API Keys group
203− apiKeysGroup := huh.NewGroup(
204− huh.NewNote().
205− Title("API Keys").
206− Description("You need to provide at least one API key to use termai"),
207−
208− huh.NewInput().
209− Title("OpenAI API Key").
210− Placeholder("sk-...").
211− Key("openai_key").
212− Value(&initModel.openAIKey),
213−
214− huh.NewInput().
215− Title("Anthropic API Key").
216− Placeholder("sk-ant-...").
217− Key("anthropic_key").
218− Value(&initModel.anthropicKey),
219−
220− huh.NewInput().
221− Title("Groq API Key").
222− Placeholder("gsk_...").
223− Key("groq_key").
224− Value(&initModel.groqKey),
225− )
226−
227− // Model configuration group
228− modelsGroup := huh.NewGroup(
229− huh.NewNote().
230− Title("Model Configuration").
231− Description("Select which models to use"),
232−
233− huh.NewSelect[string]().
234− Title("Big Model").
235− Options(modelOpts...).
236− Key("big_model").
237− Value(&initModel.bigModel),
238−
239− huh.NewSelect[string]().
240− Title("Small Model").
241− Options(modelOpts...).
242− Key("small_model").
243− Value(&initModel.smallModel),
244−
245− huh.NewInput().
246− Title("Max Tokens").
247− Placeholder("4000").
248− Key("max_tokens").
249− CharLimit(5).
250− Validate(func(s string) error {
251− var n int
252− _, err := fmt.Sscanf(s, "%d", &n)
253− if err != nil || n <= 0 {
254− return fmt.Errorf("must be a positive number")
255− }
256− initModel.maxTokens = s
257− return nil
258− }).
259− Value(&initModel.maxTokens),
260− )
261−
262− // General settings group
263− generalGroup := huh.NewGroup(
264− huh.NewNote().
265− Title("General Settings").
266− Description("Configure general termai settings"),
267−
268− huh.NewInput().
269− Title("Data Directory").
270− Placeholder(".termai").
271− Key("data_dir").
272− Value(&initModel.dataDir),
273−
274− huh.NewSelect[string]().
275− Title("Default Agent").
276− Options(agentOpts...).
277− Key("agent").
278− Value(&initModel.agent),
279−
280− huh.NewConfirm().
281− Title("Save Configuration").
282− Affirmative("Save").
283− Negative("Cancel"),
284− )
285−
286− // Create form with theme
287− form := huh.NewForm(
288− apiKeysGroup,
289− modelsGroup,
290− generalGroup,
291− ).WithTheme(styles.HuhTheme()).
292− WithShowHelp(true).
293− WithShowErrors(true)
294−
295− // Set the form in the model
296− initModel.form = form
297−
298− return layout.NewSinglePane(
299− initModel,
300− layout.WithSinglePaneFocusable(true),
301− layout.WithSinglePaneBordered(true),
302− layout.WithSinglePaneBorderText(
303− map[layout.BorderPosition]string{
304− layout.TopMiddleBorder: "Welcome to termai - Initial Setup",
305− },
306− ),
307− )
308−}
internal/tui/page/logs.go+17
@@ -8,6 +8,23 @@ import (
88
99var LogsPage PageID = "logs"
1010
11+type logsPage struct {
12+ table logs.TableComponent
13+ details logs.DetailComponent
14+}
15+
16+func (p *logsPage) Init() tea.Cmd {
17+ return nil
18+}
19+
20+func (p *logsPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
21+ return p, nil
22+}
23+
24+func (p *logsPage) View() string {
25+ return p.table.View() + "\n" + p.details.View()
26+}
27+
1128func NewLogsPage() tea.Model {
1229 return layout.NewBentoLayout(
1330 layout.BentoPanes{
internal/tui/page/repl.godeleted−21
@@ -1,21 +0,0 @@
1−package page
2−
3−import (
4− tea "github.com/charmbracelet/bubbletea"
5− "github.com/kujtimiihoxha/termai/internal/app"
6− "github.com/kujtimiihoxha/termai/internal/tui/components/repl"
7− "github.com/kujtimiihoxha/termai/internal/tui/layout"
8−)
9−
10−var ReplPage PageID = "repl"
11−
12−func NewReplPage(app *app.App) tea.Model {
13− return layout.NewBentoLayout(
14− layout.BentoPanes{
15− layout.BentoLeftPane: repl.NewSessionsCmp(app),
16− layout.BentoRightTopPane: repl.NewMessagesCmp(app),
17− layout.BentoRightBottomPane: repl.NewEditorCmp(app),
18− },
19− layout.WithBentoLayoutCurrentPane(layout.BentoRightBottomPane),
20− )
21−}
internal/tui/tui.go+141−136
@@ -1,8 +1,6 @@
11package tui
22
33import (
4− "context"
5−
64 "github.com/charmbracelet/bubbles/key"
75 tea "github.com/charmbracelet/bubbletea"
86 "github.com/charmbracelet/lipgloss"
@@ -12,47 +10,41 @@ import (
1210 "github.com/kujtimiihoxha/termai/internal/pubsub"
1311 "github.com/kujtimiihoxha/termai/internal/tui/components/core"
1412 "github.com/kujtimiihoxha/termai/internal/tui/components/dialog"
15− "github.com/kujtimiihoxha/termai/internal/tui/components/repl"
1613 "github.com/kujtimiihoxha/termai/internal/tui/layout"
1714 "github.com/kujtimiihoxha/termai/internal/tui/page"
1815 "github.com/kujtimiihoxha/termai/internal/tui/util"
19− "github.com/kujtimiihoxha/vimtea"
2016)
2117
2218type keyMap struct {
23− Logs key.Binding
24− Return key.Binding
25− Back key.Binding
26− Quit key.Binding
27− Help key.Binding
19+ Logs key.Binding
20+ Quit key.Binding
21+ Help key.Binding
2822}
2923
3024var keys = keyMap{
3125 Logs: key.NewBinding(
32− key.WithKeys("L"),
33− key.WithHelp("L", "logs"),
34− ),
35− Return: key.NewBinding(
36− key.WithKeys("esc"),
37− key.WithHelp("esc", "close"),
38− ),
39− Back: key.NewBinding(
40− key.WithKeys("backspace"),
41− key.WithHelp("backspace", "back"),
26+ key.WithKeys("ctrl+l"),
27+ key.WithHelp("ctrl+L", "logs"),
4228 ),
29+
4330 Quit: key.NewBinding(
44− key.WithKeys("ctrl+c", "q"),
45− key.WithHelp("ctrl+c/q", "quit"),
31+ key.WithKeys("ctrl+c"),
32+ key.WithHelp("ctrl+c", "quit"),
4633 ),
4734 Help: key.NewBinding(
48− key.WithKeys("?"),
49− key.WithHelp("?", "toggle help"),
35+ key.WithKeys("ctrl+_"),
36+ key.WithHelp("ctrl+?", "toggle help"),
5037 ),
5138}
5239
53−var replKeyMap = key.NewBinding(
54− key.WithKeys("N"),
55− key.WithHelp("N", "new session"),
40+var returnKey = key.NewBinding(
41+ key.WithKeys("esc"),
42+ key.WithHelp("esc", "close"),
43+)
44+
45+var logsKeyReturnKey = key.NewBinding(
46+ key.WithKeys("backspace"),
47+ key.WithHelp("backspace", "go back"),
5648)
5749
5850type appModel struct {
@@ -62,18 +54,30 @@ type appModel struct {
6254 pages map[page.PageID]tea.Model
6355 loadedPages map[page.PageID]bool
6456 status tea.Model
65− help core.HelpCmp
66− dialog core.DialogCmp
6757 app *app.App
68− dialogVisible bool
69− editorMode vimtea.EditorMode
70− showHelp bool
58+
59+ showPermissions bool
60+ permissions dialog.PermissionDialogCmp
61+
62+ showHelp bool
63+ help dialog.HelpCmp
64+
65+ showQuit bool
66+ quit dialog.QuitDialog
7167}
7268
7369func (a appModel) Init() tea.Cmd {
70+ var cmds []tea.Cmd
7471 cmd := a.pages[a.currentPage].Init()
7572 a.loadedPages[a.currentPage] = true
76− return cmd
73+ cmds = append(cmds, cmd)
74+ cmd = a.status.Init()
75+ cmds = append(cmds, cmd)
76+ cmd = a.quit.Init()
77+ cmds = append(cmds, cmd)
78+ cmd = a.help.Init()
79+ cmds = append(cmds, cmd)
80+ return tea.Batch(cmds...)
7781}
7882
7983func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -81,22 +85,20 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
8185 var cmd tea.Cmd
8286 switch msg := msg.(type) {
8387 case tea.WindowSizeMsg:
84− var cmds []tea.Cmd
8588 msg.Height -= 1 // Make space for the status bar
8689 a.width, a.height = msg.Width, msg.Height
8790
8891 a.status, _ = a.status.Update(msg)
89−
90− uh, _ := a.help.Update(msg)
91− a.help = uh.(core.HelpCmp)
92−
93− p, cmd := a.pages[a.currentPage].Update(msg)
92+ a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
9493 cmds = append(cmds, cmd)
95− a.pages[a.currentPage] = p
9694
97− d, cmd := a.dialog.Update(msg)
98− cmds = append(cmds, cmd)
99− a.dialog = d.(core.DialogCmp)
95+ prm, permCmd := a.permissions.Update(msg)
96+ a.permissions = prm.(dialog.PermissionDialogCmp)
97+ cmds = append(cmds, permCmd)
98+
99+ help, helpCmd := a.help.Update(msg)
100+ a.help = help.(dialog.HelpCmp)
101+ cmds = append(cmds, helpCmd)
100102
101103 return a, tea.Batch(cmds...)
102104
@@ -141,7 +143,9 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
141143
142144 // Permission
143145 case pubsub.Event[permission.PermissionRequest]:
144− return a, dialog.NewPermissionDialogCmd(msg.Payload)
146+ a.showPermissions = true
147+ a.permissions.SetPermissions(msg.Payload)
148+ return a, nil
145149 case dialog.PermissionResponseMsg:
146150 switch msg.Action {
147151 case dialog.PermissionAllow:
@@ -151,91 +155,71 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
151155 case dialog.PermissionDeny:
152156 a.app.Permissions.Deny(msg.Permission)
153157 }
154−
155− // Dialog
156− case core.DialogMsg:
157− d, cmd := a.dialog.Update(msg)
158− a.dialog = d.(core.DialogCmp)
159− a.dialogVisible = true
160− return a, cmd
161− case core.DialogCloseMsg:
162− d, cmd := a.dialog.Update(msg)
163− a.dialog = d.(core.DialogCmp)
164− a.dialogVisible = false
165− return a, cmd
166−
167− // Editor
168− case vimtea.EditorModeMsg:
169− a.editorMode = msg.Mode
158+ a.showPermissions = false
159+ return a, nil
170160
171161 case page.PageChangeMsg:
172162 return a, a.moveToPage(msg.ID)
163+
164+ case dialog.CloseQuitMsg:
165+ a.showQuit = false
166+ return a, nil
167+
173168 case tea.KeyMsg:
174− if a.editorMode == vimtea.ModeNormal {
175− switch {
176− case key.Matches(msg, keys.Quit):
177− return a, dialog.NewQuitDialogCmd()
178− case key.Matches(msg, keys.Back):
179− if a.previousPage != "" {
180− return a, a.moveToPage(a.previousPage)
181− }
182− case key.Matches(msg, keys.Return):
183− if a.showHelp {
184− a.ToggleHelp()
185− return a, nil
186− }
187− case key.Matches(msg, replKeyMap):
188− if a.currentPage == page.ReplPage {
189− sessions, err := a.app.Sessions.List(context.Background())
190− if err != nil {
191− return a, util.CmdHandler(util.ReportError(err))
192− }
193− lastSession := sessions[0]
194− if lastSession.MessageCount == 0 {
195− return a, util.CmdHandler(repl.SelectedSessionMsg{SessionID: lastSession.ID})
196− }
197− s, err := a.app.Sessions.Create(context.Background(), "New Session")
198− if err != nil {
199− return a, util.CmdHandler(util.ReportError(err))
200− }
201− return a, util.CmdHandler(repl.SelectedSessionMsg{SessionID: s.ID})
202− }
203− // case key.Matches(msg, keys.Logs):
204− // return a, a.moveToPage(page.LogsPage)
205− case msg.String() == "O":
206− return a, a.moveToPage(page.ReplPage)
207− case key.Matches(msg, keys.Help):
208− a.ToggleHelp()
169+ switch {
170+ case key.Matches(msg, keys.Quit):
171+ a.showQuit = !a.showQuit
172+ if a.showHelp {
173+ a.showHelp = false
174+ }
175+ return a, nil
176+ case key.Matches(msg, logsKeyReturnKey):
177+ if a.currentPage == page.LogsPage {
178+ return a, a.moveToPage(page.ChatPage)
179+ }
180+ case key.Matches(msg, returnKey):
181+ if a.showQuit {
182+ a.showQuit = !a.showQuit
183+ return a, nil
184+ }
185+ if a.showHelp {
186+ a.showHelp = !a.showHelp
187+ return a, nil
188+ }
189+ case key.Matches(msg, keys.Logs):
190+ return a, a.moveToPage(page.LogsPage)
191+ case key.Matches(msg, keys.Help):
192+ if a.showQuit {
209193 return a, nil
210194 }
195+ a.showHelp = !a.showHelp
196+ return a, nil
211197 }
212198 }
213199
214− if a.dialogVisible {
215− d, cmd := a.dialog.Update(msg)
216− a.dialog = d.(core.DialogCmp)
217− cmds = append(cmds, cmd)
218− return a, tea.Batch(cmds...)
200+ if a.showQuit {
201+ q, quitCmd := a.quit.Update(msg)
202+ a.quit = q.(dialog.QuitDialog)
203+ cmds = append(cmds, quitCmd)
204+ // Only block key messages send all other messages down
205+ if _, ok := msg.(tea.KeyMsg); ok {
206+ return a, tea.Batch(cmds...)
207+ }
208+ }
209+ if a.showPermissions {
210+ d, permissionsCmd := a.permissions.Update(msg)
211+ a.permissions = d.(dialog.PermissionDialogCmp)
212+ cmds = append(cmds, permissionsCmd)
213+ // Only block key messages send all other messages down
214+ if _, ok := msg.(tea.KeyMsg); ok {
215+ return a, tea.Batch(cmds...)
216+ }
219217 }
220218 a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
221219 cmds = append(cmds, cmd)
222220 return a, tea.Batch(cmds...)
223221}
224222
225−func (a *appModel) ToggleHelp() {
226− if a.showHelp {
227− a.showHelp = false
228− a.height += a.help.Height()
229− } else {
230− a.showHelp = true
231− a.height -= a.help.Height()
232− }
233−
234− if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
235− sizable.SetSize(a.width, a.height)
236− }
237−}
238−
239223func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
240224 var cmd tea.Cmd
241225 if _, ok := a.loadedPages[pageID]; !ok {
@@ -256,27 +240,55 @@ func (a appModel) View() string {
256240 a.pages[a.currentPage].View(),
257241 }
258242
243+ components = append(components, a.status.View())
244+
245+ appView := lipgloss.JoinVertical(lipgloss.Top, components...)
246+
247+ if a.showPermissions {
248+ overlay := a.permissions.View()
249+ row := lipgloss.Height(appView) / 2
250+ row -= lipgloss.Height(overlay) / 2
251+ col := lipgloss.Width(appView) / 2
252+ col -= lipgloss.Width(overlay) / 2
253+ appView = layout.PlaceOverlay(
254+ col,
255+ row,
256+ overlay,
257+ appView,
258+ true,
259+ )
260+ }
261+
259262 if a.showHelp {
260263 bindings := layout.KeyMapToSlice(keys)
261264 if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
262265 bindings = append(bindings, p.BindingKeys()...)
263266 }
264− if a.dialogVisible {
265− bindings = append(bindings, a.dialog.BindingKeys()...)
267+ if a.showPermissions {
268+ bindings = append(bindings, a.permissions.BindingKeys()...)
266269 }
267− if a.currentPage == page.ReplPage {
268− bindings = append(bindings, replKeyMap)
270+ if a.currentPage == page.LogsPage {
271+ bindings = append(bindings, logsKeyReturnKey)
269272 }
270− a.help.SetBindings(bindings)
271− components = append(components, a.help.View())
272− }
273273
274− components = append(components, a.status.View())
274+ a.help.SetBindings(bindings)
275275
276− appView := lipgloss.JoinVertical(lipgloss.Top, components...)
276+ overlay := a.help.View()
277+ row := lipgloss.Height(appView) / 2
278+ row -= lipgloss.Height(overlay) / 2
279+ col := lipgloss.Width(appView) / 2
280+ col -= lipgloss.Width(overlay) / 2
281+ appView = layout.PlaceOverlay(
282+ col,
283+ row,
284+ overlay,
285+ appView,
286+ true,
287+ )
288+ }
277289
278− if a.dialogVisible {
279− overlay := a.dialog.View()
290+ if a.showQuit {
291+ overlay := a.quit.View()
280292 row := lipgloss.Height(appView) / 2
281293 row -= lipgloss.Height(overlay) / 2
282294 col := lipgloss.Width(appView) / 2
@@ -289,30 +301,23 @@ func (a appModel) View() string {
289301 true,
290302 )
291303 }
304+
292305 return appView
293306}
294307
295308func New(app *app.App) tea.Model {
296− // homedir, _ := os.UserHomeDir()
297− // configPath := filepath.Join(homedir, ".termai.yaml")
298− //
299309 startPage := page.ChatPage
300− // if _, err := os.Stat(configPath); os.IsNotExist(err) {
301− // startPage = page.InitPage
302− // }
303−
304310 return &appModel{
305311 currentPage: startPage,
306312 loadedPages: make(map[page.PageID]bool),
307− status: core.NewStatusCmp(),
308− help: core.NewHelpCmp(),
309− dialog: core.NewDialogCmp(),
313+ status: core.NewStatusCmp(app.LSPClients),
314+ help: dialog.NewHelpCmp(),
315+ quit: dialog.NewQuitCmp(),
316+ permissions: dialog.NewPermissionDialogCmp(),
310317 app: app,
311318 pages: map[page.PageID]tea.Model{
312319 page.ChatPage: page.NewChatPage(app),
313320 page.LogsPage: page.NewLogsPage(),
314− page.InitPage: page.NewInitPage(),
315− page.ReplPage: page.NewReplPage(app),
316321 },
317322 }
318323}
main.go+7
@@ -2,8 +2,15 @@ package main
22
33import (
44 "github.com/kujtimiihoxha/termai/cmd"
5+ "github.com/kujtimiihoxha/termai/internal/logging"
56)
67
78func main() {
9+ // Set up panic recovery for the main function
10+ defer logging.RecoverPanic("main", func() {
11+ // Perform any necessary cleanup before exit
12+ logging.ErrorPersist("Application terminated due to unhandled panic")
13+ })
14+
815 cmd.Execute()
916}