on main help sign in
rework llm
afd9ad0Kujtim Hoxha
.gitignore+2
@@ -42,3 +42,5 @@ debug.log
42.env.local
42.env.local
43
43
44.termai
44.termai
45+
46+.termai.json
README.md+94−7
@@ -1,13 +1,100 @@
1# TermAI
1# TermAI
2
2
3−**⚠️ WORK IN PROGRESS ⚠️**
3+A powerful terminal-based AI assistant for developers, providing intelligent coding assistance directly in your terminal.
4
4
5−This project is currently under active development.
5+[![TermAI Demo](https://asciinema.org/a/dtc4nJyGSZX79HRUmFLY3gmoy.svg)](https://asciinema.org/a/dtc4nJyGSZX79HRUmFLY3gmoy)
6
6
7−## Current Progress
7+## Overview
8
8
9−- Initial CLI setup
9+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.
10−- Basic functionality implementation
11−- Working on core features
12
10
13−More details coming soon.
11+## Features
12+
13+- **Interactive TUI**: Built with [Bubble Tea](https://github.com/charmbracelet/bubbletea) for a smooth terminal experience
14+- **Multiple AI Providers**: Support for OpenAI, Anthropic Claude, and Google Gemini models
15+- **Session Management**: Save and manage multiple conversation sessions
16+- **Tool Integration**: AI can execute commands, search files, and modify code
17+- **Vim-like Editor**: Integrated editor with Vim keybindings for text input
18+- **Persistent Storage**: SQLite database for storing conversations and sessions
19+
20+## Installation
21+
22+```bash
23+# Coming soon
24+go install github.com/kujtimiihoxha/termai@latest
25+```
26+
27+## Configuration
28+
29+TermAI looks for configuration in the following locations:
30+
31+- `$HOME/.termai.json`
32+- `$XDG_CONFIG_HOME/termai/.termai.json`
33+- `./.termai.json` (local directory)
34+
35+You can also use environment variables:
36+
37+- `ANTHROPIC_API_KEY`: For Claude models
38+- `OPENAI_API_KEY`: For OpenAI models
39+- `GEMINI_API_KEY`: For Google Gemini models
40+
41+## Usage
42+
43+```bash
44+# Start TermAI
45+termai
46+
47+# Start with debug logging
48+termai -d
49+```
50+
51+### Keyboard Shortcuts
52+
53+- `L`: View logs
54+- `N`: Create new session
55+- `Esc`: Close current view
56+- `Backspace`: Go back
57+- `Ctrl+C` or `q`: Quit
58+- `?`: Toggle help
59+
60+## Architecture
61+
62+TermAI is built with a modular architecture:
63+
64+- **cmd**: Command-line interface using Cobra
65+- **internal/app**: Core application services
66+- **internal/config**: Configuration management
67+- **internal/db**: Database operations and migrations
68+- **internal/llm**: LLM providers and tools integration
69+- **internal/tui**: Terminal UI components and layouts
70+- **internal/logging**: Logging infrastructure
71+- **internal/message**: Message handling
72+- **internal/session**: Session management
73+
74+## Development
75+
76+### Prerequisites
77+
78+- Go 1.23.5 or higher
79+
80+### Building from Source
81+
82+```bash
83+# Clone the repository
84+git clone https://github.com/kujtimiihoxha/termai.git
85+cd termai
86+
87+# Build
88+go build -o termai
89+
90+# Run
91+./termai
92+```
93+
94+## License
95+
96+[License information coming soon]
97+
98+## Contributing
99+
100+[Contribution guidelines coming soon]
cmd/root.go+7−49
@@ -7,12 +7,12 @@ import (
7
7
8 tea "github.com/charmbracelet/bubbletea"
8 tea "github.com/charmbracelet/bubbletea"
9 "github.com/kujtimiihoxha/termai/internal/app"
9 "github.com/kujtimiihoxha/termai/internal/app"
10+ "github.com/kujtimiihoxha/termai/internal/config"
10 "github.com/kujtimiihoxha/termai/internal/db"
11 "github.com/kujtimiihoxha/termai/internal/db"
11− "github.com/kujtimiihoxha/termai/internal/llm/models"
12+ "github.com/kujtimiihoxha/termai/internal/llm/agent"
12 "github.com/kujtimiihoxha/termai/internal/tui"
13 "github.com/kujtimiihoxha/termai/internal/tui"
13 zone "github.com/lrstanley/bubblezone"
14 zone "github.com/lrstanley/bubblezone"
14 "github.com/spf13/cobra"
15 "github.com/spf13/cobra"
15− "github.com/spf13/viper"
16)
16)
17
17
18var rootCmd = &cobra.Command{
18var rootCmd = &cobra.Command{
@@ -25,11 +25,10 @@ var rootCmd = &cobra.Command{
25 return nil
25 return nil
26 }
26 }
27 debug, _ := cmd.Flags().GetBool("debug")
27 debug, _ := cmd.Flags().GetBool("debug")
28− viper.Set("debug", debug)
28+ err := config.Load(debug)
29− if debug {
29+ if err != nil {
30− viper.Set("log.level", "debug")
30+ return err
31 }
31 }
32−
33 conn, err := db.Connect()
32 conn, err := db.Connect()
34 if err != nil {
33 if err != nil {
35 return err
34 return err
@@ -49,6 +48,8 @@ var rootCmd = &cobra.Command{
49 defer unsub()
48 defer unsub()
50
49
51 go func() {
50 go func() {
51+ // Set this up once
52+ agent.GetMcpTools(ctx)
52 for msg := range ch {
53 for msg := range ch {
53 tui.Send(msg)
54 tui.Send(msg)
54 }
55 }
@@ -95,16 +96,6 @@ func setupSubscriptions(app *app.App) (chan tea.Msg, func()) {
95 wg.Done()
96 wg.Done()
96 }()
97 }()
97 }
98 }
98− {
99− sub := app.LLM.Subscribe(ctx)
100− wg.Add(1)
101− go func() {
102− for ev := range sub {
103− ch <- ev
104− }
105− wg.Done()
106− }()
107− }
108 {
99 {
109 sub := app.Permissions.Subscribe(ctx)
100 sub := app.Permissions.Subscribe(ctx)
110 wg.Add(1)
101 wg.Add(1)
@@ -129,40 +120,7 @@ func Execute() {
129 }
120 }
130}
121}
131
122
132−func loadConfig() {
133− viper.SetConfigName(".termai")
134− viper.SetConfigType("yaml")
135− viper.AddConfigPath("$HOME")
136− viper.AddConfigPath("$XDG_CONFIG_HOME/termai")
137− viper.AddConfigPath(".")
138− viper.SetEnvPrefix("TERMAI")
139− // SET DEFAULTS
140− viper.SetDefault("log.level", "info")
141− viper.SetDefault("data.dir", ".termai")
142−
143− // LLM
144− viper.SetDefault("models.big", string(models.DefaultBigModel))
145− viper.SetDefault("models.small", string(models.DefaultLittleModel))
146−
147− viper.SetDefault("providers.openai.key", os.Getenv("OPENAI_API_KEY"))
148− viper.SetDefault("providers.anthropic.key", os.Getenv("ANTHROPIC_API_KEY"))
149− viper.SetDefault("providers.groq.key", os.Getenv("GROQ_API_KEY"))
150− viper.SetDefault("providers.common.max_tokens", 4000)
151−
152− viper.SetDefault("agents.default", "coder")
153−
154− viper.ReadInConfig()
155−
156− workdir, err := os.Getwd()
157− if err != nil {
158− panic(err)
159− }
160− viper.Set("wd", workdir)
161−}
162−
163func init() {
123func init() {
164− loadConfig()
165−
166 rootCmd.Flags().BoolP("help", "h", false, "Help")
124 rootCmd.Flags().BoolP("help", "h", false, "Help")
167 rootCmd.Flags().BoolP("debug", "d", false, "Help")
125 rootCmd.Flags().BoolP("debug", "d", false, "Help")
168}
126}
go.mod+39−47
@@ -3,6 +3,7 @@ module github.com/kujtimiihoxha/termai
3go 1.23.5
3go 1.23.5
4
4
5require (
5require (
6+ github.com/anthropics/anthropic-sdk-go v0.2.0-beta.2
6 github.com/bmatcuk/doublestar/v4 v4.8.1
7 github.com/bmatcuk/doublestar/v4 v4.8.1
7 github.com/catppuccin/go v0.3.0
8 github.com/catppuccin/go v0.3.0
8 github.com/charmbracelet/bubbles v0.20.0
9 github.com/charmbracelet/bubbles v0.20.0
@@ -10,92 +11,70 @@ require (
10 github.com/charmbracelet/glamour v0.9.1
11 github.com/charmbracelet/glamour v0.9.1
11 github.com/charmbracelet/huh v0.6.0
12 github.com/charmbracelet/huh v0.6.0
12 github.com/charmbracelet/lipgloss v1.1.0
13 github.com/charmbracelet/lipgloss v1.1.0
13− github.com/cloudwego/eino v0.3.17
14− github.com/cloudwego/eino-ext/components/model/claude v0.0.0-20250320062631-616205c32186
15− github.com/cloudwego/eino-ext/components/model/openai v0.0.0-20250320062631-616205c32186
16 github.com/go-logfmt/logfmt v0.6.0
14 github.com/go-logfmt/logfmt v0.6.0
17 github.com/golang-migrate/migrate/v4 v4.18.2
15 github.com/golang-migrate/migrate/v4 v4.18.2
16+ github.com/google/generative-ai-go v0.19.0
18 github.com/google/uuid v1.6.0
17 github.com/google/uuid v1.6.0
19− github.com/kujtimiihoxha/vimtea v0.0.3-0.20250317175717-9d8ba9c69840
18+ github.com/kujtimiihoxha/vimtea v0.0.3-0.20250329221256-a250e98498f9
20 github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231
19 github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231
20+ github.com/mark3labs/mcp-go v0.17.0
21 github.com/mattn/go-runewidth v0.0.16
21 github.com/mattn/go-runewidth v0.0.16
22 github.com/mattn/go-sqlite3 v1.14.24
22 github.com/mattn/go-sqlite3 v1.14.24
23 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
23 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
24 github.com/muesli/reflow v0.3.0
24 github.com/muesli/reflow v0.3.0
25 github.com/muesli/termenv v0.16.0
25 github.com/muesli/termenv v0.16.0
26+ github.com/openai/openai-go v0.1.0-beta.2
26 github.com/sergi/go-diff v1.3.1
27 github.com/sergi/go-diff v1.3.1
27 github.com/spf13/cobra v1.9.1
28 github.com/spf13/cobra v1.9.1
28 github.com/spf13/viper v1.20.0
29 github.com/spf13/viper v1.20.0
30+ github.com/stretchr/testify v1.10.0
29 golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1
31 golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1
32+ google.golang.org/api v0.215.0
30)
33)
31
34
32require (
35require (
36+ cloud.google.com/go v0.116.0 // indirect
37+ cloud.google.com/go/ai v0.8.0 // indirect
38+ cloud.google.com/go/auth v0.13.0 // indirect
39+ cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
40+ cloud.google.com/go/compute/metadata v0.6.0 // indirect
41+ cloud.google.com/go/longrunning v0.5.7 // indirect
33 github.com/alecthomas/chroma/v2 v2.15.0 // indirect
42 github.com/alecthomas/chroma/v2 v2.15.0 // indirect
34− github.com/anthropics/anthropic-sdk-go v0.2.0-alpha.8 // indirect
35 github.com/atotto/clipboard v0.1.4 // indirect
43 github.com/atotto/clipboard v0.1.4 // indirect
36− github.com/aws/aws-sdk-go-v2 v1.33.0 // indirect
37− github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect
38− github.com/aws/aws-sdk-go-v2/config v1.29.1 // indirect
39− github.com/aws/aws-sdk-go-v2/credentials v1.17.54 // indirect
40− github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.24 // indirect
41− github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.28 // indirect
42− github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.28 // indirect
43− github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
44− github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect
45− github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.9 // indirect
46− github.com/aws/aws-sdk-go-v2/service/sso v1.24.11 // indirect
47− github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.10 // indirect
48− github.com/aws/aws-sdk-go-v2/service/sts v1.33.9 // indirect
49− github.com/aws/smithy-go v1.22.1 // indirect
50 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
44 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
51 github.com/aymerick/douceur v0.2.0 // indirect
45 github.com/aymerick/douceur v0.2.0 // indirect
52− github.com/bytedance/sonic v1.12.2 // indirect
53− github.com/bytedance/sonic/loader v0.2.0 // indirect
54 github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
46 github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
55 github.com/charmbracelet/x/ansi v0.8.0 // indirect
47 github.com/charmbracelet/x/ansi v0.8.0 // indirect
56 github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
48 github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
57 github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
49 github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
58 github.com/charmbracelet/x/term v0.2.1 // indirect
50 github.com/charmbracelet/x/term v0.2.1 // indirect
59− github.com/cloudwego/base64x v0.1.4 // indirect
51+ github.com/davecgh/go-spew v1.1.1 // indirect
60− github.com/cloudwego/eino-ext/libs/acl/openai v0.0.0-20250305023926-469de0301955 // indirect
61− github.com/cloudwego/iasm v0.2.0 // indirect
62 github.com/dlclark/regexp2 v1.11.4 // indirect
52 github.com/dlclark/regexp2 v1.11.4 // indirect
63 github.com/dustin/go-humanize v1.0.1 // indirect
53 github.com/dustin/go-humanize v1.0.1 // indirect
64 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
54 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
55+ github.com/felixge/httpsnoop v1.0.4 // indirect
65 github.com/fsnotify/fsnotify v1.8.0 // indirect
56 github.com/fsnotify/fsnotify v1.8.0 // indirect
66− github.com/getkin/kin-openapi v0.118.0 // indirect
57+ github.com/go-logr/logr v1.4.2 // indirect
67− github.com/go-openapi/jsonpointer v0.19.5 // indirect
68− github.com/go-openapi/swag v0.19.5 // indirect
58+ github.com/go-logr/stdr v1.2.2 // indirect
69 github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
59 github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
70− github.com/goph/emperror v0.17.2 // indirect
60+ github.com/google/s2a-go v0.1.8 // indirect
61+ github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
62+ github.com/googleapis/gax-go/v2 v2.14.1 // indirect
71 github.com/gorilla/css v1.0.1 // indirect
63 github.com/gorilla/css v1.0.1 // indirect
72 github.com/hashicorp/errwrap v1.1.0 // indirect
64 github.com/hashicorp/errwrap v1.1.0 // indirect
73 github.com/hashicorp/go-multierror v1.1.1 // indirect
65 github.com/hashicorp/go-multierror v1.1.1 // indirect
74 github.com/inconshreveable/mousetrap v1.1.0 // indirect
66 github.com/inconshreveable/mousetrap v1.1.0 // indirect
75− github.com/invopop/yaml v0.1.0 // indirect
76− github.com/josharian/intern v1.0.0 // indirect
77− github.com/json-iterator/go v1.1.12 // indirect
78− github.com/klauspost/cpuid/v2 v2.0.9 // indirect
79 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
67 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
80− github.com/mailru/easyjson v0.7.7 // indirect
81 github.com/mattn/go-isatty v0.0.20 // indirect
68 github.com/mattn/go-isatty v0.0.20 // indirect
82 github.com/mattn/go-localereader v0.0.1 // indirect
69 github.com/mattn/go-localereader v0.0.1 // indirect
83 github.com/microcosm-cc/bluemonday v1.0.27 // indirect
70 github.com/microcosm-cc/bluemonday v1.0.27 // indirect
84 github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
71 github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
85− github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
86− github.com/modern-go/reflect2 v1.0.2 // indirect
87− github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
88 github.com/muesli/cancelreader v0.2.2 // indirect
72 github.com/muesli/cancelreader v0.2.2 // indirect
89− github.com/nikolalohinski/gonja v1.5.3 // indirect
90 github.com/pelletier/go-toml/v2 v2.2.3 // indirect
73 github.com/pelletier/go-toml/v2 v2.2.3 // indirect
91− github.com/perimeterx/marshmallow v1.1.4 // indirect
74+ github.com/pmezard/go-difflib v1.0.0 // indirect
92− github.com/pkg/errors v0.9.1 // indirect
93 github.com/rivo/uniseg v0.4.7 // indirect
75 github.com/rivo/uniseg v0.4.7 // indirect
94 github.com/sagikazarmark/locafero v0.7.0 // indirect
76 github.com/sagikazarmark/locafero v0.7.0 // indirect
95 github.com/sahilm/fuzzy v0.1.1 // indirect
77 github.com/sahilm/fuzzy v0.1.1 // indirect
96− github.com/sashabaranov/go-openai v1.32.5 // indirect
97− github.com/sirupsen/logrus v1.9.3 // indirect
98− github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect
99 github.com/sourcegraph/conc v0.3.0 // indirect
78 github.com/sourcegraph/conc v0.3.0 // indirect
100 github.com/spf13/afero v1.12.0 // indirect
79 github.com/spf13/afero v1.12.0 // indirect
101 github.com/spf13/cast v1.7.1 // indirect
80 github.com/spf13/cast v1.7.1 // indirect
@@ -105,19 +84,32 @@ require (
105 github.com/tidwall/match v1.1.1 // indirect
84 github.com/tidwall/match v1.1.1 // indirect
106 github.com/tidwall/pretty v1.2.1 // indirect
85 github.com/tidwall/pretty v1.2.1 // indirect
107 github.com/tidwall/sjson v1.2.5 // indirect
86 github.com/tidwall/sjson v1.2.5 // indirect
108− github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
109 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
87 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
110− github.com/yargevad/filepathx v1.0.0 // indirect
88+ github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
111 github.com/yuin/goldmark v1.7.8 // indirect
89 github.com/yuin/goldmark v1.7.8 // indirect
112 github.com/yuin/goldmark-emoji v1.0.5 // indirect
90 github.com/yuin/goldmark-emoji v1.0.5 // indirect
91+ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect
92+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
93+ go.opentelemetry.io/otel v1.29.0 // indirect
94+ go.opentelemetry.io/otel/metric v1.29.0 // indirect
95+ go.opentelemetry.io/otel/trace v1.29.0 // indirect
113 go.uber.org/atomic v1.9.0 // indirect
96 go.uber.org/atomic v1.9.0 // indirect
114 go.uber.org/multierr v1.9.0 // indirect
97 go.uber.org/multierr v1.9.0 // indirect
115− golang.org/x/arch v0.11.0 // indirect
98+ golang.design/x/clipboard v0.7.0 // indirect
116− golang.org/x/net v0.33.0 // indirect
99+ golang.org/x/crypto v0.33.0 // indirect
100+ golang.org/x/exp/shiny v0.0.0-20250305212735-054e65f0b394 // indirect
101+ golang.org/x/image v0.14.0 // indirect
102+ golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a // indirect
103+ golang.org/x/net v0.34.0 // indirect
104+ golang.org/x/oauth2 v0.25.0 // indirect
117 golang.org/x/sync v0.12.0 // indirect
105 golang.org/x/sync v0.12.0 // indirect
118 golang.org/x/sys v0.31.0 // indirect
106 golang.org/x/sys v0.31.0 // indirect
119 golang.org/x/term v0.30.0 // indirect
107 golang.org/x/term v0.30.0 // indirect
120 golang.org/x/text v0.23.0 // indirect
108 golang.org/x/text v0.23.0 // indirect
121− gopkg.in/yaml.v2 v2.4.0 // indirect
109+ golang.org/x/time v0.8.0 // indirect
110+ google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect
111+ google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 // indirect
112+ google.golang.org/grpc v1.67.3 // indirect
113+ google.golang.org/protobuf v1.36.1 // indirect
122 gopkg.in/yaml.v3 v3.0.1 // indirect
114 gopkg.in/yaml.v3 v3.0.1 // indirect
123)
115)
go.sum+73−169
@@ -1,66 +1,37 @@
1+cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
2+cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
3+cloud.google.com/go/ai v0.8.0 h1:rXUEz8Wp2OlrM8r1bfmpF2+VKqc1VJpafE3HgzRnD/w=
4+cloud.google.com/go/ai v0.8.0/go.mod h1:t3Dfk4cM61sytiggo2UyGsDVW3RF1qGZaUKDrZFyqkE=
5+cloud.google.com/go/auth v0.13.0 h1:8Fu8TZy167JkW8Tj3q7dIkr2v4cndv41ouecJx0PAHs=
6+cloud.google.com/go/auth v0.13.0/go.mod h1:COOjD9gwfKNKz+IIduatIhYJQIc0mG3H102r/EMxX6Q=
7+cloud.google.com/go/auth/oauth2adapt v0.2.6 h1:V6a6XDu2lTwPZWOawrAa9HUK+DB2zfJyTuciBG5hFkU=
8+cloud.google.com/go/auth/oauth2adapt v0.2.6/go.mod h1:AlmsELtlEBnaNTL7jCj8VQFLy6mbZv0s4Q7NGBeQ5E8=
9+cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
10+cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
11+cloud.google.com/go/longrunning v0.5.7 h1:WLbHekDbjK1fVFD3ibpFFVoyizlLRl73I7YKuAKilhU=
12+cloud.google.com/go/longrunning v0.5.7/go.mod h1:8GClkudohy1Fxm3owmBGid8W0pSgodEMwEAztp38Xng=
1github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
13github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
2github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
14github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
3−github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o=
4github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
15github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
5github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
16github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
6github.com/alecthomas/chroma/v2 v2.15.0 h1:LxXTQHFoYrstG2nnV9y2X5O94sOBzf0CIUpSTbpxvMc=
17github.com/alecthomas/chroma/v2 v2.15.0 h1:LxXTQHFoYrstG2nnV9y2X5O94sOBzf0CIUpSTbpxvMc=
7github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio=
18github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio=
8github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
19github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
9github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
20github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
10−github.com/anthropics/anthropic-sdk-go v0.2.0-alpha.8 h1:ss/c/eeyILgoK2sMsTJdcdLdhY3wZSt//+nanM41B9w=
21+github.com/anthropics/anthropic-sdk-go v0.2.0-beta.2 h1:h7qxtumNjKPWFv1QM/HJy60MteeW23iKeEtBoY7bYZk=
11−github.com/anthropics/anthropic-sdk-go v0.2.0-alpha.8/go.mod h1:GJxtdOs9K4neo8Gg65CjJ7jNautmldGli5/OFNabOoo=
22+github.com/anthropics/anthropic-sdk-go v0.2.0-beta.2/go.mod h1:AapDW22irxK2PSumZiQXYUFvsdQgkwIWlpESweWZI/c=
12github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
23github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
13github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
24github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
14−github.com/aws/aws-sdk-go-v2 v1.33.0 h1:Evgm4DI9imD81V0WwD+TN4DCwjUMdc94TrduMLbgZJs=
15−github.com/aws/aws-sdk-go-v2 v1.33.0/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U=
16−github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 h1:tW1/Rkad38LA15X4UQtjXZXNKsCgkshC3EbmcUmghTg=
17−github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3/go.mod h1:UbnqO+zjqk3uIt9yCACHJ9IVNhyhOCnYk8yA19SAWrM=
18−github.com/aws/aws-sdk-go-v2/config v1.29.1 h1:JZhGawAyZ/EuJeBtbQYnaoftczcb2drR2Iq36Wgz4sQ=
19−github.com/aws/aws-sdk-go-v2/config v1.29.1/go.mod h1:7bR2YD5euaxBhzt2y/oDkt3uNRb6tjFp98GlTFueRwk=
20−github.com/aws/aws-sdk-go-v2/credentials v1.17.54 h1:4UmqeOqJPvdvASZWrKlhzpRahAulBfyTJQUaYy4+hEI=
21−github.com/aws/aws-sdk-go-v2/credentials v1.17.54/go.mod h1:RTdfo0P0hbbTxIhmQrOsC/PquBZGabEPnCaxxKRPSnI=
22−github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.24 h1:5grmdTdMsovn9kPZPI23Hhvp0ZyNm5cRO+IZFIYiAfw=
23−github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.24/go.mod h1:zqi7TVKTswH3Ozq28PkmBmgzG1tona7mo9G2IJg4Cis=
24−github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.28 h1:igORFSiH3bfq4lxKFkTSYDhJEUCYo6C8VKiWJjYwQuQ=
25−github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.28/go.mod h1:3So8EA/aAYm36L7XIvCVwLa0s5N0P7o2b1oqnx/2R4g=
26−github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.28 h1:1mOW9zAUMhTSrMDssEHS/ajx8JcAj/IcftzcmNlmVLI=
27−github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.28/go.mod h1:kGlXVIWDfvt2Ox5zEaNglmq0hXPHgQFNMix33Tw22jA=
28−github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ=
29−github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
30−github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 h1:iXtILhvDxB6kPvEXgsDhGaZCSC6LQET5ZHSdJozeI0Y=
31−github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1/go.mod h1:9nu0fVANtYiAePIBh2/pFUSwtJ402hLnp854CNoDOeE=
32−github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.9 h1:TQmKDyETFGiXVhZfQ/I0cCFziqqX58pi4tKJGYGFSz0=
33−github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.9/go.mod h1:HVLPK2iHQBUx7HfZeOQSEu3v2ubZaAY2YPbAm5/WUyY=
34−github.com/aws/aws-sdk-go-v2/service/sso v1.24.11 h1:kuIyu4fTT38Kj7YCC7ouNbVZSSpqkZ+LzIfhCr6Dg+I=
35−github.com/aws/aws-sdk-go-v2/service/sso v1.24.11/go.mod h1:Ro744S4fKiCCuZECXgOi760TiYylUM8ZBf6OGiZzJtY=
36−github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.10 h1:l+dgv/64iVlQ3WsBbnn+JSbkj01jIi+SM0wYsj3y/hY=
37−github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.10/go.mod h1:Fzsj6lZEb8AkTE5S68OhcbBqeWPsR8RnGuKPr8Todl8=
38−github.com/aws/aws-sdk-go-v2/service/sts v1.33.9 h1:BRVDbewN6VZcwr+FBOszDKvYeXY1kJ+GGMCcpghlw0U=
39−github.com/aws/aws-sdk-go-v2/service/sts v1.33.9/go.mod h1:f6vjfZER1M17Fokn0IzssOTMT2N8ZSq+7jnNF0tArvw=
40−github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro=
41−github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
42github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
25github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
43github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
26github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
44github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
27github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
45github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
28github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
46github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
29github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
47github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
30github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
48−github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
49github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
31github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
50github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
32github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
51−github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
52−github.com/bugsnag/bugsnag-go v1.4.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8=
53−github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE=
54−github.com/bytedance/mockey v1.2.13 h1:jokWZAm/pUEbD939Rhznz615MKUCZNuvCFQlJ2+ntoo=
55−github.com/bytedance/mockey v1.2.13/go.mod h1:1BPHF9sol5R1ud/+0VEHGQq/+i2lN+GTsr3O2Q9IENY=
56−github.com/bytedance/sonic v1.12.2 h1:oaMFuRTpMHYLpCntGca65YWt5ny+wAceDERTkT2L9lg=
57−github.com/bytedance/sonic v1.12.2/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
58−github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
59−github.com/bytedance/sonic/loader v0.2.0 h1:zNprn+lsIP06C/IqCHs3gPQIvnvpKbbxyXQP1iU4kWM=
60−github.com/bytedance/sonic/loader v0.2.0/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
61github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
33github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
62github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
34github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
63−github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4=
64github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
35github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
65github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
36github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
66github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
37github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
@@ -83,18 +54,6 @@ github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko
83github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
54github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
84github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
55github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
85github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
56github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
86−github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
87−github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
88−github.com/cloudwego/eino v0.3.17 h1:cRQUCLU6897cautWe1u3les1H3OILasUIhnHzxr2QcE=
89−github.com/cloudwego/eino v0.3.17/go.mod h1:+kmJimGEcKuSI6OKhet7kBedkm1WUZS3H1QRazxgWUo=
90−github.com/cloudwego/eino-ext/components/model/claude v0.0.0-20250320062631-616205c32186 h1:GGneAI4dIuQaxXfgsaaytmFuIi57cFTQ0R7b8PDUOxg=
91−github.com/cloudwego/eino-ext/components/model/claude v0.0.0-20250320062631-616205c32186/go.mod h1:ABAc+C6D9zs6KZZKfuHUhsCi+YWFogbWlqsMkQ9wJYY=
92−github.com/cloudwego/eino-ext/components/model/openai v0.0.0-20250320062631-616205c32186 h1:4f7KLAI2/177oZZ2iJO2bpzeJU9KPg3TduVTR0ulzQk=
93−github.com/cloudwego/eino-ext/components/model/openai v0.0.0-20250320062631-616205c32186/go.mod h1:vs6irdysecD6QxymjsCTzAgN2JceOTPYPjTXfJCNcgQ=
94−github.com/cloudwego/eino-ext/libs/acl/openai v0.0.0-20250305023926-469de0301955 h1:fgvkmTqAalDfjdy3b6Ur2mh/KEwB9L2uvqS4MFgTOqc=
95−github.com/cloudwego/eino-ext/libs/acl/openai v0.0.0-20250305023926-469de0301955/go.mod h1:6CThw1XQx/ASXNt31yuvp0X4Yp4GprknQuIvP9VKDpw=
96−github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
97−github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
98github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
57github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
99github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
58github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
100github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
59github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -105,42 +64,37 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
105github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
64github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
106github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
65github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
107github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
66github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
67+github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
68+github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
108github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
69github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
109github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
70github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
110−github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
111github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
71github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
112github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
72github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
113−github.com/getkin/kin-openapi v0.118.0 h1:z43njxPmJ7TaPpMSCQb7PN0dEYno4tyBPQcrFdHoLuM=
114−github.com/getkin/kin-openapi v0.118.0/go.mod h1:l5e9PaFUo9fyLJCPGQeXI2ML8c3P8BHOEV2VaAVf/pc=
115−github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ=
116−github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI=
117−github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
118github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
73github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
119github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
74github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
120−github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
75+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
121−github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
76+github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
122−github.com/go-openapi/swag v0.19.5 h1:lTz6Ys4CmqqCQmZPBlbQENR1/GucA2bzYTE12Pw4tFY=
77+github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
123−github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
78+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
124−github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
79+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
125−github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
126github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
80github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
127github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
81github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
128−github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
129github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
82github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
130github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
83github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
131−github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
84+github.com/google/generative-ai-go v0.19.0 h1:R71szggh8wHMCUlEMsW2A/3T+5LdEIkiaHSYgSpUgdg=
85+github.com/google/generative-ai-go v0.19.0/go.mod h1:JYolL13VG7j79kM5BtHz4qwONHkeJQzOCkKXnpqtS/E=
132github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
86github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
133github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
87github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
134−github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
88+github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
89+github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
135github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
90github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
136github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
91github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
137−github.com/goph/emperror v0.17.2 h1:yLapQcmEsO0ipe9p5TaN22djm3OFV/TfM/fcYP0/J18=
92+github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
138−github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH4LNHic=
93+github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
139−github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
94+github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
140−github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
95+github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
141github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
96github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
142github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
97github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
143−github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
144github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
98github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
145github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
99github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
146github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
100github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -148,22 +102,8 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l
148github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
102github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
149github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
103github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
150github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
104github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
151−github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
152github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
105github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
153github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
106github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
154−github.com/invopop/yaml v0.1.0 h1:YW3WGUoJEXYfzWBjn00zIlrw7brGVD0fUKRYDPAPhrc=
155−github.com/invopop/yaml v0.1.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q=
156−github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
157−github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
158−github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
159−github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
160−github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
161−github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
162−github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
163−github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
164−github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
165−github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
166−github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
167github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
107github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
168github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
108github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
169github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
109github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -171,8 +111,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
171github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
111github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
172github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
112github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
173github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
113github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
174−github.com/kujtimiihoxha/vimtea v0.0.3-0.20250317175717-9d8ba9c69840 h1:AORwYXTzap8hg0zmTA5RWB/0fxv9F19dF42dCY0IsRc=
114+github.com/kujtimiihoxha/vimtea v0.0.3-0.20250329221256-a250e98498f9 h1:xYfCLI8KUwmXDFp1pOpNX+XsQczQw9VbEuju1pQF5/A=
175−github.com/kujtimiihoxha/vimtea v0.0.3-0.20250317175717-9d8ba9c69840/go.mod h1:VyCD1xYnYem+OHp9nzGNx8x7rCwaeB+2VSyOUgX8Zyc=
115+github.com/kujtimiihoxha/vimtea v0.0.3-0.20250329221256-a250e98498f9/go.mod h1:Ye+kIkTmPO5xuqCQ+PPHDTGIViRRoSpSIlcYgma8YlA=
176github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
116github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
177github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
117github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
178github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
118github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
@@ -181,12 +121,8 @@ github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231 h1:9rjt7AfnrX
181github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231/go.mod h1:S5etECMx+sZnW0Gm100Ma9J1PgVCTgNyFaqGu2b08b4=
121github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231/go.mod h1:S5etECMx+sZnW0Gm100Ma9J1PgVCTgNyFaqGu2b08b4=
182github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
122github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
183github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
123github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
184−github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
124+github.com/mark3labs/mcp-go v0.17.0 h1:5Ps6T7qXr7De/2QTqs9h6BKeZ/qdeUeGrgM5lPzi930=
185−github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
125+github.com/mark3labs/mcp-go v0.17.0/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE=
186−github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
187−github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
188−github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE=
189−github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
190github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
126github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
191github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
127github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
192github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
128github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
@@ -196,19 +132,10 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T
196github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
132github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
197github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
133github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
198github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
134github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
199−github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
200−github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
201github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
135github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
202github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
136github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
203github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
137github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
204github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
138github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
205−github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
206−github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
207−github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
208−github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
209−github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
210−github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
211−github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
212github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
139github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
213github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
140github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
214github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
141github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -217,18 +144,10 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
217github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
144github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
218github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
145github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
219github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
146github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
220−github.com/nikolalohinski/gonja v1.5.3 h1:GsA+EEaZDZPGJ8JtpeGN78jidhOlxeJROpqMT9fTj9c=
147+github.com/openai/openai-go v0.1.0-beta.2 h1:Ra5nCFkbEl9w+UJwAciC4kqnIBUCcJazhmMA0/YN894=
221−github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4=
148+github.com/openai/openai-go v0.1.0-beta.2/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
222−github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
223−github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
224−github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
225github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
149github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
226github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
150github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
227−github.com/perimeterx/marshmallow v1.1.4 h1:pZLDH9RjlLGGorbXhcaQLhfuV0pFMNfPO55FuFkxqLw=
228−github.com/perimeterx/marshmallow v1.1.4/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
229−github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
230−github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
231−github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
232github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
151github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
233github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
152github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
234github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
153github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -237,25 +156,13 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
237github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
156github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
238github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
157github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
239github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
158github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
240−github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtISeXco0L5PKQ=
241github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
159github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
242github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
160github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
243github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
161github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
244github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
162github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
245github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
163github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
246−github.com/sashabaranov/go-openai v1.32.5 h1:/eNVa8KzlE7mJdKPZDj6886MUzZQjoVHyn0sLvIt5qA=
247−github.com/sashabaranov/go-openai v1.32.5/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
248github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
164github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
249github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
165github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
250−github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
251−github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
252−github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
253−github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f h1:Z2cODYsUxQPofhpYRMQVwWz4yUVpHF+vPi+eUdruUYI=
254−github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f/go.mod h1:JqzWyvTuI2X4+9wOHmKSQCYxybB/8j6Ko43qVmXDuZg=
255−github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY=
256−github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec=
257−github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY=
258−github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
259github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
166github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
260github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
167github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
261github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
168github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
@@ -269,16 +176,8 @@ github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
269github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY=
176github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY=
270github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
177github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
271github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
178github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
272−github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
273−github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
274−github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
275−github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
276github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
179github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
277github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
180github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
278−github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
279−github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
280−github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
281−github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
282github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
181github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
283github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
182github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
284github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
183github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
@@ -293,66 +192,71 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
293github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
192github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
294github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
193github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
295github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
194github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
296−github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
297−github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
298−github.com/ugorji/go v1.2.7 h1:qYhyWUUd6WbiM+C6JZAUkIJt/1WrjzNHY9+KCIjVqTo=
299−github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M=
300−github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0=
301−github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
302−github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg=
303−github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE=
304github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
195github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
305github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
196github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
306−github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc=
197+github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
307−github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA=
198+github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
308github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
199github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
309github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
200github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
310github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
201github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
311github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
202github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
312github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
203github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
204+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc=
205+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI=
206+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
207+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
208+go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
209+go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
210+go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
211+go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
212+go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
213+go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
313go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
214go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
314go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
215go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
315−go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
316−go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
317go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
216go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
318go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
217go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
319−golang.org/x/arch v0.11.0 h1:KXV8WWKCXm6tRpLirl2szsO5j/oOODwZf4hATmGVNs4=
218+golang.design/x/clipboard v0.7.0 h1:4Je8M/ys9AJumVnl8m+rZnIvstSnYj1fvzqYrU3TXvo=
320−golang.org/x/arch v0.11.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
219+golang.design/x/clipboard v0.7.0/go.mod h1:PQIvqYO9GP29yINEfsEn5zSQKAz3UgXmZKzDA6dnq2E=
321−golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
220+golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
322−golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
221+golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
323−golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
324golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw=
222golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw=
325golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
223golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
326−golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
224+golang.org/x/exp/shiny v0.0.0-20250305212735-054e65f0b394 h1:bFYqOIMdeiCEdzPJkLiOoMDzW/v3tjW4AA/RmUZYsL8=
327−golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
225+golang.org/x/exp/shiny v0.0.0-20250305212735-054e65f0b394/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8=
328−golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
226+golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
329−golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
227+golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
228+golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a h1:sYbmY3FwUWCBTodZL1S3JUuOvaW6kM2o+clDzzDNBWg=
229+golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a/go.mod h1:Ede7gF0KGoHlj822RtphAHK1jLdrcuRBZg0sF1Q+SPc=
230+golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
231+golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
232+golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
233+golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
330golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
234golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
331golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
235golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
332−golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
333−golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
334golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
236golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
335−golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
336golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
237golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
337golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
238golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
338golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
239golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
339golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
240golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
340golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
241golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
341−golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
342golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
242golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
343golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
243golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
244+golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
245+golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
246+google.golang.org/api v0.215.0 h1:jdYF4qnyczlEz2ReWIsosNLDuzXyvFHJtI5gcr0J7t0=
247+google.golang.org/api v0.215.0/go.mod h1:fta3CVtuJYOEdugLNWm6WodzOS8KdFckABwN4I40hzY=
248+google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q=
249+google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08=
250+google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 h1:TqExAhdPaB60Ux47Cn0oLV07rGnxZzIsaRhQaqS666A=
251+google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8/go.mod h1:lcTa1sDdWEIHMWlITnIczmw5w60CF9ffkb8Z+DVmmjA=
252+google.golang.org/grpc v1.67.3 h1:OgPcDAFKHnH8X3O4WcO4XUc8GRDeKsKReqbQtiCj7N8=
253+google.golang.org/grpc v1.67.3/go.mod h1:YGaHCc6Oap+FzBJTZLBzkGSYt/cvGPFTPxkn7QfSU8s=
254+google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
255+google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
344gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
256gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
345−gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
346gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
257gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
347gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
258gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
348−gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
349−gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
350−gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
351gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
259gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
352−gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
353gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
260gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
354−gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
355−gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
356gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
261gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
357gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
262gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
358−nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
internal/app/services.go+3−6
@@ -4,13 +4,12 @@ import (
4 "context"
4 "context"
5 "database/sql"
5 "database/sql"
6
6
7+ "github.com/kujtimiihoxha/termai/internal/config"
7 "github.com/kujtimiihoxha/termai/internal/db"
8 "github.com/kujtimiihoxha/termai/internal/db"
8− "github.com/kujtimiihoxha/termai/internal/llm"
9 "github.com/kujtimiihoxha/termai/internal/logging"
9 "github.com/kujtimiihoxha/termai/internal/logging"
10 "github.com/kujtimiihoxha/termai/internal/message"
10 "github.com/kujtimiihoxha/termai/internal/message"
11 "github.com/kujtimiihoxha/termai/internal/permission"
11 "github.com/kujtimiihoxha/termai/internal/permission"
12 "github.com/kujtimiihoxha/termai/internal/session"
12 "github.com/kujtimiihoxha/termai/internal/session"
13− "github.com/spf13/viper"
14)
13)
15
14
16type App struct {
15type App struct {
@@ -19,7 +18,6 @@ type App struct {
19 Sessions session.Service
18 Sessions session.Service
20 Messages message.Service
19 Messages message.Service
21 Permissions permission.Service
20 Permissions permission.Service
22− LLM llm.Service
23
21
24 Logger logging.Interface
22 Logger logging.Interface
25}
23}
@@ -27,18 +25,17 @@ type App struct {
27func New(ctx context.Context, conn *sql.DB) *App {
25func New(ctx context.Context, conn *sql.DB) *App {
28 q := db.New(conn)
26 q := db.New(conn)
29 log := logging.NewLogger(logging.Options{
27 log := logging.NewLogger(logging.Options{
30− Level: viper.GetString("log.level"),
28+ Level: config.Get().Log.Level,
31 })
29 })
32 sessions := session.NewService(ctx, q)
30 sessions := session.NewService(ctx, q)
33 messages := message.NewService(ctx, q)
31 messages := message.NewService(ctx, q)
34− llm := llm.NewService(ctx, log, sessions, messages)
35
32
36 return &App{
33 return &App{
37 Context: ctx,
34 Context: ctx,
38 Sessions: sessions,
35 Sessions: sessions,
39 Messages: messages,
36 Messages: messages,
40 Permissions: permission.Default,
37 Permissions: permission.Default,
41− LLM: llm,
42 Logger: log,
38 Logger: log,
43 }
39 }
44}
40}
41+
internal/config/config.gonew file+180
@@ -0,0 +1,180 @@
1+package config
2+
3+import (
4+ "fmt"
5+ "os"
6+ "strings"
7+
8+ "github.com/kujtimiihoxha/termai/internal/llm/models"
9+ "github.com/spf13/viper"
10+)
11+
12+type MCPType string
13+
14+const (
15+ MCPStdio MCPType = "stdio"
16+ MCPSse MCPType = "sse"
17+)
18+
19+type MCPServer struct {
20+ Command string `json:"command"`
21+ Env []string `json:"env"`
22+ Args []string `json:"args"`
23+ Type MCPType `json:"type"`
24+ URL string `json:"url"`
25+ Headers map[string]string `json:"headers"`
26+ // TODO: add permissions configuration
27+ // TODO: add the ability to specify the tools to import
28+}
29+
30+type Model struct {
31+ Coder models.ModelID `json:"coder"`
32+ CoderMaxTokens int64 `json:"coderMaxTokens"`
33+
34+ Task models.ModelID `json:"task"`
35+ TaskMaxTokens int64 `json:"taskMaxTokens"`
36+ // TODO: Maybe support multiple models for different purposes
37+}
38+
39+type Provider struct {
40+ APIKey string `json:"apiKey"`
41+ Enabled bool `json:"enabled"`
42+}
43+
44+type Data struct {
45+ Directory string `json:"directory"`
46+}
47+
48+type Log struct {
49+ Level string `json:"level"`
50+}
51+
52+type Config struct {
53+ Data *Data `json:"data,omitempty"`
54+ Log *Log `json:"log,omitempty"`
55+ MCPServers map[string]MCPServer `json:"mcpServers,omitempty"`
56+ Providers map[models.ModelProvider]Provider `json:"providers,omitempty"`
57+
58+ Model *Model `json:"model,omitempty"`
59+}
60+
61+var cfg *Config
62+
63+const (
64+ defaultDataDirectory = ".termai"
65+ defaultLogLevel = "info"
66+ defaultMaxTokens = int64(5000)
67+ termai = "termai"
68+)
69+
70+func Load(debug bool) error {
71+ if cfg != nil {
72+ return nil
73+ }
74+
75+ viper.SetConfigName(fmt.Sprintf(".%s", termai))
76+ viper.SetConfigType("json")
77+ viper.AddConfigPath("$HOME")
78+ viper.AddConfigPath(fmt.Sprintf("$XDG_CONFIG_HOME/%s", termai))
79+ viper.SetEnvPrefix(strings.ToUpper(termai))
80+
81+ // Add defaults
82+ viper.SetDefault("data.directory", defaultDataDirectory)
83+ if debug {
84+ viper.Set("log.level", "debug")
85+ } else {
86+ viper.SetDefault("log.level", defaultLogLevel)
87+ }
88+
89+ defaultModelSet := false
90+ if os.Getenv("ANTHROPIC_API_KEY") != "" {
91+ viper.SetDefault("providers.anthropic.apiKey", os.Getenv("ANTHROPIC_API_KEY"))
92+ viper.SetDefault("providers.anthropic.enabled", true)
93+ viper.SetDefault("model.coder", models.Claude37Sonnet)
94+ viper.SetDefault("model.task", models.Claude37Sonnet)
95+ defaultModelSet = true
96+ }
97+ if os.Getenv("OPENAI_API_KEY") != "" {
98+ viper.SetDefault("providers.openai.apiKey", os.Getenv("OPENAI_API_KEY"))
99+ viper.SetDefault("providers.openai.enabled", true)
100+ if !defaultModelSet {
101+ viper.SetDefault("model.coder", models.GPT4o)
102+ viper.SetDefault("model.task", models.GPT4o)
103+ defaultModelSet = true
104+ }
105+ }
106+ if os.Getenv("GEMINI_API_KEY") != "" {
107+ viper.SetDefault("providers.gemini.apiKey", os.Getenv("GEMINI_API_KEY"))
108+ viper.SetDefault("providers.gemini.enabled", true)
109+ if !defaultModelSet {
110+ viper.SetDefault("model.coder", models.GRMINI20Flash)
111+ viper.SetDefault("model.task", models.GRMINI20Flash)
112+ defaultModelSet = true
113+ }
114+ }
115+ if os.Getenv("GROQ_API_KEY") != "" {
116+ viper.SetDefault("providers.groq.apiKey", os.Getenv("GROQ_API_KEY"))
117+ viper.SetDefault("providers.groq.enabled", true)
118+ if !defaultModelSet {
119+ viper.SetDefault("model.coder", models.QWENQwq)
120+ viper.SetDefault("model.task", models.QWENQwq)
121+ defaultModelSet = true
122+ }
123+ }
124+ // TODO: add more providers
125+ cfg = &Config{}
126+
127+ err := viper.ReadInConfig()
128+ if err != nil {
129+ if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
130+ return err
131+ }
132+ }
133+ local := viper.New()
134+ local.SetConfigName(fmt.Sprintf(".%s", termai))
135+ local.SetConfigType("json")
136+ local.AddConfigPath(".")
137+ // load local config, this will override the global config
138+ if err = local.ReadInConfig(); err == nil {
139+ viper.MergeConfigMap(local.AllSettings())
140+ }
141+ viper.Unmarshal(cfg)
142+
143+ if cfg.Model != nil && cfg.Model.CoderMaxTokens <= 0 {
144+ cfg.Model.CoderMaxTokens = defaultMaxTokens
145+ }
146+ if cfg.Model != nil && cfg.Model.TaskMaxTokens <= 0 {
147+ cfg.Model.TaskMaxTokens = defaultMaxTokens
148+ }
149+
150+ for _, v := range cfg.MCPServers {
151+ if v.Type == "" {
152+ v.Type = MCPStdio
153+ }
154+ }
155+
156+ workdir, err := os.Getwd()
157+ if err != nil {
158+ return err
159+ }
160+ viper.Set("wd", workdir)
161+ return nil
162+}
163+
164+func Get() *Config {
165+ if cfg == nil {
166+ err := Load(false)
167+ if err != nil {
168+ panic(err)
169+ }
170+ }
171+ return cfg
172+}
173+
174+func WorkingDirectory() string {
175+ return viper.GetString("wd")
176+}
177+
178+func Write() error {
179+ return viper.WriteConfig()
180+}
internal/config/config_test.gonew file+465
@@ -0,0 +1,465 @@
1+package config
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/kujtimiihoxha/termai/internal/llm/models"
10+ "github.com/spf13/viper"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestLoad(t *testing.T) {
16+ setupTest(t)
17+
18+ t.Run("loads configuration successfully", func(t *testing.T) {
19+ homeDir := t.TempDir()
20+ t.Setenv("HOME", homeDir)
21+ configPath := filepath.Join(homeDir, ".termai.json")
22+
23+ configContent := `{
24+ "data": {
25+ "directory": "custom-dir"
26+ },
27+ "log": {
28+ "level": "debug"
29+ },
30+ "mcpServers": {
31+ "test-server": {
32+ "command": "test-command",
33+ "env": ["TEST_ENV=value"],
34+ "args": ["--arg1", "--arg2"],
35+ "type": "stdio",
36+ "url": "",
37+ "headers": {}
38+ },
39+ "sse-server": {
40+ "command": "",
41+ "env": [],
42+ "args": [],
43+ "type": "sse",
44+ "url": "https://api.example.com/events",
45+ "headers": {
46+ "Authorization": "Bearer token123",
47+ "Content-Type": "application/json"
48+ }
49+ }
50+ },
51+ "providers": {
52+ "anthropic": {
53+ "apiKey": "test-api-key",
54+ "enabled": true
55+ }
56+ },
57+ "model": {
58+ "coder": "claude-3-haiku",
59+ "task": "claude-3-haiku"
60+ }
61+ }`
62+ err := os.WriteFile(configPath, []byte(configContent), 0o644)
63+ require.NoError(t, err)
64+
65+ cfg = nil
66+ viper.Reset()
67+
68+ err = Load(false)
69+ require.NoError(t, err)
70+
71+ config := Get()
72+ assert.NotNil(t, config)
73+ assert.Equal(t, "custom-dir", config.Data.Directory)
74+ assert.Equal(t, "debug", config.Log.Level)
75+
76+ assert.Contains(t, config.MCPServers, "test-server")
77+ stdioServer := config.MCPServers["test-server"]
78+ assert.Equal(t, "test-command", stdioServer.Command)
79+ assert.Equal(t, []string{"TEST_ENV=value"}, stdioServer.Env)
80+ assert.Equal(t, []string{"--arg1", "--arg2"}, stdioServer.Args)
81+ assert.Equal(t, MCPStdio, stdioServer.Type)
82+ assert.Equal(t, "", stdioServer.URL)
83+ assert.Empty(t, stdioServer.Headers)
84+
85+ assert.Contains(t, config.MCPServers, "sse-server")
86+ sseServer := config.MCPServers["sse-server"]
87+ assert.Equal(t, "", sseServer.Command)
88+ assert.Empty(t, sseServer.Env)
89+ assert.Empty(t, sseServer.Args)
90+ assert.Equal(t, MCPSse, sseServer.Type)
91+ assert.Equal(t, "https://api.example.com/events", sseServer.URL)
92+ assert.Equal(t, map[string]string{
93+ "authorization": "Bearer token123",
94+ "content-type": "application/json",
95+ }, sseServer.Headers)
96+
97+ assert.Contains(t, config.Providers, models.ModelProvider("anthropic"))
98+ provider := config.Providers[models.ModelProvider("anthropic")]
99+ assert.Equal(t, "test-api-key", provider.APIKey)
100+ assert.True(t, provider.Enabled)
101+
102+ assert.NotNil(t, config.Model)
103+ assert.Equal(t, models.Claude3Haiku, config.Model.Coder)
104+ assert.Equal(t, models.Claude3Haiku, config.Model.Task)
105+ assert.Equal(t, defaultMaxTokens, config.Model.CoderMaxTokens)
106+ })
107+
108+ t.Run("loads configuration with environment variables", func(t *testing.T) {
109+ homeDir := t.TempDir()
110+ t.Setenv("HOME", homeDir)
111+ configPath := filepath.Join(homeDir, ".termai.json")
112+ err := os.WriteFile(configPath, []byte("{}"), 0o644)
113+ require.NoError(t, err)
114+
115+ t.Setenv("ANTHROPIC_API_KEY", "env-anthropic-key")
116+ t.Setenv("OPENAI_API_KEY", "env-openai-key")
117+ t.Setenv("GEMINI_API_KEY", "env-gemini-key")
118+
119+ cfg = nil
120+ viper.Reset()
121+
122+ err = Load(false)
123+ require.NoError(t, err)
124+
125+ config := Get()
126+ assert.NotNil(t, config)
127+
128+ assert.Equal(t, defaultDataDirectory, config.Data.Directory)
129+ assert.Equal(t, defaultLogLevel, config.Log.Level)
130+
131+ assert.Contains(t, config.Providers, models.ModelProvider("anthropic"))
132+ assert.Equal(t, "env-anthropic-key", config.Providers[models.ModelProvider("anthropic")].APIKey)
133+ assert.True(t, config.Providers[models.ModelProvider("anthropic")].Enabled)
134+
135+ assert.Contains(t, config.Providers, models.ModelProvider("openai"))
136+ assert.Equal(t, "env-openai-key", config.Providers[models.ModelProvider("openai")].APIKey)
137+ assert.True(t, config.Providers[models.ModelProvider("openai")].Enabled)
138+
139+ assert.Contains(t, config.Providers, models.ModelProvider("gemini"))
140+ assert.Equal(t, "env-gemini-key", config.Providers[models.ModelProvider("gemini")].APIKey)
141+ assert.True(t, config.Providers[models.ModelProvider("gemini")].Enabled)
142+
143+ assert.Equal(t, models.Claude37Sonnet, config.Model.Coder)
144+ })
145+
146+ t.Run("local config overrides global config", func(t *testing.T) {
147+ homeDir := t.TempDir()
148+ t.Setenv("HOME", homeDir)
149+ globalConfigPath := filepath.Join(homeDir, ".termai.json")
150+ globalConfig := `{
151+ "data": {
152+ "directory": "global-dir"
153+ },
154+ "log": {
155+ "level": "info"
156+ }
157+ }`
158+ err := os.WriteFile(globalConfigPath, []byte(globalConfig), 0o644)
159+ require.NoError(t, err)
160+
161+ workDir := t.TempDir()
162+ origDir, err := os.Getwd()
163+ require.NoError(t, err)
164+ defer os.Chdir(origDir)
165+ err = os.Chdir(workDir)
166+ require.NoError(t, err)
167+
168+ localConfigPath := filepath.Join(workDir, ".termai.json")
169+ localConfig := `{
170+ "data": {
171+ "directory": "local-dir"
172+ },
173+ "log": {
174+ "level": "debug"
175+ }
176+ }`
177+ err = os.WriteFile(localConfigPath, []byte(localConfig), 0o644)
178+ require.NoError(t, err)
179+
180+ cfg = nil
181+ viper.Reset()
182+
183+ err = Load(false)
184+ require.NoError(t, err)
185+
186+ config := Get()
187+ assert.NotNil(t, config)
188+
189+ assert.Equal(t, "local-dir", config.Data.Directory)
190+ assert.Equal(t, "debug", config.Log.Level)
191+ })
192+
193+ t.Run("missing config file should not return error", func(t *testing.T) {
194+ emptyDir := t.TempDir()
195+ t.Setenv("HOME", emptyDir)
196+
197+ cfg = nil
198+ viper.Reset()
199+
200+ err := Load(false)
201+ assert.NoError(t, err)
202+ })
203+
204+ t.Run("model priority and fallbacks", func(t *testing.T) {
205+ testCases := []struct {
206+ name string
207+ anthropicKey string
208+ openaiKey string
209+ geminiKey string
210+ expectedModel models.ModelID
211+ explicitModel models.ModelID
212+ useExplicitModel bool
213+ }{
214+ {
215+ name: "anthropic has priority",
216+ anthropicKey: "test-key",
217+ openaiKey: "test-key",
218+ geminiKey: "test-key",
219+ expectedModel: models.Claude37Sonnet,
220+ },
221+ {
222+ name: "fallback to openai when no anthropic",
223+ anthropicKey: "",
224+ openaiKey: "test-key",
225+ geminiKey: "test-key",
226+ expectedModel: models.GPT4o,
227+ },
228+ {
229+ name: "fallback to gemini when no others",
230+ anthropicKey: "",
231+ openaiKey: "",
232+ geminiKey: "test-key",
233+ expectedModel: models.GRMINI20Flash,
234+ },
235+ {
236+ name: "explicit model overrides defaults",
237+ anthropicKey: "test-key",
238+ openaiKey: "test-key",
239+ geminiKey: "test-key",
240+ explicitModel: models.GPT4o,
241+ useExplicitModel: true,
242+ expectedModel: models.GPT4o,
243+ },
244+ }
245+
246+ for _, tc := range testCases {
247+ t.Run(tc.name, func(t *testing.T) {
248+ homeDir := t.TempDir()
249+ t.Setenv("HOME", homeDir)
250+ configPath := filepath.Join(homeDir, ".termai.json")
251+
252+ configContent := "{}"
253+ if tc.useExplicitModel {
254+ configContent = fmt.Sprintf(`{"model":{"coder":"%s"}}`, tc.explicitModel)
255+ }
256+
257+ err := os.WriteFile(configPath, []byte(configContent), 0o644)
258+ require.NoError(t, err)
259+
260+ if tc.anthropicKey != "" {
261+ t.Setenv("ANTHROPIC_API_KEY", tc.anthropicKey)
262+ } else {
263+ t.Setenv("ANTHROPIC_API_KEY", "")
264+ }
265+
266+ if tc.openaiKey != "" {
267+ t.Setenv("OPENAI_API_KEY", tc.openaiKey)
268+ } else {
269+ t.Setenv("OPENAI_API_KEY", "")
270+ }
271+
272+ if tc.geminiKey != "" {
273+ t.Setenv("GEMINI_API_KEY", tc.geminiKey)
274+ } else {
275+ t.Setenv("GEMINI_API_KEY", "")
276+ }
277+
278+ cfg = nil
279+ viper.Reset()
280+
281+ err = Load(false)
282+ require.NoError(t, err)
283+
284+ config := Get()
285+ assert.NotNil(t, config)
286+ assert.Equal(t, tc.expectedModel, config.Model.Coder)
287+ })
288+ }
289+ })
290+}
291+
292+func TestGet(t *testing.T) {
293+ t.Run("get returns same config instance", func(t *testing.T) {
294+ setupTest(t)
295+ homeDir := t.TempDir()
296+ t.Setenv("HOME", homeDir)
297+ configPath := filepath.Join(homeDir, ".termai.json")
298+ err := os.WriteFile(configPath, []byte("{}"), 0o644)
299+ require.NoError(t, err)
300+
301+ cfg = nil
302+ viper.Reset()
303+
304+ config1 := Get()
305+ require.NotNil(t, config1)
306+
307+ config2 := Get()
308+ require.NotNil(t, config2)
309+
310+ assert.Same(t, config1, config2)
311+ })
312+
313+ t.Run("get loads config if not loaded", func(t *testing.T) {
314+ setupTest(t)
315+ homeDir := t.TempDir()
316+ t.Setenv("HOME", homeDir)
317+ configPath := filepath.Join(homeDir, ".termai.json")
318+ configContent := `{"data":{"directory":"test-dir"}}`
319+ err := os.WriteFile(configPath, []byte(configContent), 0o644)
320+ require.NoError(t, err)
321+
322+ cfg = nil
323+ viper.Reset()
324+
325+ config := Get()
326+ require.NotNil(t, config)
327+ assert.Equal(t, "test-dir", config.Data.Directory)
328+ })
329+}
330+
331+func TestWorkingDirectory(t *testing.T) {
332+ t.Run("returns current working directory", func(t *testing.T) {
333+ setupTest(t)
334+ homeDir := t.TempDir()
335+ t.Setenv("HOME", homeDir)
336+ configPath := filepath.Join(homeDir, ".termai.json")
337+ err := os.WriteFile(configPath, []byte("{}"), 0o644)
338+ require.NoError(t, err)
339+
340+ cfg = nil
341+ viper.Reset()
342+
343+ err = Load(false)
344+ require.NoError(t, err)
345+
346+ wd := WorkingDirectory()
347+ expectedWd, err := os.Getwd()
348+ require.NoError(t, err)
349+ assert.Equal(t, expectedWd, wd)
350+ })
351+}
352+
353+func TestWrite(t *testing.T) {
354+ t.Run("writes config to file", func(t *testing.T) {
355+ setupTest(t)
356+ homeDir := t.TempDir()
357+ t.Setenv("HOME", homeDir)
358+ configPath := filepath.Join(homeDir, ".termai.json")
359+ err := os.WriteFile(configPath, []byte("{}"), 0o644)
360+ require.NoError(t, err)
361+
362+ cfg = nil
363+ viper.Reset()
364+
365+ err = Load(false)
366+ require.NoError(t, err)
367+
368+ viper.Set("data.directory", "modified-dir")
369+
370+ err = Write()
371+ require.NoError(t, err)
372+
373+ content, err := os.ReadFile(configPath)
374+ require.NoError(t, err)
375+ assert.Contains(t, string(content), "modified-dir")
376+ })
377+}
378+
379+func TestMCPType(t *testing.T) {
380+ t.Run("MCPType constants", func(t *testing.T) {
381+ assert.Equal(t, MCPType("stdio"), MCPStdio)
382+ assert.Equal(t, MCPType("sse"), MCPSse)
383+ })
384+
385+ t.Run("MCPType JSON unmarshaling", func(t *testing.T) {
386+ homeDir := t.TempDir()
387+ t.Setenv("HOME", homeDir)
388+ configPath := filepath.Join(homeDir, ".termai.json")
389+
390+ configContent := `{
391+ "mcpServers": {
392+ "stdio-server": {
393+ "type": "stdio"
394+ },
395+ "sse-server": {
396+ "type": "sse"
397+ },
398+ "invalid-server": {
399+ "type": "invalid"
400+ }
401+ }
402+ }`
403+ err := os.WriteFile(configPath, []byte(configContent), 0o644)
404+ require.NoError(t, err)
405+
406+ cfg = nil
407+ viper.Reset()
408+
409+ err = Load(false)
410+ require.NoError(t, err)
411+
412+ config := Get()
413+ assert.NotNil(t, config)
414+
415+ assert.Equal(t, MCPStdio, config.MCPServers["stdio-server"].Type)
416+ assert.Equal(t, MCPSse, config.MCPServers["sse-server"].Type)
417+ assert.Equal(t, MCPType("invalid"), config.MCPServers["invalid-server"].Type)
418+ })
419+
420+ t.Run("default MCPType", func(t *testing.T) {
421+ homeDir := t.TempDir()
422+ t.Setenv("HOME", homeDir)
423+ configPath := filepath.Join(homeDir, ".termai.json")
424+
425+ configContent := `{
426+ "mcpServers": {
427+ "test-server": {
428+ "command": "test-command"
429+ }
430+ }
431+ }`
432+ err := os.WriteFile(configPath, []byte(configContent), 0o644)
433+ require.NoError(t, err)
434+
435+ cfg = nil
436+ viper.Reset()
437+
438+ err = Load(false)
439+ require.NoError(t, err)
440+
441+ config := Get()
442+ assert.NotNil(t, config)
443+
444+ assert.Equal(t, MCPType(""), config.MCPServers["test-server"].Type)
445+ })
446+}
447+
448+func setupTest(t *testing.T) {
449+ origHome := os.Getenv("HOME")
450+ origXdgConfigHome := os.Getenv("XDG_CONFIG_HOME")
451+ origAnthropicKey := os.Getenv("ANTHROPIC_API_KEY")
452+ origOpenAIKey := os.Getenv("OPENAI_API_KEY")
453+ origGeminiKey := os.Getenv("GEMINI_API_KEY")
454+
455+ t.Cleanup(func() {
456+ t.Setenv("HOME", origHome)
457+ t.Setenv("XDG_CONFIG_HOME", origXdgConfigHome)
458+ t.Setenv("ANTHROPIC_API_KEY", origAnthropicKey)
459+ t.Setenv("OPENAI_API_KEY", origOpenAIKey)
460+ t.Setenv("GEMINI_API_KEY", origGeminiKey)
461+
462+ cfg = nil
463+ viper.Reset()
464+ })
465+}
internal/db/connect.go+2−2
@@ -12,14 +12,14 @@ import (
12 "github.com/golang-migrate/migrate/v4/database/sqlite3"
12 "github.com/golang-migrate/migrate/v4/database/sqlite3"
13 _ "github.com/mattn/go-sqlite3"
13 _ "github.com/mattn/go-sqlite3"
14
14
15+ "github.com/kujtimiihoxha/termai/internal/config"
15 "github.com/kujtimiihoxha/termai/internal/logging"
16 "github.com/kujtimiihoxha/termai/internal/logging"
16− "github.com/spf13/viper"
17)
17)
18
18
19var log = logging.Get()
19var log = logging.Get()
20
20
21func Connect() (*sql.DB, error) {
21func Connect() (*sql.DB, error) {
22− dataDir := viper.GetString("data.dir")
22+ dataDir := config.Get().Data.Directory
23 if dataDir == "" {
23 if dataDir == "" {
24 return nil, fmt.Errorf("data.dir is not set")
24 return nil, fmt.Errorf("data.dir is not set")
25 }
25 }
internal/db/db.go+10
@@ -51,6 +51,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
51 if q.listSessionsStmt, err = db.PrepareContext(ctx, listSessions); err != nil {
51 if q.listSessionsStmt, err = db.PrepareContext(ctx, listSessions); err != nil {
52 return nil, fmt.Errorf("error preparing query ListSessions: %w", err)
52 return nil, fmt.Errorf("error preparing query ListSessions: %w", err)
53 }
53 }
54+ if q.updateMessageStmt, err = db.PrepareContext(ctx, updateMessage); err != nil {
55+ return nil, fmt.Errorf("error preparing query UpdateMessage: %w", err)
56+ }
54 if q.updateSessionStmt, err = db.PrepareContext(ctx, updateSession); err != nil {
57 if q.updateSessionStmt, err = db.PrepareContext(ctx, updateSession); err != nil {
55 return nil, fmt.Errorf("error preparing query UpdateSession: %w", err)
58 return nil, fmt.Errorf("error preparing query UpdateSession: %w", err)
56 }
59 }
@@ -104,6 +107,11 @@ func (q *Queries) Close() error {
104 err = fmt.Errorf("error closing listSessionsStmt: %w", cerr)
107 err = fmt.Errorf("error closing listSessionsStmt: %w", cerr)
105 }
108 }
106 }
109 }
110+ if q.updateMessageStmt != nil {
111+ if cerr := q.updateMessageStmt.Close(); cerr != nil {
112+ err = fmt.Errorf("error closing updateMessageStmt: %w", cerr)
113+ }
114+ }
107 if q.updateSessionStmt != nil {
115 if q.updateSessionStmt != nil {
108 if cerr := q.updateSessionStmt.Close(); cerr != nil {
116 if cerr := q.updateSessionStmt.Close(); cerr != nil {
109 err = fmt.Errorf("error closing updateSessionStmt: %w", cerr)
117 err = fmt.Errorf("error closing updateSessionStmt: %w", cerr)
@@ -157,6 +165,7 @@ type Queries struct {
157 getSessionByIDStmt *sql.Stmt
165 getSessionByIDStmt *sql.Stmt
158 listMessagesBySessionStmt *sql.Stmt
166 listMessagesBySessionStmt *sql.Stmt
159 listSessionsStmt *sql.Stmt
167 listSessionsStmt *sql.Stmt
168+ updateMessageStmt *sql.Stmt
160 updateSessionStmt *sql.Stmt
169 updateSessionStmt *sql.Stmt
161}
170}
162
171
@@ -173,6 +182,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
173 getSessionByIDStmt: q.getSessionByIDStmt,
182 getSessionByIDStmt: q.getSessionByIDStmt,
174 listMessagesBySessionStmt: q.listMessagesBySessionStmt,
183 listMessagesBySessionStmt: q.listMessagesBySessionStmt,
175 listSessionsStmt: q.listSessionsStmt,
184 listSessionsStmt: q.listSessionsStmt,
185+ updateMessageStmt: q.updateMessageStmt,
176 updateSessionStmt: q.updateSessionStmt,
186 updateSessionStmt: q.updateSessionStmt,
177 }
187 }
178}
188}
internal/db/messages.sql.go+77−12
@@ -7,34 +7,56 @@ package db
7
7
8import (
8import (
9 "context"
9 "context"
10+ "database/sql"
10)
11)
11
12
12const createMessage = `-- name: CreateMessage :one
13const createMessage = `-- name: CreateMessage :one
13INSERT INTO messages (
14INSERT INTO messages (
14 id,
15 id,
15 session_id,
16 session_id,
16− message_data,
17+ role,
18+ finished,
19+ content,
20+ tool_calls,
21+ tool_results,
17 created_at,
22 created_at,
18 updated_at
23 updated_at
19) VALUES (
24) VALUES (
20− ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
25+ ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
21)
26)
22−RETURNING id, session_id, message_data, created_at, updated_at
27+RETURNING id, session_id, role, content, thinking, finished, tool_calls, tool_results, created_at, updated_at
23`
28`
24
29
25type CreateMessageParams struct {
30type CreateMessageParams struct {
26− ID string `json:"id"`
31+ ID string `json:"id"`
27− SessionID string `json:"session_id"`
32+ SessionID string `json:"session_id"`
28− MessageData string `json:"message_data"`
33+ Role string `json:"role"`
34+ Finished bool `json:"finished"`
35+ Content string `json:"content"`
36+ ToolCalls sql.NullString `json:"tool_calls"`
37+ ToolResults sql.NullString `json:"tool_results"`
29}
38}
30
39
31func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error) {
40func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error) {
32− row := q.queryRow(ctx, q.createMessageStmt, createMessage, arg.ID, arg.SessionID, arg.MessageData)
41+ row := q.queryRow(ctx, q.createMessageStmt, createMessage,
42+ arg.ID,
43+ arg.SessionID,
44+ arg.Role,
45+ arg.Finished,
46+ arg.Content,
47+ arg.ToolCalls,
48+ arg.ToolResults,
49+ )
33 var i Message
50 var i Message
34 err := row.Scan(
51 err := row.Scan(
35 &i.ID,
52 &i.ID,
36 &i.SessionID,
53 &i.SessionID,
37− &i.MessageData,
54+ &i.Role,
55+ &i.Content,
56+ &i.Thinking,
57+ &i.Finished,
58+ &i.ToolCalls,
59+ &i.ToolResults,
38 &i.CreatedAt,
60 &i.CreatedAt,
39 &i.UpdatedAt,
61 &i.UpdatedAt,
40 )
62 )
@@ -62,7 +84,7 @@ func (q *Queries) DeleteSessionMessages(ctx context.Context, sessionID string) e
62}
84}
63
85
64const getMessage = `-- name: GetMessage :one
86const getMessage = `-- name: GetMessage :one
65−SELECT id, session_id, message_data, created_at, updated_at
87+SELECT id, session_id, role, content, thinking, finished, tool_calls, tool_results, created_at, updated_at
66FROM messages
88FROM messages
67WHERE id = ? LIMIT 1
89WHERE id = ? LIMIT 1
68`
90`
@@ -73,7 +95,12 @@ func (q *Queries) GetMessage(ctx context.Context, id string) (Message, error) {
73 err := row.Scan(
95 err := row.Scan(
74 &i.ID,
96 &i.ID,
75 &i.SessionID,
97 &i.SessionID,
76− &i.MessageData,
98+ &i.Role,
99+ &i.Content,
100+ &i.Thinking,
101+ &i.Finished,
102+ &i.ToolCalls,
103+ &i.ToolResults,
77 &i.CreatedAt,
104 &i.CreatedAt,
78 &i.UpdatedAt,
105 &i.UpdatedAt,
79 )
106 )
@@ -81,7 +108,7 @@ func (q *Queries) GetMessage(ctx context.Context, id string) (Message, error) {
81}
108}
82
109
83const listMessagesBySession = `-- name: ListMessagesBySession :many
110const listMessagesBySession = `-- name: ListMessagesBySession :many
84−SELECT id, session_id, message_data, created_at, updated_at
111+SELECT id, session_id, role, content, thinking, finished, tool_calls, tool_results, created_at, updated_at
85FROM messages
112FROM messages
86WHERE session_id = ?
113WHERE session_id = ?
87ORDER BY created_at ASC
114ORDER BY created_at ASC
@@ -99,7 +126,12 @@ func (q *Queries) ListMessagesBySession(ctx context.Context, sessionID string) (
99 if err := rows.Scan(
126 if err := rows.Scan(
100 &i.ID,
127 &i.ID,
101 &i.SessionID,
128 &i.SessionID,
102− &i.MessageData,
129+ &i.Role,
130+ &i.Content,
131+ &i.Thinking,
132+ &i.Finished,
133+ &i.ToolCalls,
134+ &i.ToolResults,
103 &i.CreatedAt,
135 &i.CreatedAt,
104 &i.UpdatedAt,
136 &i.UpdatedAt,
105 ); err != nil {
137 ); err != nil {
@@ -115,3 +147,36 @@ func (q *Queries) ListMessagesBySession(ctx context.Context, sessionID string) (
115 }
147 }
116 return items, nil
148 return items, nil
117}
149}
150+
151+const updateMessage = `-- name: UpdateMessage :exec
152+UPDATE messages
153+SET
154+ content = ?,
155+ thinking = ?,
156+ tool_calls = ?,
157+ tool_results = ?,
158+ finished = ?,
159+ updated_at = strftime('%s', 'now')
160+WHERE id = ?
161+`
162+
163+type UpdateMessageParams struct {
164+ Content string `json:"content"`
165+ Thinking string `json:"thinking"`
166+ ToolCalls sql.NullString `json:"tool_calls"`
167+ ToolResults sql.NullString `json:"tool_results"`
168+ Finished bool `json:"finished"`
169+ ID string `json:"id"`
170+}
171+
172+func (q *Queries) UpdateMessage(ctx context.Context, arg UpdateMessageParams) error {
173+ _, err := q.exec(ctx, q.updateMessageStmt, updateMessage,
174+ arg.Content,
175+ arg.Thinking,
176+ arg.ToolCalls,
177+ arg.ToolResults,
178+ arg.Finished,
179+ arg.ID,
180+ )
181+ return err
182+}
internal/db/migrations/000001_initial.up.sql+7−1
@@ -1,6 +1,7 @@
1-- Sessions
1-- Sessions
2CREATE TABLE IF NOT EXISTS sessions (
2CREATE TABLE IF NOT EXISTS sessions (
3 id TEXT PRIMARY KEY,
3 id TEXT PRIMARY KEY,
4+ parent_session_id TEXT,
4 title TEXT NOT NULL,
5 title TEXT NOT NULL,
5 message_count INTEGER NOT NULL DEFAULT 0 CHECK (message_count >= 0),
6 message_count INTEGER NOT NULL DEFAULT 0 CHECK (message_count >= 0),
6 prompt_tokens INTEGER NOT NULL DEFAULT 0 CHECK (prompt_tokens >= 0),
7 prompt_tokens INTEGER NOT NULL DEFAULT 0 CHECK (prompt_tokens >= 0),
@@ -21,7 +22,12 @@ END;
21CREATE TABLE IF NOT EXISTS messages (
22CREATE TABLE IF NOT EXISTS messages (
22 id TEXT PRIMARY KEY,
23 id TEXT PRIMARY KEY,
23 session_id TEXT NOT NULL,
24 session_id TEXT NOT NULL,
24− message_data TEXT NOT NULL, -- JSON string of message content
25+ role TEXT NOT NULL,
26+ content TEXT NOT NULL,
27+ thinking Text NOT NULL DEFAULT '',
28+ finished BOOLEAN NOT NULL DEFAULT 0,
29+ tool_calls TEXT,
30+ tool_results TEXT,
25 created_at INTEGER NOT NULL, -- Unix timestamp in milliseconds
31 created_at INTEGER NOT NULL, -- Unix timestamp in milliseconds
26 updated_at INTEGER NOT NULL, -- Unix timestamp in milliseconds
32 updated_at INTEGER NOT NULL, -- Unix timestamp in milliseconds
27 FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE
33 FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE
internal/db/models.go+23−13
@@ -4,21 +4,31 @@
4
4
5package db
5package db
6
6
7+import (
8+ "database/sql"
9+)
10+
7type Message struct {
11type Message struct {
8− ID string `json:"id"`
12+ ID string `json:"id"`
9− SessionID string `json:"session_id"`
13+ SessionID string `json:"session_id"`
10− MessageData string `json:"message_data"`
14+ Role string `json:"role"`
15+ Content string `json:"content"`
16+ Thinking string `json:"thinking"`
17+ Finished bool `json:"finished"`
18+ ToolCalls sql.NullString `json:"tool_calls"`
19+ ToolResults sql.NullString `json:"tool_results"`
11− CreatedAt int64 `json:"created_at"`
20+ CreatedAt int64 `json:"created_at"`
12− UpdatedAt int64 `json:"updated_at"`
21+ UpdatedAt int64 `json:"updated_at"`
13}
22}
14
23
15type Session struct {
24type Session struct {
16− ID string `json:"id"`
25+ ID string `json:"id"`
26+ ParentSessionID sql.NullString `json:"parent_session_id"`
17− Title string `json:"title"`
27+ Title string `json:"title"`
18− MessageCount int64 `json:"message_count"`
28+ MessageCount int64 `json:"message_count"`
19− PromptTokens int64 `json:"prompt_tokens"`
29+ PromptTokens int64 `json:"prompt_tokens"`
20− CompletionTokens int64 `json:"completion_tokens"`
30+ CompletionTokens int64 `json:"completion_tokens"`
21− Cost float64 `json:"cost"`
31+ Cost float64 `json:"cost"`
22− UpdatedAt int64 `json:"updated_at"`
32+ UpdatedAt int64 `json:"updated_at"`
23− CreatedAt int64 `json:"created_at"`
33+ CreatedAt int64 `json:"created_at"`
24}
34}
internal/db/querier.go+1
@@ -18,6 +18,7 @@ type Querier interface {
18 GetSessionByID(ctx context.Context, id string) (Session, error)
18 GetSessionByID(ctx context.Context, id string) (Session, error)
19 ListMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
19 ListMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
20 ListSessions(ctx context.Context) ([]Session, error)
20 ListSessions(ctx context.Context) ([]Session, error)
21+ UpdateMessage(ctx context.Context, arg UpdateMessageParams) error
21 UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error)
22 UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error)
22}
23}
23
24
internal/db/sessions.sql.go+20−10
@@ -7,11 +7,13 @@ package db
7
7
8import (
8import (
9 "context"
9 "context"
10+ "database/sql"
10)
11)
11
12
12const createSession = `-- name: CreateSession :one
13const createSession = `-- name: CreateSession :one
13INSERT INTO sessions (
14INSERT INTO sessions (
14 id,
15 id,
16+ parent_session_id,
15 title,
17 title,
16 message_count,
18 message_count,
17 prompt_tokens,
19 prompt_tokens,
@@ -26,23 +28,26 @@ INSERT INTO sessions (
26 ?,
28 ?,
27 ?,
29 ?,
28 ?,
30 ?,
31+ ?,
29 strftime('%s', 'now'),
32 strftime('%s', 'now'),
30 strftime('%s', 'now')
33 strftime('%s', 'now')
31−) RETURNING id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at
34+) RETURNING id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at
32`
35`
33
36
34type CreateSessionParams struct {
37type CreateSessionParams struct {
35− ID string `json:"id"`
38+ ID string `json:"id"`
39+ ParentSessionID sql.NullString `json:"parent_session_id"`
36− Title string `json:"title"`
40+ Title string `json:"title"`
37− MessageCount int64 `json:"message_count"`
41+ MessageCount int64 `json:"message_count"`
38− PromptTokens int64 `json:"prompt_tokens"`
42+ PromptTokens int64 `json:"prompt_tokens"`
39− CompletionTokens int64 `json:"completion_tokens"`
43+ CompletionTokens int64 `json:"completion_tokens"`
40− Cost float64 `json:"cost"`
44+ Cost float64 `json:"cost"`
41}
45}
42
46
43func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) {
47func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) {
44 row := q.queryRow(ctx, q.createSessionStmt, createSession,
48 row := q.queryRow(ctx, q.createSessionStmt, createSession,
45 arg.ID,
49 arg.ID,
50+ arg.ParentSessionID,
46 arg.Title,
51 arg.Title,
47 arg.MessageCount,
52 arg.MessageCount,
48 arg.PromptTokens,
53 arg.PromptTokens,
@@ -52,6 +57,7 @@ func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (S
52 var i Session
57 var i Session
53 err := row.Scan(
58 err := row.Scan(
54 &i.ID,
59 &i.ID,
60+ &i.ParentSessionID,
55 &i.Title,
61 &i.Title,
56 &i.MessageCount,
62 &i.MessageCount,
57 &i.PromptTokens,
63 &i.PromptTokens,
@@ -74,7 +80,7 @@ func (q *Queries) DeleteSession(ctx context.Context, id string) error {
74}
80}
75
81
76const getSessionByID = `-- name: GetSessionByID :one
82const getSessionByID = `-- name: GetSessionByID :one
77−SELECT id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at
83+SELECT id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at
78FROM sessions
84FROM sessions
79WHERE id = ? LIMIT 1
85WHERE id = ? LIMIT 1
80`
86`
@@ -84,6 +90,7 @@ func (q *Queries) GetSessionByID(ctx context.Context, id string) (Session, error
84 var i Session
90 var i Session
85 err := row.Scan(
91 err := row.Scan(
86 &i.ID,
92 &i.ID,
93+ &i.ParentSessionID,
87 &i.Title,
94 &i.Title,
88 &i.MessageCount,
95 &i.MessageCount,
89 &i.PromptTokens,
96 &i.PromptTokens,
@@ -96,8 +103,9 @@ func (q *Queries) GetSessionByID(ctx context.Context, id string) (Session, error
96}
103}
97
104
98const listSessions = `-- name: ListSessions :many
105const listSessions = `-- name: ListSessions :many
99−SELECT id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at
106+SELECT id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at
100FROM sessions
107FROM sessions
108+WHERE parent_session_id is NULL
101ORDER BY created_at DESC
109ORDER BY created_at DESC
102`
110`
103
111
@@ -112,6 +120,7 @@ func (q *Queries) ListSessions(ctx context.Context) ([]Session, error) {
112 var i Session
120 var i Session
113 if err := rows.Scan(
121 if err := rows.Scan(
114 &i.ID,
122 &i.ID,
123+ &i.ParentSessionID,
115 &i.Title,
124 &i.Title,
116 &i.MessageCount,
125 &i.MessageCount,
117 &i.PromptTokens,
126 &i.PromptTokens,
@@ -141,7 +150,7 @@ SET
141 completion_tokens = ?,
150 completion_tokens = ?,
142 cost = ?
151 cost = ?
143WHERE id = ?
152WHERE id = ?
144−RETURNING id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at
153+RETURNING id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at
145`
154`
146
155
147type UpdateSessionParams struct {
156type UpdateSessionParams struct {
@@ -163,6 +172,7 @@ func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) (S
163 var i Session
172 var i Session
164 err := row.Scan(
173 err := row.Scan(
165 &i.ID,
174 &i.ID,
175+ &i.ParentSessionID,
166 &i.Title,
176 &i.Title,
167 &i.MessageCount,
177 &i.MessageCount,
168 &i.PromptTokens,
178 &i.PromptTokens,
internal/db/sql/messages.sql+17−2
@@ -13,14 +13,29 @@ ORDER BY created_at ASC;
13INSERT INTO messages (
13INSERT INTO messages (
14 id,
14 id,
15 session_id,
15 session_id,
16− message_data,
16+ role,
17+ finished,
18+ content,
19+ tool_calls,
20+ tool_results,
17 created_at,
21 created_at,
18 updated_at
22 updated_at
19) VALUES (
23) VALUES (
20− ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
24+ ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
21)
25)
22RETURNING *;
26RETURNING *;
23
27
28+-- name: UpdateMessage :exec
29+UPDATE messages
30+SET
31+ content = ?,
32+ thinking = ?,
33+ tool_calls = ?,
34+ tool_results = ?,
35+ finished = ?,
36+ updated_at = strftime('%s', 'now')
37+WHERE id = ?;
38+
24-- name: DeleteMessage :exec
39-- name: DeleteMessage :exec
25DELETE FROM messages
40DELETE FROM messages
26WHERE id = ?;
41WHERE id = ?;
internal/db/sql/sessions.sql+3
@@ -1,6 +1,7 @@
1-- name: CreateSession :one
1-- name: CreateSession :one
2INSERT INTO sessions (
2INSERT INTO sessions (
3 id,
3 id,
4+ parent_session_id,
4 title,
5 title,
5 message_count,
6 message_count,
6 prompt_tokens,
7 prompt_tokens,
@@ -15,6 +16,7 @@ INSERT INTO sessions (
15 ?,
16 ?,
16 ?,
17 ?,
17 ?,
18 ?,
19+ ?,
18 strftime('%s', 'now'),
20 strftime('%s', 'now'),
19 strftime('%s', 'now')
21 strftime('%s', 'now')
20) RETURNING *;
22) RETURNING *;
@@ -27,6 +29,7 @@ WHERE id = ? LIMIT 1;
27-- name: ListSessions :many
29-- name: ListSessions :many
28SELECT *
30SELECT *
29FROM sessions
31FROM sessions
32+WHERE parent_session_id is NULL
30ORDER BY created_at DESC;
33ORDER BY created_at DESC;
31
34
32-- name: UpdateSession :one
35-- name: UpdateSession :one
internal/llm/agent/agent-tool.gonew file+102
@@ -0,0 +1,102 @@
1+package agent
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+
8+ "github.com/kujtimiihoxha/termai/internal/app"
9+ "github.com/kujtimiihoxha/termai/internal/llm/tools"
10+ "github.com/kujtimiihoxha/termai/internal/message"
11+)
12+
13+type agentTool struct {
14+ parentSessionID string
15+ app *app.App
16+}
17+
18+const (
19+ AgentToolName = "agent"
20+)
21+
22+type AgentParams struct {
23+ Prompt string `json:"prompt"`
24+}
25+
26+func (b *agentTool) Info() tools.ToolInfo {
27+ return tools.ToolInfo{
28+ Name: AgentToolName,
29+ Description: "Launch a new agent that has access to the following tools: GlobTool, GrepTool, LS, View. When you are searching for a keyword or file and are not confident that you will find the right match on the first try, use the Agent tool to perform the search for you. For example:\n\n- If you are searching for a keyword like \"config\" or \"logger\", or for questions like \"which file does X?\", the Agent tool is strongly recommended\n- If you want to read a specific file path, use the View or GlobTool tool instead of the Agent tool, to find the match more quickly\n- If you are searching for a specific class definition like \"class Foo\", use the GlobTool tool instead, to find the match more quickly\n\nUsage notes:\n1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses\n2. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.\n3. Each agent invocation is stateless. You will not be able to send additional messages to the agent, nor will the agent be able to communicate with you outside of its final report. Therefore, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you.\n4. The agent's outputs should generally be trusted\n5. IMPORTANT: The agent can not use Bash, Replace, Edit, so can not modify files. If you want to use these tools, use them directly instead of going through the agent.",
30+ Parameters: map[string]any{
31+ "prompt": map[string]any{
32+ "type": "string",
33+ "description": "The task for the agent to perform",
34+ },
35+ },
36+ Required: []string{"prompt"},
37+ }
38+}
39+
40+func (b *agentTool) Run(ctx context.Context, call tools.ToolCall) (tools.ToolResponse, error) {
41+ var params AgentParams
42+ if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
43+ return tools.NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
44+ }
45+ if params.Prompt == "" {
46+ return tools.NewTextErrorResponse("prompt is required"), nil
47+ }
48+
49+ agent, err := NewTaskAgent(b.app)
50+ if err != nil {
51+ return tools.NewTextErrorResponse(fmt.Sprintf("error creating agent: %s", err)), nil
52+ }
53+
54+ session, err := b.app.Sessions.CreateTaskSession(call.ID, b.parentSessionID, "New Agent Session")
55+ if err != nil {
56+ return tools.NewTextErrorResponse(fmt.Sprintf("error creating session: %s", err)), nil
57+ }
58+
59+ err = agent.Generate(session.ID, params.Prompt)
60+ if err != nil {
61+ return tools.NewTextErrorResponse(fmt.Sprintf("error generating agent: %s", err)), nil
62+ }
63+
64+ messages, err := b.app.Messages.List(session.ID)
65+ if err != nil {
66+ return tools.NewTextErrorResponse(fmt.Sprintf("error listing messages: %s", err)), nil
67+ }
68+ if len(messages) == 0 {
69+ return tools.NewTextErrorResponse("no messages found"), nil
70+ }
71+
72+ response := messages[len(messages)-1]
73+ if response.Role != message.Assistant {
74+ return tools.NewTextErrorResponse("no assistant message found"), nil
75+ }
76+
77+ updatedSession, err := b.app.Sessions.Get(session.ID)
78+ if err != nil {
79+ return tools.NewTextErrorResponse(fmt.Sprintf("error: %s", err)), nil
80+ }
81+ parentSession, err := b.app.Sessions.Get(b.parentSessionID)
82+ if err != nil {
83+ return tools.NewTextErrorResponse(fmt.Sprintf("error: %s", err)), nil
84+ }
85+
86+ parentSession.Cost += updatedSession.Cost
87+ parentSession.PromptTokens += updatedSession.PromptTokens
88+ parentSession.CompletionTokens += updatedSession.CompletionTokens
89+
90+ _, err = b.app.Sessions.Save(parentSession)
91+ if err != nil {
92+ return tools.NewTextErrorResponse(fmt.Sprintf("error: %s", err)), nil
93+ }
94+ return tools.NewTextResponse(response.Content), nil
95+}
96+
97+func NewAgentTool(parentSessionID string, app *app.App) tools.BaseTool {
98+ return &agentTool{
99+ parentSessionID: parentSessionID,
100+ app: app,
101+ }
102+}
internal/llm/agent/agent.go+344−7
@@ -2,16 +2,353 @@ package agent
2
2
3import (
3import (
4 "context"
4 "context"
5+ "errors"
5 "fmt"
6 "fmt"
7+ "log"
8+ "sync"
6
9
7− "github.com/cloudwego/eino/flow/agent/react"
10+ "github.com/kujtimiihoxha/termai/internal/app"
11+ "github.com/kujtimiihoxha/termai/internal/config"
12+ "github.com/kujtimiihoxha/termai/internal/llm/models"
13+ "github.com/kujtimiihoxha/termai/internal/llm/prompt"
14+ "github.com/kujtimiihoxha/termai/internal/llm/provider"
15+ "github.com/kujtimiihoxha/termai/internal/llm/tools"
16+ "github.com/kujtimiihoxha/termai/internal/message"
8)
17)
9
18
10−func GetAgent(ctx context.Context, name string) (*react.Agent, string, error) {
19+type Agent interface {
20+ Generate(sessionID string, content string) error
21+}
22+
23+type agent struct {
24+ *app.App
25+ model models.Model
26+ tools []tools.BaseTool
27+ agent provider.Provider
28+ titleGenerator provider.Provider
29+}
30+
31+func (c *agent) handleTitleGeneration(sessionID, content string) {
32+ response, err := c.titleGenerator.SendMessages(
33+ c.Context,
34+ []message.Message{
35+ {
36+ Role: message.User,
37+ Content: content,
38+ },
39+ },
40+ nil,
41+ )
42+ if err != nil {
43+ return
44+ }
45+
46+ session, err := c.Sessions.Get(sessionID)
47+ if err != nil {
48+ return
49+ }
50+ if response.Content != "" {
51+ session.Title = response.Content
52+ c.Sessions.Save(session)
53+ }
54+}
55+
56+func (c *agent) TrackUsage(sessionID string, model models.Model, usage provider.TokenUsage) error {
57+ session, err := c.Sessions.Get(sessionID)
58+ if err != nil {
59+ return err
60+ }
61+
62+ cost := model.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) +
63+ model.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) +
64+ model.CostPer1MIn/1e6*float64(usage.InputTokens) +
65+ model.CostPer1MOut/1e6*float64(usage.OutputTokens)
66+
67+ session.Cost += cost
68+ session.CompletionTokens += usage.OutputTokens
69+ session.PromptTokens += usage.InputTokens
70+
71+ _, err = c.Sessions.Save(session)
72+ return err
73+}
74+
75+func (c *agent) processEvent(
76+ sessionID string,
77+ assistantMsg *message.Message,
78+ event provider.ProviderEvent,
79+) error {
11− switch name {
80+ switch event.Type {
12− case "coder":
81+ case provider.EventThinkingDelta:
13− agent, err := NewCoderAgent(ctx)
82+ assistantMsg.Thinking += event.Thinking
14− return agent, CoderSystemPrompt(), err
83+ return c.Messages.Update(*assistantMsg)
84+ case provider.EventContentDelta:
85+ assistantMsg.Content += event.Content
86+ return c.Messages.Update(*assistantMsg)
87+ case provider.EventError:
88+ log.Println("error", event.Error)
89+ return event.Error
90+
91+ case provider.EventComplete:
92+ assistantMsg.ToolCalls = event.Response.ToolCalls
93+ err := c.Messages.Update(*assistantMsg)
94+ if err != nil {
95+ return err
96+ }
97+ return c.TrackUsage(sessionID, c.model, event.Response.Usage)
98+ }
99+
100+ return nil
101+}
102+
103+func (c *agent) ExecuteTools(ctx context.Context, toolCalls []message.ToolCall, tls []tools.BaseTool) ([]message.ToolResult, error) {
104+ var wg sync.WaitGroup
105+ toolResults := make([]message.ToolResult, len(toolCalls))
106+ mutex := &sync.Mutex{}
107+
108+ for i, tc := range toolCalls {
109+ wg.Add(1)
110+ go func(index int, toolCall message.ToolCall) {
111+ defer wg.Done()
112+
113+ response := ""
114+ isError := false
115+ found := false
116+
117+ for _, tool := range tls {
118+ if tool.Info().Name == toolCall.Name {
119+ found = true
120+ toolResult, toolErr := tool.Run(ctx, tools.ToolCall{
121+ ID: toolCall.ID,
122+ Name: toolCall.Name,
123+ Input: toolCall.Input,
124+ })
125+ if toolErr != nil {
126+ response = fmt.Sprintf("error running tool: %s", toolErr)
127+ isError = true
128+ } else {
129+ response = toolResult.Content
130+ isError = toolResult.IsError
131+ }
132+ break
133+ }
134+ }
135+
136+ if !found {
137+ response = fmt.Sprintf("tool not found: %s", toolCall.Name)
138+ isError = true
139+ }
140+
141+ mutex.Lock()
142+ defer mutex.Unlock()
143+
144+ toolResults[index] = message.ToolResult{
145+ ToolCallID: toolCall.ID,
146+ Content: response,
147+ IsError: isError,
148+ }
149+ }(i, tc)
150+ }
151+
152+ wg.Wait()
153+ return toolResults, nil
154+}
155+
156+func (c *agent) handleToolExecution(
157+ ctx context.Context,
158+ assistantMsg message.Message,
159+) (*message.Message, error) {
160+ if len(assistantMsg.ToolCalls) == 0 {
161+ return nil, nil
162+ }
163+
164+ toolResults, err := c.ExecuteTools(ctx, assistantMsg.ToolCalls, c.tools)
165+ if err != nil {
166+ return nil, err
15 }
167 }
16− return nil, "", fmt.Errorf("agent %s not found", name)
168+
169+ msg, err := c.Messages.Create(assistantMsg.SessionID, message.CreateMessageParams{
170+ Role: message.Tool,
171+ ToolResults: toolResults,
172+ })
173+
174+ return &msg, err
175+}
176+
177+func (c *agent) generate(sessionID string, content string) error {
178+ messages, err := c.Messages.List(sessionID)
179+ if err != nil {
180+ return err
181+ }
182+
183+ if len(messages) == 0 {
184+ go c.handleTitleGeneration(sessionID, content)
185+ }
186+
187+ userMsg, err := c.Messages.Create(sessionID, message.CreateMessageParams{
188+ Role: message.User,
189+ Content: content,
190+ })
191+ if err != nil {
192+ return err
193+ }
194+
195+ messages = append(messages, userMsg)
196+ for {
197+
198+ eventChan, err := c.agent.StreamResponse(c.Context, messages, c.tools)
199+ if err != nil {
200+ return err
201+ }
202+
203+ assistantMsg, err := c.Messages.Create(sessionID, message.CreateMessageParams{
204+ Role: message.Assistant,
205+ Content: "",
206+ })
207+ if err != nil {
208+ return err
209+ }
210+ for event := range eventChan {
211+ err = c.processEvent(sessionID, &assistantMsg, event)
212+ if err != nil {
213+ assistantMsg.Finished = true
214+ c.Messages.Update(assistantMsg)
215+ return err
216+ }
217+ }
218+
219+ msg, err := c.handleToolExecution(c.Context, assistantMsg)
220+ assistantMsg.Finished = true
221+ c.Messages.Update(assistantMsg)
222+ if err != nil {
223+ return err
224+ }
225+
226+ if len(assistantMsg.ToolCalls) == 0 {
227+ break
228+ }
229+
230+ messages = append(messages, assistantMsg)
231+ if msg != nil {
232+ messages = append(messages, *msg)
233+ }
234+ }
235+ return nil
236+}
237+
238+func getAgentProviders(ctx context.Context, model models.Model) (provider.Provider, provider.Provider, error) {
239+ maxTokens := config.Get().Model.CoderMaxTokens
240+
241+ providerConfig, ok := config.Get().Providers[model.Provider]
242+ if !ok || !providerConfig.Enabled {
243+ return nil, nil, errors.New("provider is not enabled")
244+ }
245+ var agentProvider provider.Provider
246+ var titleGenerator provider.Provider
247+
248+ switch model.Provider {
249+ case models.ProviderOpenAI:
250+ var err error
251+ agentProvider, err = provider.NewOpenAIProvider(
252+ provider.WithOpenAISystemMessage(
253+ prompt.CoderOpenAISystemPrompt(),
254+ ),
255+ provider.WithOpenAIMaxTokens(maxTokens),
256+ provider.WithOpenAIModel(model),
257+ provider.WithOpenAIKey(providerConfig.APIKey),
258+ )
259+ if err != nil {
260+ return nil, nil, err
261+ }
262+ titleGenerator, err = provider.NewOpenAIProvider(
263+ provider.WithOpenAISystemMessage(
264+ prompt.TitlePrompt(),
265+ ),
266+ provider.WithOpenAIMaxTokens(80),
267+ provider.WithOpenAIModel(model),
268+ provider.WithOpenAIKey(providerConfig.APIKey),
269+ )
270+ if err != nil {
271+ return nil, nil, err
272+ }
273+ case models.ProviderAnthropic:
274+ var err error
275+ agentProvider, err = provider.NewAnthropicProvider(
276+ provider.WithAnthropicSystemMessage(
277+ prompt.CoderAnthropicSystemPrompt(),
278+ ),
279+ provider.WithAnthropicMaxTokens(maxTokens),
280+ provider.WithAnthropicKey(providerConfig.APIKey),
281+ provider.WithAnthropicModel(model),
282+ )
283+ if err != nil {
284+ return nil, nil, err
285+ }
286+ titleGenerator, err = provider.NewAnthropicProvider(
287+ provider.WithAnthropicSystemMessage(
288+ prompt.TitlePrompt(),
289+ ),
290+ provider.WithAnthropicMaxTokens(80),
291+ provider.WithAnthropicKey(providerConfig.APIKey),
292+ provider.WithAnthropicModel(model),
293+ )
294+ if err != nil {
295+ return nil, nil, err
296+ }
297+
298+ case models.ProviderGemini:
299+ var err error
300+ agentProvider, err = provider.NewGeminiProvider(
301+ ctx,
302+ provider.WithGeminiSystemMessage(
303+ prompt.CoderOpenAISystemPrompt(),
304+ ),
305+ provider.WithGeminiMaxTokens(int32(maxTokens)),
306+ provider.WithGeminiKey(providerConfig.APIKey),
307+ provider.WithGeminiModel(model),
308+ )
309+ if err != nil {
310+ return nil, nil, err
311+ }
312+ titleGenerator, err = provider.NewGeminiProvider(
313+ ctx,
314+ provider.WithGeminiSystemMessage(
315+ prompt.TitlePrompt(),
316+ ),
317+ provider.WithGeminiMaxTokens(80),
318+ provider.WithGeminiKey(providerConfig.APIKey),
319+ provider.WithGeminiModel(model),
320+ )
321+ if err != nil {
322+ return nil, nil, err
323+ }
324+ case models.ProviderGROQ:
325+ var err error
326+ agentProvider, err = provider.NewOpenAIProvider(
327+ provider.WithOpenAISystemMessage(
328+ prompt.CoderAnthropicSystemPrompt(),
329+ ),
330+ provider.WithOpenAIMaxTokens(maxTokens),
331+ provider.WithOpenAIModel(model),
332+ provider.WithOpenAIKey(providerConfig.APIKey),
333+ provider.WithOpenAIBaseURL("https://api.groq.com/openai/v1"),
334+ )
335+ if err != nil {
336+ return nil, nil, err
337+ }
338+ titleGenerator, err = provider.NewOpenAIProvider(
339+ provider.WithOpenAISystemMessage(
340+ prompt.TitlePrompt(),
341+ ),
342+ provider.WithOpenAIMaxTokens(80),
343+ provider.WithOpenAIModel(model),
344+ provider.WithOpenAIKey(providerConfig.APIKey),
345+ provider.WithOpenAIBaseURL("https://api.groq.com/openai/v1"),
346+ )
347+ if err != nil {
348+ return nil, nil, err
349+ }
350+
351+ }
352+
353+ return agentProvider, titleGenerator, nil
17}
354}
internal/llm/agent/coder.go+48−163
@@ -1,182 +1,67 @@
1package agent
1package agent
2
2
3import (
3import (
4− "context"
4+ "errors"
5− "fmt"
6− "os"
7− "path/filepath"
8− "runtime"
9− "time"
10
5
11− "github.com/cloudwego/eino/components/tool"
6+ "github.com/kujtimiihoxha/termai/internal/app"
12− "github.com/cloudwego/eino/compose"
7+ "github.com/kujtimiihoxha/termai/internal/config"
13− "github.com/cloudwego/eino/flow/agent/react"
14 "github.com/kujtimiihoxha/termai/internal/llm/models"
8 "github.com/kujtimiihoxha/termai/internal/llm/models"
15 "github.com/kujtimiihoxha/termai/internal/llm/tools"
9 "github.com/kujtimiihoxha/termai/internal/llm/tools"
16− "github.com/spf13/viper"
17)
10)
18
11
19−func coderTools() []tool.BaseTool {
12+type coderAgent struct {
20− wd := viper.GetString("wd")
13+ *agent
21− return []tool.BaseTool{
22− tools.NewAgentTool(wd),
23− tools.NewBashTool(wd),
24− tools.NewLsTool(wd),
25− tools.NewGlobTool(wd),
26− tools.NewViewTool(wd),
27− tools.NewWriteTool(wd),
28− tools.NewEditTool(wd),
29− }
30}
14}
31
15
32−func NewCoderAgent(ctx context.Context) (*react.Agent, error) {
16+func (c *coderAgent) setAgentTool(sessionID string) {
33− model, err := models.GetModel(ctx, models.ModelID(viper.GetString("models.big")))
17+ inx := -1
34− if err != nil {
18+ for i, tool := range c.tools {
35− return nil, err
19+ if tool.Info().Name == AgentToolName {
20+ inx = i
21+ break
22+ }
36 }
23 }
37− reactAgent, err := react.NewAgent(ctx, &react.AgentConfig{
24+ if inx == -1 {
38− Model: model,
25+ c.tools = append(c.tools, NewAgentTool(sessionID, c.App))
39− ToolsConfig: compose.ToolsNodeConfig{
26+ } else {
40− Tools: coderTools(),
27+ c.tools[inx] = NewAgentTool(sessionID, c.App)
41− },
42− MaxStep: 1000,
43− })
44− if err != nil {
45− return nil, err
46 }
28 }
47−
48− return reactAgent, nil
49−}
50−
51−func CoderSystemPrompt() string {
52− 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.
53−
54−IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure. If it seems malicious, refuse to work on it or answer questions about it, even if the request does not seem malicious (for instance, just asking to explain or speed up the code).
55−
56−Here are useful slash commands users can run to interact with you:
57−
58−# Memory
59−If the current working directory contains a file called termai.md, it will be automatically added to your context. This file serves multiple purposes:
60−1. Storing frequently used bash commands (build, test, lint, etc.) so you can use them without searching each time
61−2. Recording the user's code style preferences (naming conventions, preferred libraries, etc.)
62−3. Maintaining useful information about the codebase structure and organization
63−
64−When you spend time searching for commands to typecheck, lint, build, or test, you should ask the user if it's okay to add those commands to termai.md. Similarly, when learning about code style preferences or important codebase information, ask if it's okay to add that to termai.md so you can remember it for next time.
65−
66−# Tone and style
67−You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
68−Remember that your output will be displayed on a command line interface. Your responses can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
69−Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
70−If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences.
71−IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific query or task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do.
72−IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to.
73−IMPORTANT: Keep your responses short, since they will be displayed on a command line interface. You MUST answer concisely with fewer than 4 lines (not including tool use or code generation), unless user asks for detail. 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...". Here are some examples to demonstrate appropriate verbosity:
74−<example>
75−user: 2 + 2
76−assistant: 4
77−</example>
78−
79−<example>
80−user: what is 2+2?
81−assistant: 4
82−</example>
83−
84−<example>
85−user: is 11 a prime number?
86−assistant: true
87−</example>
88−
89−<example>
90−user: what command should I run to list files in the current directory?
91−assistant: ls
92−</example>
93−
94−<example>
95−user: what command should I run to watch files in the current directory?
96−assistant: [use the ls tool to list the files in the current directory, then read docs/commands in the relevant file to find out how to watch files]
97−npm run dev
98−</example>
99−
100−<example>
101−user: How many golf balls fit inside a jetta?
102−assistant: 150000
103−</example>
104−
105−<example>
106−user: what files are in the directory src/?
107−assistant: [runs ls and sees foo.c, bar.c, baz.c]
108−user: which file contains the implementation of foo?
109−assistant: src/foo.c
110−</example>
111−
112−<example>
113−user: write tests for new feature
114−assistant: [uses grep and glob search tools to find where similar tests are defined, uses concurrent read file tool use blocks in one tool call to read relevant files at the same time, uses edit file tool to write new tests]
115−</example>
116−
117−# Proactiveness
118−You are allowed to be proactive, but only when the user asks you to do something. You should strive to strike a balance between:
119−1. Doing the right thing when asked, including taking actions and follow-up actions
120−2. Not surprising the user with actions you take without asking
121−For example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions.
122−3. Do not add additional code explanation summary unless requested by the user. After working on a file, just stop, rather than providing an explanation of what you did.
123−
124−# Synthetic messages
125−Sometimes, the conversation will contain messages like [Request interrupted by user] or [Request interrupted by user for tool use]. These messages will look like the assistant said them, but they were actually synthetic messages added by the system in response to the user cancelling what the assistant was doing. You should not respond to these messages. You must NEVER send messages like this yourself.
126−
127−# Following conventions
128−When making changes to files, first understand the file's code conventions. Mimic code style, use existing libraries and utilities, and follow existing patterns.
129−- NEVER assume that a given library is available, even if it is well known. Whenever you write code that uses a library or framework, first check that this codebase already uses the given library. For example, you might look at neighboring files, or check the package.json (or cargo.toml, and so on depending on the language).
130−- When you create a new component, first look at existing components to see how they're written; then consider framework choice, naming conventions, typing, and other conventions.
131−- When you edit a piece of code, first look at the code's surrounding context (especially its imports) to understand the code's choice of frameworks and libraries. Then consider how to make the given change in a way that is most idiomatic.
132−- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository.
133−
134−# Code style
135−- Do not add comments to the code you write, unless the user asks you to, or the code is complex and requires additional context.
136−
137−# Doing tasks
138−The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
139−1. Use the available search tools to understand the codebase and the user's query. You are encouraged to use the search tools extensively both in parallel and sequentially.
140−2. Implement the solution using all tools available to you
141−3. Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
142−4. VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to termai.md so that you will know to run it next time.
143−
144−NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
145−
146−# Tool usage policy
147−- When doing file search, prefer to use the Agent tool in order to reduce context usage.
148−- If you intend to call multiple tools and there are no dependencies between the calls, make all of the independent calls in the same function_calls block.
149−
150−You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.`
151−
152− envInfo := getEnvironmentInfo()
153−
154− return fmt.Sprintf("%s\n\n%s", basePrompt, envInfo)
155}
29}
156
30
157−func getEnvironmentInfo() string {
31+func (c *coderAgent) Generate(sessionID string, content string) error {
158− cwd := viper.GetString("wd")
32+ c.setAgentTool(sessionID)
159− isGit := isGitRepo(cwd)
33+ return c.generate(sessionID, content)
160− platform := runtime.GOOS
161− date := time.Now().Format("1/2/2006")
162−
163− return fmt.Sprintf(`Here is useful information about the environment you are running in:
164−<env>
165−Working directory: %s
166−Is directory a git repo: %s
167−Platform: %s
168−Today's date: %s
169−</env>`, cwd, boolToYesNo(isGit), platform, date)
170}
34}
171
35
172−func isGitRepo(dir string) bool {
36+func NewCoderAgent(app *app.App) (Agent, error) {
173− _, err := os.Stat(filepath.Join(dir, ".git"))
37+ model, ok := models.SupportedModels[config.Get().Model.Coder]
174− return err == nil
38+ if !ok {
39+ return nil, errors.New("model not supported")
175−}
40+ }
176
41
177−func boolToYesNo(b bool) string {
42+ agentProvider, titleGenerator, err := getAgentProviders(app.Context, model)
178− if b {
43+ if err != nil {
179− return "Yes"
44+ return nil, err
180 }
45 }
46+
47+ mcpTools := GetMcpTools(app.Context)
181− return "No"
48+ return &coderAgent{
49+ agent: &agent{
50+ App: app,
51+ tools: append(
52+ []tools.BaseTool{
53+ tools.NewBashTool(),
54+ tools.NewEditTool(),
55+ tools.NewGlobTool(),
56+ tools.NewGrepTool(),
57+ tools.NewLsTool(),
58+ tools.NewViewTool(),
59+ tools.NewWriteTool(),
60+ }, mcpTools...,
61+ ),
62+ model: model,
63+ agent: agentProvider,
64+ titleGenerator: titleGenerator,
65+ },
66+ }, nil
182}
67}
internal/llm/agent/mcp-tools.gonew file+190
@@ -0,0 +1,190 @@
1+package agent
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "log"
8+
9+ "github.com/kujtimiihoxha/termai/internal/config"
10+ "github.com/kujtimiihoxha/termai/internal/llm/tools"
11+ "github.com/kujtimiihoxha/termai/internal/permission"
12+ "github.com/kujtimiihoxha/termai/internal/version"
13+
14+ "github.com/mark3labs/mcp-go/client"
15+ "github.com/mark3labs/mcp-go/mcp"
16+)
17+
18+type mcpTool struct {
19+ mcpName string
20+ tool mcp.Tool
21+ mcpConfig config.MCPServer
22+}
23+
24+type MCPClient interface {
25+ Initialize(
26+ ctx context.Context,
27+ request mcp.InitializeRequest,
28+ ) (*mcp.InitializeResult, error)
29+ ListTools(ctx context.Context, request mcp.ListToolsRequest) (*mcp.ListToolsResult, error)
30+ CallTool(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error)
31+ Close() error
32+}
33+
34+func (b *mcpTool) Info() tools.ToolInfo {
35+ return tools.ToolInfo{
36+ Name: fmt.Sprintf("%s_%s", b.mcpName, b.tool.Name),
37+ Description: b.tool.Description,
38+ Parameters: b.tool.InputSchema.Properties,
39+ Required: b.tool.InputSchema.Required,
40+ }
41+}
42+
43+func runTool(ctx context.Context, c MCPClient, toolName string, input string) (tools.ToolResponse, error) {
44+ defer c.Close()
45+ initRequest := mcp.InitializeRequest{}
46+ initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
47+ initRequest.Params.ClientInfo = mcp.Implementation{
48+ Name: "termai",
49+ Version: version.Version,
50+ }
51+
52+ _, err := c.Initialize(ctx, initRequest)
53+ if err != nil {
54+ return tools.NewTextErrorResponse(err.Error()), nil
55+ }
56+
57+ toolRequest := mcp.CallToolRequest{}
58+ toolRequest.Params.Name = toolName
59+ var args map[string]any
60+ if err = json.Unmarshal([]byte(input), &input); err != nil {
61+ return tools.NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
62+ }
63+ toolRequest.Params.Arguments = args
64+ result, err := c.CallTool(ctx, toolRequest)
65+ if err != nil {
66+ return tools.NewTextErrorResponse(err.Error()), nil
67+ }
68+
69+ output := ""
70+ for _, v := range result.Content {
71+ if v, ok := v.(mcp.TextContent); ok {
72+ output = v.Text
73+ } else {
74+ output = fmt.Sprintf("%v", v)
75+ }
76+ }
77+
78+ return tools.NewTextResponse(output), nil
79+}
80+
81+func (b *mcpTool) Run(ctx context.Context, params tools.ToolCall) (tools.ToolResponse, error) {
82+ permissionDescription := fmt.Sprintf("execute %s with the following parameters: %s", b.Info().Name, params.Input)
83+ p := permission.Default.Request(
84+ permission.CreatePermissionRequest{
85+ Path: config.WorkingDirectory(),
86+ ToolName: b.Info().Name,
87+ Action: "execute",
88+ Description: permissionDescription,
89+ Params: params.Input,
90+ },
91+ )
92+ if !p {
93+ return tools.NewTextErrorResponse("permission denied"), nil
94+ }
95+
96+ switch b.mcpConfig.Type {
97+ case config.MCPStdio:
98+ c, err := client.NewStdioMCPClient(
99+ b.mcpConfig.Command,
100+ b.mcpConfig.Env,
101+ b.mcpConfig.Args...,
102+ )
103+ if err != nil {
104+ return tools.NewTextErrorResponse(err.Error()), nil
105+ }
106+ return runTool(ctx, c, b.tool.Name, params.Input)
107+ case config.MCPSse:
108+ c, err := client.NewSSEMCPClient(
109+ b.mcpConfig.URL,
110+ client.WithHeaders(b.mcpConfig.Headers),
111+ )
112+ if err != nil {
113+ return tools.NewTextErrorResponse(err.Error()), nil
114+ }
115+ return runTool(ctx, c, b.tool.Name, params.Input)
116+ }
117+
118+ return tools.NewTextErrorResponse("invalid mcp type"), nil
119+}
120+
121+func NewMcpTool(name string, tool mcp.Tool, mcpConfig config.MCPServer) tools.BaseTool {
122+ return &mcpTool{
123+ mcpName: name,
124+ tool: tool,
125+ mcpConfig: mcpConfig,
126+ }
127+}
128+
129+var mcpTools []tools.BaseTool
130+
131+func getTools(ctx context.Context, name string, m config.MCPServer, c MCPClient) []tools.BaseTool {
132+ var stdioTools []tools.BaseTool
133+ initRequest := mcp.InitializeRequest{}
134+ initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
135+ initRequest.Params.ClientInfo = mcp.Implementation{
136+ Name: "termai",
137+ Version: version.Version,
138+ }
139+
140+ _, err := c.Initialize(ctx, initRequest)
141+ if err != nil {
142+ log.Printf("error initializing mcp client: %s", err)
143+ return stdioTools
144+ }
145+ toolsRequest := mcp.ListToolsRequest{}
146+ tools, err := c.ListTools(ctx, toolsRequest)
147+ if err != nil {
148+ log.Printf("error listing tools: %s", err)
149+ return stdioTools
150+ }
151+ for _, t := range tools.Tools {
152+ stdioTools = append(stdioTools, NewMcpTool(name, t, m))
153+ }
154+ defer c.Close()
155+ return stdioTools
156+}
157+
158+func GetMcpTools(ctx context.Context) []tools.BaseTool {
159+ if len(mcpTools) > 0 {
160+ return mcpTools
161+ }
162+ for name, m := range config.Get().MCPServers {
163+ switch m.Type {
164+ case config.MCPStdio:
165+ c, err := client.NewStdioMCPClient(
166+ m.Command,
167+ m.Env,
168+ m.Args...,
169+ )
170+ if err != nil {
171+ log.Printf("error creating mcp client: %s", err)
172+ continue
173+ }
174+
175+ mcpTools = append(mcpTools, getTools(ctx, name, m, c)...)
176+ case config.MCPSse:
177+ c, err := client.NewSSEMCPClient(
178+ m.URL,
179+ client.WithHeaders(m.Headers),
180+ )
181+ if err != nil {
182+ log.Printf("error creating mcp client: %s", err)
183+ continue
184+ }
185+ mcpTools = append(mcpTools, getTools(ctx, name, m, c)...)
186+ }
187+ }
188+
189+ return mcpTools
190+}
internal/llm/agent/task.gonew file+44
@@ -0,0 +1,44 @@
1+package agent
2+
3+import (
4+ "errors"
5+
6+ "github.com/kujtimiihoxha/termai/internal/app"
7+ "github.com/kujtimiihoxha/termai/internal/config"
8+ "github.com/kujtimiihoxha/termai/internal/llm/models"
9+ "github.com/kujtimiihoxha/termai/internal/llm/tools"
10+)
11+
12+type taskAgent struct {
13+ *agent
14+}
15+
16+func (c *taskAgent) Generate(sessionID string, content string) error {
17+ return c.generate(sessionID, content)
18+}
19+
20+func NewTaskAgent(app *app.App) (Agent, error) {
21+ model, ok := models.SupportedModels[config.Get().Model.Coder]
22+ if !ok {
23+ return nil, errors.New("model not supported")
24+ }
25+
26+ agentProvider, titleGenerator, err := getAgentProviders(app.Context, model)
27+ if err != nil {
28+ return nil, err
29+ }
30+ return &taskAgent{
31+ agent: &agent{
32+ App: app,
33+ tools: []tools.BaseTool{
34+ tools.NewGlobTool(),
35+ tools.NewGrepTool(),
36+ tools.NewLsTool(),
37+ tools.NewViewTool(),
38+ },
39+ model: model,
40+ agent: agentProvider,
41+ titleGenerator: titleGenerator,
42+ },
43+ }, nil
44+}
internal/llm/agent/title.godeleted−31
@@ -1,31 +0,0 @@
1−package agent
2−
3−import (
4− "context"
5−
6− "github.com/cloudwego/eino/schema"
7− "github.com/kujtimiihoxha/termai/internal/llm/models"
8− "github.com/spf13/viper"
9−)
10−
11−func GenerateTitle(ctx context.Context, content string) (string, error) {
12− model, err := models.GetModel(ctx, models.ModelID(viper.GetString("models.small")))
13− if err != nil {
14− return "", err
15− }
16− out, err := model.Generate(
17− ctx,
18− []*schema.Message{
19− schema.SystemMessage(`- you will generate a short title based on the first message a user begins a conversation with
20− - ensure it is not more than 80 characters long
21− - the title should be a summary of the user's message
22− - do not use quotes or colons
23− - the entire text you return will be used as the title`),
24− schema.UserMessage(content),
25− },
26− )
27− if err != nil {
28− return "", err
29− }
30− return out.Content, nil
31−}
internal/llm/llm.godeleted−229
@@ -1,229 +0,0 @@
1−package llm
2−
3−import (
4− "context"
5− "log"
6− "sync"
7− "time"
8−
9− "github.com/cloudwego/eino/callbacks"
10− "github.com/cloudwego/eino/compose"
11− "github.com/cloudwego/eino/schema"
12− "github.com/google/uuid"
13− "github.com/kujtimiihoxha/termai/internal/llm/agent"
14− "github.com/kujtimiihoxha/termai/internal/llm/models"
15− "github.com/kujtimiihoxha/termai/internal/logging"
16− "github.com/kujtimiihoxha/termai/internal/message"
17− "github.com/kujtimiihoxha/termai/internal/pubsub"
18− "github.com/kujtimiihoxha/termai/internal/session"
19−
20− eModel "github.com/cloudwego/eino/components/model"
21− enioAgent "github.com/cloudwego/eino/flow/agent"
22− "github.com/spf13/viper"
23−)
24−
25−const (
26− AgentRequestoEvent pubsub.EventType = "agent_request"
27− AgentErrorEvent pubsub.EventType = "agent_error"
28− AgentResponseEvent pubsub.EventType = "agent_response"
29−)
30−
31−type AgentMessageType int
32−
33−const (
34− AgentMessageTypeNewUserMessage AgentMessageType = iota
35− AgentMessageTypeAgentResponse
36− AgentMessageTypeError
37−)
38−
39−type agentID string
40−
41−const (
42− RootAgent agentID = "root"
43− TaskAgent agentID = "task"
44−)
45−
46−type AgentEvent struct {
47− ID string `json:"id"`
48− Type AgentMessageType `json:"type"`
49− AgentID agentID `json:"agent_id"`
50− MessageID string `json:"message_id"`
51− SessionID string `json:"session_id"`
52− Content string `json:"content"`
53−}
54−
55−type Service interface {
56− pubsub.Suscriber[AgentEvent]
57−
58− SendRequest(sessionID string, content string)
59−}
60−type service struct {
61− *pubsub.Broker[AgentEvent]
62− Requests sync.Map
63− ctx context.Context
64− activeRequests sync.Map
65− messages message.Service
66− sessions session.Service
67− logger logging.Interface
68−}
69−
70−func (s *service) handleRequest(id string, sessionID string, content string) {
71− cancel, ok := s.activeRequests.Load(id)
72− if !ok {
73− return
74− }
75− defer cancel.(context.CancelFunc)()
76− defer s.activeRequests.Delete(id)
77−
78− history, err := s.messages.List(sessionID)
79− if err != nil {
80− s.Publish(AgentErrorEvent, AgentEvent{
81− ID: id,
82− Type: AgentMessageTypeError,
83− AgentID: RootAgent,
84− MessageID: "",
85− SessionID: sessionID,
86− Content: err.Error(),
87− })
88− return
89− }
90−
91− currentAgent, systemMessage, err := agent.GetAgent(s.ctx, viper.GetString("agents.default"))
92− if err != nil {
93− s.Publish(AgentErrorEvent, AgentEvent{
94− ID: id,
95− Type: AgentMessageTypeError,
96− AgentID: RootAgent,
97− MessageID: "",
98− SessionID: sessionID,
99− Content: err.Error(),
100− })
101− return
102− }
103−
104− messages := []*schema.Message{
105− {
106− Role: schema.System,
107− Content: systemMessage,
108− },
109− }
110− for _, m := range history {
111− messages = append(messages, &m.MessageData)
112− }
113−
114− builder := callbacks.NewHandlerBuilder()
115− builder.OnStartFn(func(ctx context.Context, info *callbacks.RunInfo, input callbacks.CallbackInput) context.Context {
116− i, ok := input.(*eModel.CallbackInput)
117− if info.Component == "ChatModel" && ok {
118− if len(messages) < len(i.Messages) {
119− // find new messages
120− newMessages := i.Messages[len(messages):]
121− for _, m := range newMessages {
122− _, err = s.messages.Create(sessionID, *m)
123− if err != nil {
124− s.Publish(AgentErrorEvent, AgentEvent{
125− ID: id,
126− Type: AgentMessageTypeError,
127− AgentID: RootAgent,
128− MessageID: "",
129− SessionID: sessionID,
130− Content: err.Error(),
131− })
132− }
133− messages = append(messages, m)
134− }
135− }
136− }
137−
138− return ctx
139− })
140− builder.OnEndFn(func(ctx context.Context, info *callbacks.RunInfo, output callbacks.CallbackOutput) context.Context {
141− return ctx
142− })
143−
144− out, err := currentAgent.Generate(s.ctx, messages, enioAgent.WithComposeOptions(compose.WithCallbacks(builder.Build())))
145− if err != nil {
146− s.Publish(AgentErrorEvent, AgentEvent{
147− ID: id,
148− Type: AgentMessageTypeError,
149− AgentID: RootAgent,
150− MessageID: "",
151− SessionID: sessionID,
152− Content: err.Error(),
153− })
154− return
155− }
156− usage := out.ResponseMeta.Usage
157− s.messages.Create(sessionID, *out)
158− if usage != nil {
159− log.Printf("Prompt Tokens: %d, Completion Tokens: %d, Total Tokens: %d", usage.PromptTokens, usage.CompletionTokens, usage.TotalTokens)
160− session, err := s.sessions.Get(sessionID)
161− if err != nil {
162− s.Publish(AgentErrorEvent, AgentEvent{
163− ID: id,
164− Type: AgentMessageTypeError,
165− AgentID: RootAgent,
166− MessageID: "",
167− SessionID: sessionID,
168− Content: err.Error(),
169− })
170− return
171− }
172− session.PromptTokens += int64(usage.PromptTokens)
173− session.CompletionTokens += int64(usage.CompletionTokens)
174− model := models.SupportedModels[models.ModelID(viper.GetString("models.big"))]
175− session.Cost += float64(usage.PromptTokens)*(model.CostPer1MIn/1_000_000) +
176− float64(usage.CompletionTokens)*(model.CostPer1MOut/1_000_000)
177− var newTitle string
178− if len(history) == 1 {
179− // first message generate the title
180− newTitle, err = agent.GenerateTitle(s.ctx, content)
181− if err != nil {
182− s.Publish(AgentErrorEvent, AgentEvent{
183− ID: id,
184− Type: AgentMessageTypeError,
185− AgentID: RootAgent,
186− MessageID: "",
187− SessionID: sessionID,
188− Content: err.Error(),
189− })
190− return
191− }
192− }
193− if newTitle != "" {
194− session.Title = newTitle
195− }
196−
197− _, err = s.sessions.Save(session)
198− if err != nil {
199− s.Publish(AgentErrorEvent, AgentEvent{
200− ID: id,
201− Type: AgentMessageTypeError,
202− AgentID: RootAgent,
203− MessageID: "",
204− SessionID: sessionID,
205− Content: err.Error(),
206− })
207− return
208− }
209− }
210−}
211−
212−func (s *service) SendRequest(sessionID string, content string) {
213− id := uuid.New().String()
214−
215− _, cancel := context.WithTimeout(s.ctx, 5*time.Minute)
216− s.activeRequests.Store(id, cancel)
217− log.Printf("Request: %s", content)
218− go s.handleRequest(id, sessionID, content)
219−}
220−
221−func NewService(ctx context.Context, logger logging.Interface, sessions session.Service, messages message.Service) Service {
222− return &service{
223− Broker: pubsub.NewBroker[AgentEvent](),
224− ctx: ctx,
225− sessions: sessions,
226− messages: messages,
227− logger: logger,
228− }
229−}
internal/llm/models/models.go+83−191
@@ -1,230 +1,122 @@
1package models
1package models
2
2
3−import (
4− "context"
5− "errors"
6− "log"
7−
8− "github.com/cloudwego/eino-ext/components/model/claude"
9− "github.com/cloudwego/eino-ext/components/model/openai"
10− "github.com/cloudwego/eino/components/model"
11− "github.com/spf13/viper"
12−)
13−
14type (
3type (
15 ModelID string
4 ModelID string
16 ModelProvider string
5 ModelProvider string
17)
6)
18
7
19type Model struct {
8type Model struct {
20− ID ModelID `json:"id"`
9+ ID ModelID `json:"id"`
21− Name string `json:"name"`
10+ Name string `json:"name"`
22− Provider ModelProvider `json:"provider"`
11+ Provider ModelProvider `json:"provider"`
23− APIModel string `json:"api_model"`
12+ APIModel string `json:"api_model"`
24− CostPer1MIn float64 `json:"cost_per_1m_in"`
13+ CostPer1MIn float64 `json:"cost_per_1m_in"`
25− CostPer1MOut float64 `json:"cost_per_1m_out"`
14+ CostPer1MOut float64 `json:"cost_per_1m_out"`
15+ CostPer1MInCached float64 `json:"cost_per_1m_in_cached"`
16+ CostPer1MOutCached float64 `json:"cost_per_1m_out_cached"`
26}
17}
27
18
28−const (
29− DefaultBigModel = Claude37Sonnet
30− DefaultLittleModel = Claude37Sonnet
31−)
32−
33// Model IDs
19// Model IDs
34const (
20const (
35− // OpenAI
36− GPT4o ModelID = "gpt-4o"
37− GPT4oMini ModelID = "gpt-4o-mini"
38− GPT45 ModelID = "gpt-4.5"
39− O1 ModelID = "o1"
40− O1Mini ModelID = "o1-mini"
41 // Anthropic
21 // Anthropic
42 Claude35Sonnet ModelID = "claude-3.5-sonnet"
22 Claude35Sonnet ModelID = "claude-3.5-sonnet"
43 Claude3Haiku ModelID = "claude-3-haiku"
23 Claude3Haiku ModelID = "claude-3-haiku"
44 Claude37Sonnet ModelID = "claude-3.7-sonnet"
24 Claude37Sonnet ModelID = "claude-3.7-sonnet"
45− // Google
25+ // OpenAI
46− Gemini20Pro ModelID = "gemini-2.0-pro"
26+ GPT4o ModelID = "gpt-4o"
27+
28+ // GEMINI
47− Gemini15Flash ModelID = "gemini-1.5-flash"
29+ GEMINI25 ModelID = "gemini-2.5"
48− Gemini20Flash ModelID = "gemini-2.0-flash"
30+ GRMINI20Flash ModelID = "gemini-2.0-flash"
49− // xAI
31+
50− Grok3 ModelID = "grok-3"
51− Grok2Mini ModelID = "grok-2-mini"
52− // DeepSeek
53− DeepSeekR1 ModelID = "deepseek-r1"
54− DeepSeekCoder ModelID = "deepseek-coder"
55− // Meta
56− Llama3 ModelID = "llama-3"
57− Llama270B ModelID = "llama-2-70b"
58 // GROQ
32 // GROQ
59− GroqLlama3SpecDec ModelID = "groq-llama-3-spec-dec"
33+ QWENQwq ModelID = "qwen-qwq"
60− GroqQwen32BCoder ModelID = "qwen-2.5-coder-32b"
61)
34)
62
35
63const (
36const (
64 ProviderOpenAI ModelProvider = "openai"
37 ProviderOpenAI ModelProvider = "openai"
65 ProviderAnthropic ModelProvider = "anthropic"
38 ProviderAnthropic ModelProvider = "anthropic"
66− ProviderGoogle ModelProvider = "google"
39+ ProviderGemini ModelProvider = "gemini"
67− ProviderXAI ModelProvider = "xai"
68− ProviderDeepSeek ModelProvider = "deepseek"
69− ProviderMeta ModelProvider = "meta"
70− ProviderGroq ModelProvider = "groq"
40+ ProviderGROQ ModelProvider = "groq"
71)
41)
72
42
73var SupportedModels = map[ModelID]Model{
43var SupportedModels = map[ModelID]Model{
74− // OpenAI
75− GPT4o: {
76− ID: GPT4o,
77− Name: "GPT-4o",
78− Provider: ProviderOpenAI,
79− APIModel: "gpt-4o",
80− },
81− GPT4oMini: {
82− ID: GPT4oMini,
83− Name: "GPT-4o Mini",
84− Provider: ProviderOpenAI,
85− APIModel: "gpt-4o-mini",
86− CostPer1MIn: 0.150,
87− CostPer1MOut: 0.600,
88− },
89− GPT45: {
90− ID: GPT45,
91− Name: "GPT-4.5",
92− Provider: ProviderOpenAI,
93− APIModel: "gpt-4.5",
94− },
95− O1: {
96− ID: O1,
97− Name: "o1",
98− Provider: ProviderOpenAI,
99− APIModel: "o1",
100− },
101− O1Mini: {
102− ID: O1Mini,
103− Name: "o1 Mini",
104− Provider: ProviderOpenAI,
105− APIModel: "o1-mini",
106− },
107 // Anthropic
44 // Anthropic
108 Claude35Sonnet: {
45 Claude35Sonnet: {
109− ID: Claude35Sonnet,
46+ ID: Claude35Sonnet,
110− Name: "Claude 3.5 Sonnet",
47+ Name: "Claude 3.5 Sonnet",
111− Provider: ProviderAnthropic,
48+ Provider: ProviderAnthropic,
112− APIModel: "claude-3.5-sonnet",
49+ APIModel: "claude-3-5-sonnet-latest",
50+ CostPer1MIn: 3.0,
51+ CostPer1MInCached: 3.75,
52+ CostPer1MOutCached: 0.30,
53+ CostPer1MOut: 15.0,
113 },
54 },
114 Claude3Haiku: {
55 Claude3Haiku: {
115− ID: Claude3Haiku,
56+ ID: Claude3Haiku,
116− Name: "Claude 3 Haiku",
57+ Name: "Claude 3 Haiku",
117− Provider: ProviderAnthropic,
58+ Provider: ProviderAnthropic,
118− APIModel: "claude-3-haiku",
59+ APIModel: "claude-3-haiku-latest",
60+ CostPer1MIn: 0.80,
61+ CostPer1MInCached: 1,
62+ CostPer1MOutCached: 0.08,
63+ CostPer1MOut: 4,
119 },
64 },
120 Claude37Sonnet: {
65 Claude37Sonnet: {
121− ID: Claude37Sonnet,
66+ ID: Claude37Sonnet,
122− Name: "Claude 3.7 Sonnet",
67+ Name: "Claude 3.7 Sonnet",
123− Provider: ProviderAnthropic,
68+ Provider: ProviderAnthropic,
124− APIModel: "claude-3-7-sonnet-20250219",
69+ APIModel: "claude-3-7-sonnet-latest",
125− CostPer1MIn: 3.0,
70+ CostPer1MIn: 3.0,
71+ CostPer1MInCached: 3.75,
72+ CostPer1MOutCached: 0.30,
126− CostPer1MOut: 15.0,
73+ CostPer1MOut: 15.0,
127 },
74 },
128− // Google
75+
129− Gemini20Pro: {
76+ // OpenAI
130− ID: Gemini20Pro,
77+ GPT4o: {
131− Name: "Gemini 2.0 Pro",
78+ ID: GPT4o,
132− Provider: ProviderGoogle,
79+ Name: "GPT-4o",
133− APIModel: "gemini-2.0-pro",
80+ Provider: ProviderOpenAI,
134− },
135− Gemini15Flash: {
136− ID: Gemini15Flash,
137− Name: "Gemini 1.5 Flash",
138− Provider: ProviderGoogle,
139− APIModel: "gemini-1.5-flash",
81+ APIModel: "gpt-4o",
140− },
82+ CostPer1MIn: 2.50,
141− Gemini20Flash: {
83+ CostPer1MInCached: 1.25,
142− ID: Gemini20Flash,
84+ CostPer1MOutCached: 0,
143− Name: "Gemini 2.0 Flash",
85+ CostPer1MOut: 10.00,
144− Provider: ProviderGoogle,
145− APIModel: "gemini-2.0-flash",
146− },
147− // xAI
148− Grok3: {
149− ID: Grok3,
150− Name: "Grok 3",
151− Provider: ProviderXAI,
152− APIModel: "grok-3",
153− },
154− Grok2Mini: {
155− ID: Grok2Mini,
156− Name: "Grok 2 Mini",
157− Provider: ProviderXAI,
158− APIModel: "grok-2-mini",
159− },
160− // DeepSeek
161− DeepSeekR1: {
162− ID: DeepSeekR1,
163− Name: "DeepSeek R1",
164− Provider: ProviderDeepSeek,
165− APIModel: "deepseek-r1",
166− },
167− DeepSeekCoder: {
168− ID: DeepSeekCoder,
169− Name: "DeepSeek Coder",
170− Provider: ProviderDeepSeek,
171− APIModel: "deepseek-coder",
172 },
86 },
173− // Meta
87+
174− Llama3: {
88+ // GEMINI
175− ID: Llama3,
89+ GEMINI25: {
176− Name: "LLaMA 3",
90+ ID: GEMINI25,
177− Provider: ProviderMeta,
91+ Name: "Gemini 2.5 Pro",
92+ Provider: ProviderGemini,
178− APIModel: "llama-3",
93+ APIModel: "gemini-2.5-pro-exp-03-25",
94+ CostPer1MIn: 0,
95+ CostPer1MInCached: 0,
96+ CostPer1MOutCached: 0,
97+ CostPer1MOut: 0,
179 },
98 },
180− Llama270B: {
99+
181− ID: Llama270B,
100+ GRMINI20Flash: {
182− Name: "LLaMA 2 70B",
101+ ID: GRMINI20Flash,
183− Provider: ProviderMeta,
102+ Name: "Gemini 2.0 Flash",
103+ Provider: ProviderGemini,
184− APIModel: "llama-2-70b",
104+ APIModel: "gemini-2.0-flash",
105+ CostPer1MIn: 0.1,
106+ CostPer1MInCached: 0,
107+ CostPer1MOutCached: 0.025,
108+ CostPer1MOut: 0.4,
185 },
109 },
186
110
187 // GROQ
111 // GROQ
188− GroqLlama3SpecDec: {
112+ QWENQwq: {
189− ID: GroqLlama3SpecDec,
113+ ID: QWENQwq,
190− Name: "GROQ LLaMA 3 SpecDec",
114+ Name: "Qwen Qwq",
191− Provider: ProviderGroq,
192− APIModel: "llama-3.3-70b-specdec",
193− },
194− GroqQwen32BCoder: {
195− ID: GroqQwen32BCoder,
196− Name: "GROQ Qwen 2.5 Coder 32B",
197− Provider: ProviderGroq,
115+ Provider: ProviderGROQ,
198− APIModel: "qwen-2.5-coder-32b",
116+ APIModel: "qwen-qwq-32b",
117+ CostPer1MIn: 0,
118+ CostPer1MInCached: 0,
119+ CostPer1MOutCached: 0,
120+ CostPer1MOut: 0,
199 },
121 },
200}
122}
201−
202−func GetModel(ctx context.Context, model ModelID) (model.ChatModel, error) {
203− provider := SupportedModels[model].Provider
204− log.Printf("Provider: %s", provider)
205− maxTokens := viper.GetInt("providers.common.max_tokens")
206− switch provider {
207− case ProviderOpenAI:
208− return openai.NewChatModel(ctx, &openai.ChatModelConfig{
209− APIKey: viper.GetString("providers.openai.key"),
210− Model: string(SupportedModels[model].APIModel),
211− MaxTokens: &maxTokens,
212− })
213− case ProviderAnthropic:
214− return claude.NewChatModel(ctx, &claude.Config{
215− APIKey: viper.GetString("providers.anthropic.key"),
216− Model: string(SupportedModels[model].APIModel),
217− MaxTokens: maxTokens,
218− })
219−
220− case ProviderGroq:
221− return openai.NewChatModel(ctx, &openai.ChatModelConfig{
222− BaseURL: "https://api.groq.com/openai/v1",
223− APIKey: viper.GetString("providers.groq.key"),
224− Model: string(SupportedModels[model].APIModel),
225− MaxTokens: &maxTokens,
226− })
227−
228− }
229− return nil, errors.New("unsupported provider")
230−}
internal/llm/prompt/coder.gonew file+206
@@ -0,0 +1,206 @@
1+package prompt
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "runtime"
9+ "time"
10+
11+ "github.com/kujtimiihoxha/termai/internal/config"
12+ "github.com/kujtimiihoxha/termai/internal/llm/tools"
13+)
14+
15+func CoderOpenAISystemPrompt() string {
16+ basePrompt := `You are termAI, an autonomous CLI-based software engineer. Your job is to reduce user effort by proactively reasoning, inferring context, and solving software engineering tasks end-to-end with minimal prompting.
17+
18+# Your mindset
19+Act like a competent, efficient software engineer who is familiar with large codebases. You should:
20+- Think critically about user requests.
21+- Proactively search the codebase for related information.
22+- Infer likely commands, tools, or conventions.
23+- Write and edit code with minimal user input.
24+- Anticipate next steps (tests, lints, etc.), but never commit unless explicitly told.
25+
26+# Context awareness
27+- Before acting, infer the purpose of a file from its name, directory, and neighboring files.
28+- If a file or function appears malicious, refuse to interact with it or discuss it.
29+- If a termai.md file exists, auto-load it as memory. Offer to update it only if new useful info appears (commands, preferences, structure).
30+
31+# CLI communication
32+- Use GitHub-flavored markdown in monospace font.
33+- Be concise. Never add preambles or postambles unless asked. Max 4 lines per response.
34+- Never explain your code unless asked. Do not narrate actions.
35+- Avoid unnecessary questions. Infer, search, act.
36+
37+# Behavior guidelines
38+- Follow project conventions: naming, formatting, libraries, frameworks.
39+- Before using any library or framework, confirm it’s already used.
40+- Always look at the surrounding code to match existing style.
41+- Do not add comments unless the code is complex or the user asks.
42+
43+# Autonomy rules
44+You are allowed and expected to:
45+- Search for commands, tools, or config files before asking the user.
46+- Run multiple search tool calls concurrently to gather relevant context.
47+- Choose test, lint, and typecheck commands based on package files or scripts.
48+- Offer to store these commands in termai.md if not already present.
49+
50+# Example behavior
51+user: write tests for new feature
52+assistant: [searches for existing test patterns, finds appropriate location, generates test code using existing style, optionally asks to add test command to termai.md]
53+
54+user: how do I typecheck this codebase?
55+assistant: [searches for known commands, infers package manager, checks for scripts or config files]
56+tsc --noEmit
57+
58+user: is X function used anywhere else?
59+assistant: [searches repo for references, returns file paths and lines]
60+
61+# Tool usage
62+- Use parallel calls when possible.
63+- Use file search and content tools before asking the user.
64+- Do not ask the user for information unless it cannot be determined via tools.
65+
66+Never commit changes unless the user explicitly asks you to.`
67+
68+ envInfo := getEnvironmentInfo()
69+
70+ return fmt.Sprintf("%s\n\n%s", basePrompt, envInfo)
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.
75+
76+IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
77+
78+# Memory
79+If the current working directory contains a file called termai.md, it will be automatically added to your context. This file serves multiple purposes:
80+1. Storing frequently used bash commands (build, test, lint, etc.) so you can use them without searching each time
81+2. Recording the user's code style preferences (naming conventions, preferred libraries, etc.)
82+3. Maintaining useful information about the codebase structure and organization
83+
84+When you spend time searching for commands to typecheck, lint, build, or test, you should ask the user if it's okay to add those commands to termai.md. Similarly, when learning about code style preferences or important codebase information, ask if it's okay to add that to termai.md so you can remember it for next time.
85+
86+# Tone and style
87+You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
88+Remember that your output will be displayed on a command line interface. Your responses can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
89+Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
90+If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences.
91+IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific query or task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do.
92+IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to.
93+IMPORTANT: Keep your responses short, since they will be displayed on a command line interface. You MUST answer concisely with fewer than 4 lines (not including tool use or code generation), unless user asks for detail. 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...". Here are some examples to demonstrate appropriate verbosity:
94+<example>
95+user: 2 + 2
96+assistant: 4
97+</example>
98+
99+<example>
100+user: what is 2+2?
101+assistant: 4
102+</example>
103+
104+<example>
105+user: is 11 a prime number?
106+assistant: true
107+</example>
108+
109+<example>
110+user: what command should I run to list files in the current directory?
111+assistant: ls
112+</example>
113+
114+<example>
115+user: what command should I run to watch files in the current directory?
116+assistant: [use the ls tool to list the files in the current directory, then read docs/commands in the relevant file to find out how to watch files]
117+npm run dev
118+</example>
119+
120+<example>
121+user: How many golf balls fit inside a jetta?
122+assistant: 150000
123+</example>
124+
125+<example>
126+user: what files are in the directory src/?
127+assistant: [runs ls and sees foo.c, bar.c, baz.c]
128+user: which file contains the implementation of foo?
129+assistant: src/foo.c
130+</example>
131+
132+<example>
133+user: write tests for new feature
134+assistant: [uses grep and glob search tools to find where similar tests are defined, uses concurrent read file tool use blocks in one tool call to read relevant files at the same time, uses edit file tool to write new tests]
135+</example>
136+
137+# Proactiveness
138+You are allowed to be proactive, but only when the user asks you to do something. You should strive to strike a balance between:
139+1. Doing the right thing when asked, including taking actions and follow-up actions
140+2. Not surprising the user with actions you take without asking
141+For example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions.
142+3. Do not add additional code explanation summary unless requested by the user. After working on a file, just stop, rather than providing an explanation of what you did.
143+
144+# Following conventions
145+When making changes to files, first understand the file's code conventions. Mimic code style, use existing libraries and utilities, and follow existing patterns.
146+- NEVER assume that a given library is available, even if it is well known. Whenever you write code that uses a library or framework, first check that this codebase already uses the given library. For example, you might look at neighboring files, or check the package.json (or cargo.toml, and so on depending on the language).
147+- When you create a new component, first look at existing components to see how they're written; then consider framework choice, naming conventions, typing, and other conventions.
148+- When you edit a piece of code, first look at the code's surrounding context (especially its imports) to understand the code's choice of frameworks and libraries. Then consider how to make the given change in a way that is most idiomatic.
149+- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository.
150+
151+# Code style
152+- Do not add comments to the code you write, unless the user asks you to, or the code is complex and requires additional context.
153+
154+# Doing tasks
155+The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
156+1. Use the available search tools to understand the codebase and the user's query. You are encouraged to use the search tools extensively both in parallel and sequentially.
157+2. Implement the solution using all tools available to you
158+3. Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
159+4. VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to termai.md so that you will know to run it next time.
160+
161+NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
162+
163+# Tool usage policy
164+- When doing file search, prefer to use the Agent tool in order to reduce context usage.
165+- If you intend to call multiple tools and there are no dependencies between the calls, make all of the independent calls in the same function_calls block.
166+
167+You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.`
168+
169+ envInfo := getEnvironmentInfo()
170+
171+ return fmt.Sprintf("%s\n\n%s", basePrompt, envInfo)
172+}
173+
174+func getEnvironmentInfo() string {
175+ cwd := config.WorkingDirectory()
176+ isGit := isGitRepo(cwd)
177+ platform := runtime.GOOS
178+ date := time.Now().Format("1/2/2006")
179+ ls := tools.NewLsTool()
180+ r, _ := ls.Run(context.Background(), tools.ToolCall{
181+ Input: `{"path":"."}`,
182+ })
183+ return fmt.Sprintf(`Here is useful information about the environment you are running in:
184+<env>
185+Working directory: %s
186+Is directory a git repo: %s
187+Platform: %s
188+Today's date: %s
189+</env>
190+<project>
191+%s
192+</project>
193+ `, cwd, boolToYesNo(isGit), platform, date, r.Content)
194+}
195+
196+func isGitRepo(dir string) bool {
197+ _, err := os.Stat(filepath.Join(dir, ".git"))
198+ return err == nil
199+}
200+
201+func boolToYesNo(b bool) string {
202+ if b {
203+ return "Yes"
204+ }
205+ return "No"
206+}
internal/llm/prompt/task.gonew file+16
@@ -0,0 +1,16 @@
1+package prompt
2+
3+import (
4+ "fmt"
5+)
6+
7+func TaskAgentSystemPrompt() string {
8+ agentPrompt := `You are an agent for termAI. Given the user's prompt, you should use the tools available to you to answer the user's question.
9+
10+Notes:
11+1. 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...".
12+2. When relevant, share file names and code snippets relevant to the query
13+3. Any file paths you return in your final response MUST be absolute. DO NOT use relative paths.`
14+
15+ return fmt.Sprintf("%s\n%s\n", agentPrompt, getEnvironmentInfo())
16+}
internal/llm/prompt/title.gonew file+9
@@ -0,0 +1,9 @@
1+package prompt
2+
3+func TitlePrompt() string {
4+ return `you will generate a short title based on the first message a user begins a conversation with
5+- ensure it is not more than 50 characters long
6+- the title should be a summary of the user's message
7+- do not use quotes or colons
8+- the entire text you return will be used as the title`
9+}
internal/llm/provider/anthropic.gonew file+309
@@ -0,0 +1,309 @@
1+package provider
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "strings"
8+
9+ "github.com/anthropics/anthropic-sdk-go"
10+ "github.com/anthropics/anthropic-sdk-go/option"
11+ "github.com/kujtimiihoxha/termai/internal/llm/models"
12+ "github.com/kujtimiihoxha/termai/internal/llm/tools"
13+ "github.com/kujtimiihoxha/termai/internal/message"
14+)
15+
16+type anthropicProvider struct {
17+ client anthropic.Client
18+ model models.Model
19+ maxTokens int64
20+ apiKey string
21+ systemMessage string
22+}
23+
24+type AnthropicOption func(*anthropicProvider)
25+
26+func WithAnthropicSystemMessage(message string) AnthropicOption {
27+ return func(a *anthropicProvider) {
28+ a.systemMessage = message
29+ }
30+}
31+
32+func WithAnthropicMaxTokens(maxTokens int64) AnthropicOption {
33+ return func(a *anthropicProvider) {
34+ a.maxTokens = maxTokens
35+ }
36+}
37+
38+func WithAnthropicModel(model models.Model) AnthropicOption {
39+ return func(a *anthropicProvider) {
40+ a.model = model
41+ }
42+}
43+
44+func WithAnthropicKey(apiKey string) AnthropicOption {
45+ return func(a *anthropicProvider) {
46+ a.apiKey = apiKey
47+ }
48+}
49+
50+func NewAnthropicProvider(opts ...AnthropicOption) (Provider, error) {
51+ provider := &anthropicProvider{
52+ maxTokens: 1024,
53+ }
54+
55+ for _, opt := range opts {
56+ opt(provider)
57+ }
58+
59+ if provider.systemMessage == "" {
60+ return nil, errors.New("system message is required")
61+ }
62+
63+ provider.client = anthropic.NewClient(option.WithAPIKey(provider.apiKey))
64+ return provider, nil
65+}
66+
67+func (a *anthropicProvider) SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
68+ anthropicMessages := a.convertToAnthropicMessages(messages)
69+ anthropicTools := a.convertToAnthropicTools(tools)
70+
71+ response, err := a.client.Messages.New(ctx, anthropic.MessageNewParams{
72+ Model: anthropic.Model(a.model.APIModel),
73+ MaxTokens: a.maxTokens,
74+ Temperature: anthropic.Float(0),
75+ Messages: anthropicMessages,
76+ Tools: anthropicTools,
77+ System: []anthropic.TextBlockParam{
78+ {
79+ Text: a.systemMessage,
80+ CacheControl: anthropic.CacheControlEphemeralParam{
81+ Type: "ephemeral",
82+ },
83+ },
84+ },
85+ })
86+ if err != nil {
87+ return nil, err
88+ }
89+
90+ content := ""
91+ for _, block := range response.Content {
92+ if text, ok := block.AsAny().(anthropic.TextBlock); ok {
93+ content += text.Text
94+ }
95+ }
96+
97+ toolCalls := a.extractToolCalls(response.Content)
98+ tokenUsage := a.extractTokenUsage(response.Usage)
99+
100+ return &ProviderResponse{
101+ Content: content,
102+ ToolCalls: toolCalls,
103+ Usage: tokenUsage,
104+ }, nil
105+}
106+
107+func (a *anthropicProvider) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (<-chan ProviderEvent, error) {
108+ anthropicMessages := a.convertToAnthropicMessages(messages)
109+ anthropicTools := a.convertToAnthropicTools(tools)
110+
111+ var thinkingParam anthropic.ThinkingConfigParamUnion
112+ lastMessage := messages[len(messages)-1]
113+ temperature := anthropic.Float(0)
114+ if lastMessage.Role == message.User && strings.Contains(strings.ToLower(lastMessage.Content), "think") {
115+ thinkingParam = anthropic.ThinkingConfigParamUnion{
116+ OfThinkingConfigEnabled: &anthropic.ThinkingConfigEnabledParam{
117+ BudgetTokens: int64(float64(a.maxTokens) * 0.8),
118+ Type: "enabled",
119+ },
120+ }
121+ temperature = anthropic.Float(1)
122+ }
123+
124+ stream := a.client.Messages.NewStreaming(ctx, anthropic.MessageNewParams{
125+ Model: anthropic.Model(a.model.APIModel),
126+ MaxTokens: a.maxTokens,
127+ Temperature: temperature,
128+ Messages: anthropicMessages,
129+ Tools: anthropicTools,
130+ Thinking: thinkingParam,
131+ System: []anthropic.TextBlockParam{
132+ {
133+ Text: a.systemMessage,
134+ CacheControl: anthropic.CacheControlEphemeralParam{
135+ Type: "ephemeral",
136+ },
137+ },
138+ },
139+ })
140+
141+ eventChan := make(chan ProviderEvent)
142+
143+ go func() {
144+ defer close(eventChan)
145+
146+ accumulatedMessage := anthropic.Message{}
147+
148+ for stream.Next() {
149+ event := stream.Current()
150+ err := accumulatedMessage.Accumulate(event)
151+ if err != nil {
152+ eventChan <- ProviderEvent{Type: EventError, Error: err}
153+ return
154+ }
155+
156+ switch event := event.AsAny().(type) {
157+ case anthropic.ContentBlockStartEvent:
158+ eventChan <- ProviderEvent{Type: EventContentStart}
159+
160+ case anthropic.ContentBlockDeltaEvent:
161+ if event.Delta.Type == "thinking_delta" && event.Delta.Thinking != "" {
162+ eventChan <- ProviderEvent{
163+ Type: EventThinkingDelta,
164+ Thinking: event.Delta.Thinking,
165+ }
166+ } else if event.Delta.Type == "text_delta" && event.Delta.Text != "" {
167+ eventChan <- ProviderEvent{
168+ Type: EventContentDelta,
169+ Content: event.Delta.Text,
170+ }
171+ }
172+
173+ case anthropic.ContentBlockStopEvent:
174+ eventChan <- ProviderEvent{Type: EventContentStop}
175+
176+ case anthropic.MessageStopEvent:
177+ content := ""
178+ for _, block := range accumulatedMessage.Content {
179+ if text, ok := block.AsAny().(anthropic.TextBlock); ok {
180+ content += text.Text
181+ }
182+ }
183+
184+ toolCalls := a.extractToolCalls(accumulatedMessage.Content)
185+ tokenUsage := a.extractTokenUsage(accumulatedMessage.Usage)
186+
187+ eventChan <- ProviderEvent{
188+ Type: EventComplete,
189+ Response: &ProviderResponse{
190+ Content: content,
191+ ToolCalls: toolCalls,
192+ Usage: tokenUsage,
193+ },
194+ }
195+ }
196+ }
197+
198+ if stream.Err() != nil {
199+ eventChan <- ProviderEvent{Type: EventError, Error: stream.Err()}
200+ }
201+ }()
202+
203+ return eventChan, nil
204+}
205+
206+func (a *anthropicProvider) extractToolCalls(content []anthropic.ContentBlockUnion) []message.ToolCall {
207+ var toolCalls []message.ToolCall
208+
209+ for _, block := range content {
210+ switch variant := block.AsAny().(type) {
211+ case anthropic.ToolUseBlock:
212+ toolCall := message.ToolCall{
213+ ID: variant.ID,
214+ Name: variant.Name,
215+ Input: string(variant.Input),
216+ Type: string(variant.Type),
217+ }
218+ toolCalls = append(toolCalls, toolCall)
219+ }
220+ }
221+
222+ return toolCalls
223+}
224+
225+func (a *anthropicProvider) extractTokenUsage(usage anthropic.Usage) TokenUsage {
226+ return TokenUsage{
227+ InputTokens: usage.InputTokens,
228+ OutputTokens: usage.OutputTokens,
229+ CacheCreationTokens: usage.CacheCreationInputTokens,
230+ CacheReadTokens: usage.CacheReadInputTokens,
231+ }
232+}
233+
234+func (a *anthropicProvider) convertToAnthropicTools(tools []tools.BaseTool) []anthropic.ToolUnionParam {
235+ anthropicTools := make([]anthropic.ToolUnionParam, len(tools))
236+
237+ for i, tool := range tools {
238+ info := tool.Info()
239+ toolParam := anthropic.ToolParam{
240+ Name: info.Name,
241+ Description: anthropic.String(info.Description),
242+ InputSchema: anthropic.ToolInputSchemaParam{
243+ Properties: info.Parameters,
244+ },
245+ }
246+
247+ if i == len(tools)-1 {
248+ toolParam.CacheControl = anthropic.CacheControlEphemeralParam{
249+ Type: "ephemeral",
250+ }
251+ }
252+
253+ anthropicTools[i] = anthropic.ToolUnionParam{OfTool: &toolParam}
254+ }
255+
256+ return anthropicTools
257+}
258+
259+func (a *anthropicProvider) convertToAnthropicMessages(messages []message.Message) []anthropic.MessageParam {
260+ anthropicMessages := make([]anthropic.MessageParam, len(messages))
261+ cachedBlocks := 0
262+
263+ for i, msg := range messages {
264+ switch msg.Role {
265+ case message.User:
266+ content := anthropic.NewTextBlock(msg.Content)
267+ if cachedBlocks < 2 {
268+ content.OfRequestTextBlock.CacheControl = anthropic.CacheControlEphemeralParam{
269+ Type: "ephemeral",
270+ }
271+ cachedBlocks++
272+ }
273+ anthropicMessages[i] = anthropic.NewUserMessage(content)
274+
275+ case message.Assistant:
276+ blocks := []anthropic.ContentBlockParamUnion{}
277+ if msg.Content != "" {
278+ content := anthropic.NewTextBlock(msg.Content)
279+ if cachedBlocks < 2 {
280+ content.OfRequestTextBlock.CacheControl = anthropic.CacheControlEphemeralParam{
281+ Type: "ephemeral",
282+ }
283+ cachedBlocks++
284+ }
285+ blocks = append(blocks, content)
286+ }
287+
288+ for _, toolCall := range msg.ToolCalls {
289+ var inputMap map[string]any
290+ err := json.Unmarshal([]byte(toolCall.Input), &inputMap)
291+ if err != nil {
292+ continue
293+ }
294+ blocks = append(blocks, anthropic.ContentBlockParamOfRequestToolUseBlock(toolCall.ID, inputMap, toolCall.Name))
295+ }
296+
297+ anthropicMessages[i] = anthropic.NewAssistantMessage(blocks...)
298+
299+ case message.Tool:
300+ results := make([]anthropic.ContentBlockParamUnion, len(msg.ToolResults))
301+ for i, toolResult := range msg.ToolResults {
302+ results[i] = anthropic.NewToolResultBlock(toolResult.ToolCallID, toolResult.Content, toolResult.IsError)
303+ }
304+ anthropicMessages[i] = anthropic.NewUserMessage(results...)
305+ }
306+ }
307+
308+ return anthropicMessages
309+}
internal/llm/provider/gemini.gonew file+443
@@ -0,0 +1,443 @@
1+package provider
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "log"
8+
9+ "github.com/google/generative-ai-go/genai"
10+ "github.com/google/uuid"
11+ "github.com/kujtimiihoxha/termai/internal/llm/models"
12+ "github.com/kujtimiihoxha/termai/internal/llm/tools"
13+ "github.com/kujtimiihoxha/termai/internal/message"
14+ "google.golang.org/api/googleapi"
15+ "google.golang.org/api/iterator"
16+ "google.golang.org/api/option"
17+)
18+
19+type geminiProvider struct {
20+ client *genai.Client
21+ model models.Model
22+ maxTokens int32
23+ apiKey string
24+ systemMessage string
25+}
26+
27+type GeminiOption func(*geminiProvider)
28+
29+func NewGeminiProvider(ctx context.Context, opts ...GeminiOption) (Provider, error) {
30+ provider := &geminiProvider{
31+ maxTokens: 5000,
32+ }
33+
34+ for _, opt := range opts {
35+ opt(provider)
36+ }
37+
38+ if provider.systemMessage == "" {
39+ return nil, errors.New("system message is required")
40+ }
41+
42+ client, err := genai.NewClient(ctx, option.WithAPIKey(provider.apiKey))
43+ if err != nil {
44+ return nil, err
45+ }
46+ provider.client = client
47+
48+ return provider, nil
49+}
50+
51+func WithGeminiSystemMessage(message string) GeminiOption {
52+ return func(p *geminiProvider) {
53+ p.systemMessage = message
54+ }
55+}
56+
57+func WithGeminiMaxTokens(maxTokens int32) GeminiOption {
58+ return func(p *geminiProvider) {
59+ p.maxTokens = maxTokens
60+ }
61+}
62+
63+func WithGeminiModel(model models.Model) GeminiOption {
64+ return func(p *geminiProvider) {
65+ p.model = model
66+ }
67+}
68+
69+func WithGeminiKey(apiKey string) GeminiOption {
70+ return func(p *geminiProvider) {
71+ p.apiKey = apiKey
72+ }
73+}
74+
75+func (p *geminiProvider) Close() {
76+ if p.client != nil {
77+ p.client.Close()
78+ }
79+}
80+
81+// convertToGeminiHistory converts the message history to Gemini's format
82+func (p *geminiProvider) convertToGeminiHistory(messages []message.Message) []*genai.Content {
83+ var history []*genai.Content
84+
85+ for _, msg := range messages {
86+ switch msg.Role {
87+ case message.User:
88+ history = append(history, &genai.Content{
89+ Parts: []genai.Part{genai.Text(msg.Content)},
90+ Role: "user",
91+ })
92+ case message.Assistant:
93+ content := &genai.Content{
94+ Role: "model",
95+ Parts: []genai.Part{},
96+ }
97+
98+ // Handle regular content
99+ if msg.Content != "" {
100+ content.Parts = append(content.Parts, genai.Text(msg.Content))
101+ }
102+
103+ // Handle tool calls if any
104+ if len(msg.ToolCalls) > 0 {
105+ for _, call := range msg.ToolCalls {
106+ args, _ := parseJsonToMap(call.Input)
107+ content.Parts = append(content.Parts, genai.FunctionCall{
108+ Name: call.Name,
109+ Args: args,
110+ })
111+ }
112+ }
113+
114+ history = append(history, content)
115+ case message.Tool:
116+ for _, result := range msg.ToolResults {
117+ // Parse response content to map if possible
118+ response := map[string]interface{}{"result": result.Content}
119+ parsed, err := parseJsonToMap(result.Content)
120+ if err == nil {
121+ response = parsed
122+ }
123+ var toolCall message.ToolCall
124+ for _, msg := range messages {
125+ if msg.Role == message.Assistant {
126+ for _, call := range msg.ToolCalls {
127+ if call.ID == result.ToolCallID {
128+ toolCall = call
129+ break
130+ }
131+ }
132+ }
133+ }
134+
135+ history = append(history, &genai.Content{
136+ Parts: []genai.Part{genai.FunctionResponse{
137+ Name: toolCall.Name,
138+ Response: response,
139+ }},
140+ Role: "function",
141+ })
142+ }
143+ }
144+ }
145+
146+ return history
147+}
148+
149+// convertToolsToGeminiFunctionDeclarations converts tool definitions to Gemini's function declarations
150+func (p *geminiProvider) convertToolsToGeminiFunctionDeclarations(tools []tools.BaseTool) []*genai.FunctionDeclaration {
151+ declarations := make([]*genai.FunctionDeclaration, len(tools))
152+
153+ for i, tool := range tools {
154+ info := tool.Info()
155+
156+ // Convert parameters to genai.Schema format
157+ properties := make(map[string]*genai.Schema)
158+ for name, param := range info.Parameters {
159+ // Try to extract type and description from the parameter
160+ paramMap, ok := param.(map[string]interface{})
161+ if !ok {
162+ // Default to string if unable to determine type
163+ properties[name] = &genai.Schema{Type: genai.TypeString}
164+ continue
165+ }
166+
167+ schemaType := genai.TypeString // Default
168+ var description string
169+ var itemsTypeSchema *genai.Schema
170+ if typeVal, found := paramMap["type"]; found {
171+ if typeStr, ok := typeVal.(string); ok {
172+ switch typeStr {
173+ case "string":
174+ schemaType = genai.TypeString
175+ case "number":
176+ schemaType = genai.TypeNumber
177+ case "integer":
178+ schemaType = genai.TypeInteger
179+ case "boolean":
180+ schemaType = genai.TypeBoolean
181+ case "array":
182+ schemaType = genai.TypeArray
183+ items, found := paramMap["items"]
184+ if found {
185+ itemsMap, ok := items.(map[string]interface{})
186+ if ok {
187+ itemsType, found := itemsMap["type"]
188+ if found {
189+ itemsTypeStr, ok := itemsType.(string)
190+ if ok {
191+ switch itemsTypeStr {
192+ case "string":
193+ itemsTypeSchema = &genai.Schema{
194+ Type: genai.TypeString,
195+ }
196+ case "number":
197+ itemsTypeSchema = &genai.Schema{
198+ Type: genai.TypeNumber,
199+ }
200+ case "integer":
201+ itemsTypeSchema = &genai.Schema{
202+ Type: genai.TypeInteger,
203+ }
204+ case "boolean":
205+ itemsTypeSchema = &genai.Schema{
206+ Type: genai.TypeBoolean,
207+ }
208+ }
209+ }
210+ }
211+ }
212+ }
213+ case "object":
214+ schemaType = genai.TypeObject
215+ if _, found := paramMap["properties"]; !found {
216+ continue
217+ }
218+ // TODO: Add support for other types
219+ }
220+ }
221+ }
222+
223+ if desc, found := paramMap["description"]; found {
224+ if descStr, ok := desc.(string); ok {
225+ description = descStr
226+ }
227+ }
228+
229+ properties[name] = &genai.Schema{
230+ Type: schemaType,
231+ Description: description,
232+ Items: itemsTypeSchema,
233+ }
234+ }
235+
236+ declarations[i] = &genai.FunctionDeclaration{
237+ Name: info.Name,
238+ Description: info.Description,
239+ Parameters: &genai.Schema{
240+ Type: genai.TypeObject,
241+ Properties: properties,
242+ Required: info.Required,
243+ },
244+ }
245+ }
246+
247+ return declarations
248+}
249+
250+// extractTokenUsage extracts token usage information from Gemini's response
251+func (p *geminiProvider) extractTokenUsage(resp *genai.GenerateContentResponse) TokenUsage {
252+ if resp == nil || resp.UsageMetadata == nil {
253+ return TokenUsage{}
254+ }
255+
256+ return TokenUsage{
257+ InputTokens: int64(resp.UsageMetadata.PromptTokenCount),
258+ OutputTokens: int64(resp.UsageMetadata.CandidatesTokenCount),
259+ CacheCreationTokens: 0, // Not directly provided by Gemini
260+ CacheReadTokens: int64(resp.UsageMetadata.CachedContentTokenCount),
261+ }
262+}
263+
264+// SendMessages sends a batch of messages to Gemini and returns the response
265+func (p *geminiProvider) SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
266+ // Create a generative model
267+ model := p.client.GenerativeModel(p.model.APIModel)
268+ model.SetMaxOutputTokens(p.maxTokens)
269+
270+ // Set system instruction
271+ model.SystemInstruction = genai.NewUserContent(genai.Text(p.systemMessage))
272+
273+ // Set up tools if provided
274+ if len(tools) > 0 {
275+ declarations := p.convertToolsToGeminiFunctionDeclarations(tools)
276+ model.Tools = []*genai.Tool{{FunctionDeclarations: declarations}}
277+ }
278+
279+ // Create chat session and set history
280+ chat := model.StartChat()
281+ chat.History = p.convertToGeminiHistory(messages[:len(messages)-1]) // Exclude last message
282+
283+ // Get the most recent user message
284+ var lastUserMsg message.Message
285+ for i := len(messages) - 1; i >= 0; i-- {
286+ if messages[i].Role == message.User {
287+ lastUserMsg = messages[i]
288+ break
289+ }
290+ }
291+
292+ // Send the message
293+ resp, err := chat.SendMessage(ctx, genai.Text(lastUserMsg.Content))
294+ if err != nil {
295+ return nil, err
296+ }
297+
298+ // Process the response
299+ var content string
300+ var toolCalls []message.ToolCall
301+
302+ if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
303+ for _, part := range resp.Candidates[0].Content.Parts {
304+ switch p := part.(type) {
305+ case genai.Text:
306+ content = string(p)
307+ case genai.FunctionCall:
308+ id := "call_" + uuid.New().String()
309+ args, _ := json.Marshal(p.Args)
310+ toolCalls = append(toolCalls, message.ToolCall{
311+ ID: id,
312+ Name: p.Name,
313+ Input: string(args),
314+ Type: "function",
315+ })
316+ }
317+ }
318+ }
319+
320+ // Extract token usage
321+ tokenUsage := p.extractTokenUsage(resp)
322+
323+ return &ProviderResponse{
324+ Content: content,
325+ ToolCalls: toolCalls,
326+ Usage: tokenUsage,
327+ }, nil
328+}
329+
330+// StreamResponse streams the response from Gemini
331+func (p *geminiProvider) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (<-chan ProviderEvent, error) {
332+ // Create a generative model
333+ model := p.client.GenerativeModel(p.model.APIModel)
334+ model.SetMaxOutputTokens(p.maxTokens)
335+
336+ // Set system instruction
337+ model.SystemInstruction = genai.NewUserContent(genai.Text(p.systemMessage))
338+
339+ // Set up tools if provided
340+ if len(tools) > 0 {
341+ declarations := p.convertToolsToGeminiFunctionDeclarations(tools)
342+ for _, declaration := range declarations {
343+ model.Tools = append(model.Tools, &genai.Tool{FunctionDeclarations: []*genai.FunctionDeclaration{declaration}})
344+ }
345+ }
346+
347+ // Create chat session and set history
348+ chat := model.StartChat()
349+ chat.History = p.convertToGeminiHistory(messages[:len(messages)-1]) // Exclude last message
350+
351+ lastUserMsg := messages[len(messages)-1]
352+
353+ // Start streaming
354+ iter := chat.SendMessageStream(ctx, genai.Text(lastUserMsg.Content))
355+
356+ eventChan := make(chan ProviderEvent)
357+
358+ go func() {
359+ defer close(eventChan)
360+
361+ var finalResp *genai.GenerateContentResponse
362+ currentContent := ""
363+ toolCalls := []message.ToolCall{}
364+
365+ for {
366+ resp, err := iter.Next()
367+ if err == iterator.Done {
368+ break
369+ }
370+ if err != nil {
371+ var apiErr *googleapi.Error
372+ if errors.As(err, &apiErr) {
373+ log.Printf("%s", apiErr.Body)
374+ }
375+ eventChan <- ProviderEvent{
376+ Type: EventError,
377+ Error: err,
378+ }
379+ return
380+ }
381+
382+ finalResp = resp
383+
384+ if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
385+ for _, part := range resp.Candidates[0].Content.Parts {
386+ switch p := part.(type) {
387+ case genai.Text:
388+ newText := string(p)
389+ eventChan <- ProviderEvent{
390+ Type: EventContentDelta,
391+ Content: newText,
392+ }
393+ currentContent += newText
394+ case genai.FunctionCall:
395+ // For function calls, we assume they come complete, not streamed in parts
396+ id := "call_" + uuid.New().String()
397+ args, _ := json.Marshal(p.Args)
398+ newCall := message.ToolCall{
399+ ID: id,
400+ Name: p.Name,
401+ Input: string(args),
402+ Type: "function",
403+ }
404+
405+ // Check if this is a new tool call
406+ isNew := true
407+ for _, existing := range toolCalls {
408+ if existing.Name == newCall.Name && existing.Input == newCall.Input {
409+ isNew = false
410+ break
411+ }
412+ }
413+
414+ if isNew {
415+ toolCalls = append(toolCalls, newCall)
416+ }
417+ }
418+ }
419+ }
420+ }
421+
422+ // Extract token usage from the final response
423+ tokenUsage := p.extractTokenUsage(finalResp)
424+
425+ eventChan <- ProviderEvent{
426+ Type: EventComplete,
427+ Response: &ProviderResponse{
428+ Content: currentContent,
429+ ToolCalls: toolCalls,
430+ Usage: tokenUsage,
431+ },
432+ }
433+ }()
434+
435+ return eventChan, nil
436+}
437+
438+// Helper function to parse JSON string into map
439+func parseJsonToMap(jsonStr string) (map[string]interface{}, error) {
440+ var result map[string]interface{}
441+ err := json.Unmarshal([]byte(jsonStr), &result)
442+ return result, err
443+}
internal/llm/provider/openai.gonew file+278
@@ -0,0 +1,278 @@
1+package provider
2+
3+import (
4+ "context"
5+ "errors"
6+
7+ "github.com/kujtimiihoxha/termai/internal/llm/models"
8+ "github.com/kujtimiihoxha/termai/internal/llm/tools"
9+ "github.com/kujtimiihoxha/termai/internal/message"
10+ "github.com/openai/openai-go"
11+ "github.com/openai/openai-go/option"
12+)
13+
14+type openaiProvider struct {
15+ client openai.Client
16+ model models.Model
17+ maxTokens int64
18+ baseURL string
19+ apiKey string
20+ systemMessage string
21+}
22+
23+type OpenAIOption func(*openaiProvider)
24+
25+func NewOpenAIProvider(opts ...OpenAIOption) (Provider, error) {
26+ provider := &openaiProvider{
27+ maxTokens: 5000,
28+ }
29+
30+ for _, opt := range opts {
31+ opt(provider)
32+ }
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
47+}
48+
49+func WithOpenAISystemMessage(message string) OpenAIOption {
50+ return func(p *openaiProvider) {
51+ p.systemMessage = message
52+ }
53+}
54+
55+func WithOpenAIMaxTokens(maxTokens int64) OpenAIOption {
56+ return func(p *openaiProvider) {
57+ p.maxTokens = maxTokens
58+ }
59+}
60+
61+func WithOpenAIModel(model models.Model) OpenAIOption {
62+ return func(p *openaiProvider) {
63+ p.model = model
64+ }
65+}
66+
67+func WithOpenAIBaseURL(baseURL string) OpenAIOption {
68+ return func(p *openaiProvider) {
69+ p.baseURL = baseURL
70+ }
71+}
72+
73+func WithOpenAIKey(apiKey string) OpenAIOption {
74+ return func(p *openaiProvider) {
75+ p.apiKey = apiKey
76+ }
77+}
78+
79+func (p *openaiProvider) convertToOpenAIMessages(messages []message.Message) []openai.ChatCompletionMessageParamUnion {
80+ var chatMessages []openai.ChatCompletionMessageParamUnion
81+
82+ chatMessages = append(chatMessages, openai.SystemMessage(p.systemMessage))
83+
84+ for _, msg := range messages {
85+ switch msg.Role {
86+ case message.User:
87+ chatMessages = append(chatMessages, openai.UserMessage(msg.Content))
88+
89+ case message.Assistant:
90+ assistantMsg := openai.ChatCompletionAssistantMessageParam{
91+ Role: "assistant",
92+ }
93+
94+ if msg.Content != "" {
95+ assistantMsg.Content = openai.ChatCompletionAssistantMessageParamContentUnion{
96+ OfString: openai.String(msg.Content),
97+ }
98+ }
99+
100+ if len(msg.ToolCalls) > 0 {
101+ assistantMsg.ToolCalls = make([]openai.ChatCompletionMessageToolCallParam, len(msg.ToolCalls))
102+ for i, call := range msg.ToolCalls {
103+ assistantMsg.ToolCalls[i] = openai.ChatCompletionMessageToolCallParam{
104+ ID: call.ID,
105+ Type: "function",
106+ Function: openai.ChatCompletionMessageToolCallFunctionParam{
107+ Name: call.Name,
108+ Arguments: call.Input,
109+ },
110+ }
111+ }
112+ }
113+
114+ chatMessages = append(chatMessages, openai.ChatCompletionMessageParamUnion{
115+ OfAssistant: &assistantMsg,
116+ })
117+
118+ case message.Tool:
119+ for _, result := range msg.ToolResults {
120+ chatMessages = append(chatMessages,
121+ openai.ToolMessage(result.Content, result.ToolCallID),
122+ )
123+ }
124+ }
125+ }
126+
127+ return chatMessages
128+}
129+
130+func (p *openaiProvider) convertToOpenAITools(tools []tools.BaseTool) []openai.ChatCompletionToolParam {
131+ openaiTools := make([]openai.ChatCompletionToolParam, len(tools))
132+
133+ for i, tool := range tools {
134+ info := tool.Info()
135+ openaiTools[i] = openai.ChatCompletionToolParam{
136+ Function: openai.FunctionDefinitionParam{
137+ Name: info.Name,
138+ Description: openai.String(info.Description),
139+ Parameters: openai.FunctionParameters{
140+ "type": "object",
141+ "properties": info.Parameters,
142+ "required": info.Required,
143+ },
144+ },
145+ }
146+ }
147+
148+ return openaiTools
149+}
150+
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,
162+ }
163+}
164+
165+func (p *openaiProvider) SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
166+ chatMessages := p.convertToOpenAIMessages(messages)
167+ openaiTools := p.convertToOpenAITools(tools)
168+
169+ params := openai.ChatCompletionNewParams{
170+ Model: openai.ChatModel(p.model.APIModel),
171+ Messages: chatMessages,
172+ MaxTokens: openai.Int(p.maxTokens),
173+ Tools: openaiTools,
174+ }
175+
176+ response, err := p.client.Chat.Completions.New(ctx, params)
177+ if err != nil {
178+ return nil, err
179+ }
180+
181+ content := ""
182+ if response.Choices[0].Message.Content != "" {
183+ content = response.Choices[0].Message.Content
184+ }
185+
186+ var toolCalls []message.ToolCall
187+ if len(response.Choices[0].Message.ToolCalls) > 0 {
188+ toolCalls = make([]message.ToolCall, len(response.Choices[0].Message.ToolCalls))
189+ for i, call := range response.Choices[0].Message.ToolCalls {
190+ toolCalls[i] = message.ToolCall{
191+ ID: call.ID,
192+ Name: call.Function.Name,
193+ Input: call.Function.Arguments,
194+ Type: "function",
195+ }
196+ }
197+ }
198+
199+ tokenUsage := p.extractTokenUsage(response.Usage)
200+
201+ return &ProviderResponse{
202+ Content: content,
203+ ToolCalls: toolCalls,
204+ Usage: tokenUsage,
205+ }, nil
206+}
207+
208+func (p *openaiProvider) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (<-chan ProviderEvent, error) {
209+ chatMessages := p.convertToOpenAIMessages(messages)
210+ openaiTools := p.convertToOpenAITools(tools)
211+
212+ params := openai.ChatCompletionNewParams{
213+ Model: openai.ChatModel(p.model.APIModel),
214+ Messages: chatMessages,
215+ MaxTokens: openai.Int(p.maxTokens),
216+ Tools: openaiTools,
217+ StreamOptions: openai.ChatCompletionStreamOptionsParam{
218+ IncludeUsage: openai.Bool(true),
219+ },
220+ }
221+
222+ stream := p.client.Chat.Completions.NewStreaming(ctx, params)
223+
224+ eventChan := make(chan ProviderEvent)
225+
226+ toolCalls := make([]message.ToolCall, 0)
227+ go func() {
228+ defer close(eventChan)
229+
230+ acc := openai.ChatCompletionAccumulator{}
231+ currentContent := ""
232+
233+ for stream.Next() {
234+ chunk := stream.Current()
235+ acc.AddChunk(chunk)
236+
237+ if tool, ok := acc.JustFinishedToolCall(); ok {
238+ toolCalls = append(toolCalls, message.ToolCall{
239+ ID: tool.Id,
240+ Name: tool.Name,
241+ Input: tool.Arguments,
242+ Type: "function",
243+ })
244+ }
245+
246+ for _, choice := range chunk.Choices {
247+ if choice.Delta.Content != "" {
248+ eventChan <- ProviderEvent{
249+ Type: EventContentDelta,
250+ Content: choice.Delta.Content,
251+ }
252+ currentContent += choice.Delta.Content
253+ }
254+ }
255+ }
256+
257+ if err := stream.Err(); err != nil {
258+ eventChan <- ProviderEvent{
259+ Type: EventError,
260+ Error: err,
261+ }
262+ return
263+ }
264+
265+ tokenUsage := p.extractTokenUsage(acc.Usage)
266+
267+ eventChan <- ProviderEvent{
268+ Type: EventComplete,
269+ Response: &ProviderResponse{
270+ Content: currentContent,
271+ ToolCalls: toolCalls,
272+ Usage: tokenUsage,
273+ },
274+ }
275+ }()
276+
277+ return eventChan, nil
278+}
internal/llm/provider/provider.gonew file+48
@@ -0,0 +1,48 @@
1+package provider
2+
3+import (
4+ "context"
5+
6+ "github.com/kujtimiihoxha/termai/internal/llm/tools"
7+ "github.com/kujtimiihoxha/termai/internal/message"
8+)
9+
10+// EventType represents the type of streaming event
11+type EventType string
12+
13+const (
14+ EventContentStart EventType = "content_start"
15+ EventContentDelta EventType = "content_delta"
16+ EventThinkingDelta EventType = "thinking_delta"
17+ EventContentStop EventType = "content_stop"
18+ EventComplete EventType = "complete"
19+ EventError EventType = "error"
20+)
21+
22+type TokenUsage struct {
23+ InputTokens int64
24+ OutputTokens int64
25+ CacheCreationTokens int64
26+ CacheReadTokens int64
27+}
28+
29+type ProviderResponse struct {
30+ Content string
31+ ToolCalls []message.ToolCall
32+ Usage TokenUsage
33+}
34+
35+type ProviderEvent struct {
36+ Type EventType
37+ Content string
38+ Thinking string
39+ ToolCall *message.ToolCall
40+ Error error
41+ Response *ProviderResponse
42+}
43+
44+type Provider interface {
45+ SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error)
46+
47+ StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (<-chan ProviderEvent, error)
48+}
internal/llm/tools/agent.godeleted−141
@@ -1,141 +0,0 @@
1−package tools
2−
3−import (
4− "context"
5− "encoding/json"
6− "fmt"
7− "os"
8− "runtime"
9− "time"
10−
11− "github.com/cloudwego/eino/components/tool"
12− "github.com/cloudwego/eino/schema"
13−
14− "github.com/cloudwego/eino/compose"
15− "github.com/cloudwego/eino/flow/agent/react"
16− "github.com/kujtimiihoxha/termai/internal/llm/models"
17− "github.com/spf13/viper"
18−)
19−
20−type agentTool struct {
21− workingDir string
22−}
23−
24−const (
25− AgentToolName = "agent"
26−)
27−
28−type AgentParams struct {
29− Prompt string `json:"prompt"`
30−}
31−
32−func taskAgentTools() []tool.BaseTool {
33− wd := viper.GetString("wd")
34− return []tool.BaseTool{
35− NewBashTool(wd),
36− NewLsTool(wd),
37− NewGlobTool(wd),
38− NewViewTool(wd),
39− NewWriteTool(wd),
40− NewEditTool(wd),
41− }
42−}
43−
44−func NewTaskAgent(ctx context.Context) (*react.Agent, error) {
45− model, err := models.GetModel(ctx, models.ModelID(viper.GetString("models.big")))
46− if err != nil {
47− return nil, err
48− }
49− reactAgent, err := react.NewAgent(ctx, &react.AgentConfig{
50− Model: model,
51− ToolsConfig: compose.ToolsNodeConfig{
52− Tools: taskAgentTools(),
53− },
54− MaxStep: 1000,
55− })
56− if err != nil {
57− return nil, err
58− }
59−
60− return reactAgent, nil
61−}
62−
63−func TaskAgentSystemPrompt() string {
64− agentPrompt := `You are an agent for Orbitowl. Given the user's prompt, you should use the tools available to you to answer the user's question.
65−
66−Notes:
67−1. 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...".
68−2. When relevant, share file names and code snippets relevant to the query
69−3. Any file paths you return in your final response MUST be absolute. DO NOT use relative paths.
70−
71−Here is useful information about the environment you are running in:
72−<env>
73−Working directory: %s
74−Platform: %s
75−Today's date: %s
76−</env>`
77−
78− cwd, err := os.Getwd()
79− if err != nil {
80− cwd = "unknown"
81− }
82−
83− platform := runtime.GOOS
84−
85− switch platform {
86− case "darwin":
87− platform = "macos"
88− case "windows":
89− platform = "windows"
90− case "linux":
91− platform = "linux"
92− }
93− return fmt.Sprintf(agentPrompt, cwd, platform, time.Now().Format("1/2/2006"))
94−}
95−
96−func (b *agentTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
97− return &schema.ToolInfo{
98− Name: AgentToolName,
99− Desc: "Launch a new agent that has access to the following tools: GlobTool, GrepTool, LS, View, ReadNotebook. When you are searching for a keyword or file and are not confident that you will find the right match on the first try, use the Agent tool to perform the search for you. For example:\n\n- If you are searching for a keyword like \"config\" or \"logger\", or for questions like \"which file does X?\", the Agent tool is strongly recommended\n- If you want to read a specific file path, use the View or GlobTool tool instead of the Agent tool, to find the match more quickly\n- If you are searching for a specific class definition like \"class Foo\", use the GlobTool tool instead, to find the match more quickly\n\nUsage notes:\n1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses\n2. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.\n3. Each agent invocation is stateless. You will not be able to send additional messages to the agent, nor will the agent be able to communicate with you outside of its final report. Therefore, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you.\n4. The agent's outputs should generally be trusted\n5. IMPORTANT: The agent can not use Bash, Replace, Edit, NotebookEditCell, so can not modify files. If you want to use these tools, use them directly instead of going through the agent.",
100− ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
101− "prompt": {
102− Type: "string",
103− Desc: "The task for the agent to perform",
104− Required: true,
105− },
106− }),
107− }, nil
108−}
109−
110−func (b *agentTool) InvokableRun(ctx context.Context, args string, opts ...tool.Option) (string, error) {
111− var params AgentParams
112− if err := json.Unmarshal([]byte(args), &params); err != nil {
113− return "", err
114− }
115− if params.Prompt == "" {
116− return "prompt is required", nil
117− }
118−
119− a, err := NewTaskAgent(ctx)
120− if err != nil {
121− return "", err
122− }
123− out, err := a.Generate(
124− ctx,
125− []*schema.Message{
126− schema.SystemMessage(TaskAgentSystemPrompt()),
127− schema.UserMessage(params.Prompt),
128− },
129− )
130− if err != nil {
131− return "", err
132− }
133−
134− return out.Content, nil
135−}
136−
137−func NewAgentTool(wd string) tool.InvokableTool {
138− return &agentTool{
139− workingDir: wd,
140− }
141−}
internal/llm/tools/bash.go+36−54
@@ -6,20 +6,17 @@ import (
6 "fmt"
6 "fmt"
7 "strings"
7 "strings"
8
8
9− "github.com/cloudwego/eino/components/tool"
9+ "github.com/kujtimiihoxha/termai/internal/config"
10− "github.com/cloudwego/eino/schema"
11 "github.com/kujtimiihoxha/termai/internal/llm/tools/shell"
10 "github.com/kujtimiihoxha/termai/internal/llm/tools/shell"
12 "github.com/kujtimiihoxha/termai/internal/permission"
11 "github.com/kujtimiihoxha/termai/internal/permission"
13)
12)
14
13
15−type bashTool struct {
14+type bashTool struct{}
16− workingDir string
17−}
18
15
19const (
16const (
20 BashToolName = "bash"
17 BashToolName = "bash"
21
18
22− DefaultTimeout = 30 * 60 * 1000 // 30 minutes in milliseconds
19+ DefaultTimeout = 1 * 60 * 1000 // 1 minutes in milliseconds
23 MaxTimeout = 10 * 60 * 1000 // 10 minutes in milliseconds
20 MaxTimeout = 10 * 60 * 1000 // 10 minutes in milliseconds
24 MaxOutputLength = 30000
21 MaxOutputLength = 30000
25)
22)
@@ -29,6 +26,11 @@ type BashParams struct {
29 Timeout int `json:"timeout"`
26 Timeout int `json:"timeout"`
30}
27}
31
28
29+type BashPermissionsParams struct {
30+ Command string `json:"command"`
31+ Timeout int `json:"timeout"`
32+}
33+
32var BannedCommands = []string{
34var BannedCommands = []string{
33 "alias", "curl", "curlie", "wget", "axel", "aria2c",
35 "alias", "curl", "curlie", "wget", "axel", "aria2c",
34 "nc", "telnet", "lynx", "w3m", "links", "httpie", "xh",
36 "nc", "telnet", "lynx", "w3m", "links", "httpie", "xh",
@@ -40,29 +42,29 @@ var SafeReadOnlyCommands = []string{
40 "whatis", //...
42 "whatis", //...
41}
43}
42
44
43−func (b *bashTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
45+func (b *bashTool) Info() ToolInfo {
44− return &schema.ToolInfo{
46+ return ToolInfo{
45− Name: BashToolName,
47+ Name: BashToolName,
46− Desc: bashDescription(),
48+ Description: bashDescription(),
47− ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
49+ Parameters: map[string]any{
48− "command": {
50+ "command": map[string]any{
49− Type: "string",
51+ "type": "string",
50− Desc: "The command to execute",
52+ "description": "The command to execute",
51− Required: true,
52 },
53 },
53− "timeout": {
54+ "timeout": map[string]any{
54− Type: "number",
55+ "type": "number",
55− Desc: "Optional timeout in milliseconds (max 600000)",
56+ "desription": "Optional timeout in milliseconds (max 600000)",
56 },
57 },
57− }),
58+ },
58− }, nil
59+ Required: []string{"command"},
60+ }
59}
61}
60
62
61// Handle implements Tool.
63// Handle implements Tool.
62−func (b *bashTool) InvokableRun(ctx context.Context, args string, opts ...tool.Option) (string, error) {
64+func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
63 var params BashParams
65 var params BashParams
64− if err := json.Unmarshal([]byte(args), &params); err != nil {
66+ if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
65− return "", err
67+ return NewTextErrorResponse("invalid parameters"), nil
66 }
68 }
67
69
68 if params.Timeout > MaxTimeout {
70 if params.Timeout > MaxTimeout {
@@ -72,13 +74,13 @@ func (b *bashTool) InvokableRun(ctx context.Context, args string, opts ...tool.O
72 }
74 }
73
75
74 if params.Command == "" {
76 if params.Command == "" {
75− return "missing command", nil
77+ return NewTextErrorResponse("missing command"), nil
76 }
78 }
77
79
78 baseCmd := strings.Fields(params.Command)[0]
80 baseCmd := strings.Fields(params.Command)[0]
79 for _, banned := range BannedCommands {
81 for _, banned := range BannedCommands {
80 if strings.EqualFold(baseCmd, banned) {
82 if strings.EqualFold(baseCmd, banned) {
81− return fmt.Sprintf("command '%s' is not allowed", baseCmd), nil
83+ return NewTextErrorResponse(fmt.Sprintf("command '%s' is not allowed", baseCmd)), nil
82 }
84 }
83 }
85 }
84 isSafeReadOnly := false
86 isSafeReadOnly := false
@@ -91,39 +93,21 @@ func (b *bashTool) InvokableRun(ctx context.Context, args string, opts ...tool.O
91 if !isSafeReadOnly {
93 if !isSafeReadOnly {
92 p := permission.Default.Request(
94 p := permission.Default.Request(
93 permission.CreatePermissionRequest{
95 permission.CreatePermissionRequest{
94− Path: b.workingDir,
96+ Path: config.WorkingDirectory(),
95 ToolName: BashToolName,
97 ToolName: BashToolName,
96 Action: "execute",
98 Action: "execute",
97 Description: fmt.Sprintf("Execute command: %s", params.Command),
99 Description: fmt.Sprintf("Execute command: %s", params.Command),
98− Params: map[string]interface{}{
100+ Params: BashPermissionsParams(params),
99− "command": params.Command,
100− "timeout": params.Timeout,
101− },
102 },
101 },
103 )
102 )
104 if !p {
103 if !p {
105− return "", fmt.Errorf("permission denied for command: %s", params.Command)
104+ return NewTextErrorResponse("permission denied"), nil
106 }
105 }
107 }
106 }
108−
109− // p := b.permission.Request(permission.CreatePermissionRequest{
110− // Path: b.workingDir,
111− // ToolName: BashToolName,
112− // Action: "execute",
113− // Description: fmt.Sprintf("Execute command: %s", params.Command),
114− // Params: map[string]any{
115− // "command": params.Command,
116− // "timeout": params.Timeout,
117− // },
118− // })
119− // if !p {
120− // return "", errors.New("permission denied")
121− // }
122−
123− shell := shell.GetPersistentShell(b.workingDir)
107+ shell := shell.GetPersistentShell(config.WorkingDirectory())
124 stdout, stderr, exitCode, interrupted, err := shell.Exec(ctx, params.Command, params.Timeout)
108 stdout, stderr, exitCode, interrupted, err := shell.Exec(ctx, params.Command, params.Timeout)
125 if err != nil {
109 if err != nil {
126− return "", err
110+ return NewTextErrorResponse(fmt.Sprintf("error executing command: %s", err)), nil
127 }
111 }
128
112
129 stdout = truncateOutput(stdout)
113 stdout = truncateOutput(stdout)
@@ -153,9 +137,9 @@ func (b *bashTool) InvokableRun(ctx context.Context, args string, opts ...tool.O
153 }
137 }
154
138
155 if stdout == "" {
139 if stdout == "" {
156− return "no output", nil
140+ return NewTextResponse("no output"), nil
157 }
141 }
158− return stdout, nil
142+ return NewTextResponse(stdout), nil
159}
143}
160
144
161func truncateOutput(content string) string {
145func truncateOutput(content string) string {
@@ -327,8 +311,6 @@ Important:
327- Never update git config`, bannedCommandsStr, MaxOutputLength)
311- Never update git config`, bannedCommandsStr, MaxOutputLength)
328}
312}
329
313
330−func NewBashTool(workingDir string) tool.InvokableTool {
314+func NewBashTool() BaseTool {
331− return &bashTool{
315+ return &bashTool{}
332− workingDir: workingDir,
333− }
334}
316}
internal/llm/tools/bash_test.gonew file+389
@@ -0,0 +1,389 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "os"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "github.com/kujtimiihoxha/termai/internal/permission"
12+ "github.com/kujtimiihoxha/termai/internal/pubsub"
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func TestBashTool_Info(t *testing.T) {
18+ tool := NewBashTool()
19+ info := tool.Info()
20+
21+ assert.Equal(t, BashToolName, info.Name)
22+ assert.NotEmpty(t, info.Description)
23+ assert.Contains(t, info.Parameters, "command")
24+ assert.Contains(t, info.Parameters, "timeout")
25+ assert.Contains(t, info.Required, "command")
26+}
27+
28+func TestBashTool_Run(t *testing.T) {
29+ // Setup a mock permission handler that always allows
30+ origPermission := permission.Default
31+ defer func() {
32+ permission.Default = origPermission
33+ }()
34+ permission.Default = newMockPermissionService(true)
35+
36+ // Save original working directory
37+ origWd, err := os.Getwd()
38+ require.NoError(t, err)
39+ defer func() {
40+ os.Chdir(origWd)
41+ }()
42+
43+ t.Run("executes command successfully", func(t *testing.T) {
44+ permission.Default = newMockPermissionService(true)
45+ tool := NewBashTool()
46+ params := BashParams{
47+ Command: "echo 'Hello World'",
48+ }
49+
50+ paramsJSON, err := json.Marshal(params)
51+ require.NoError(t, err)
52+
53+ call := ToolCall{
54+ Name: BashToolName,
55+ Input: string(paramsJSON),
56+ }
57+
58+ response, err := tool.Run(context.Background(), call)
59+ require.NoError(t, err)
60+ assert.Equal(t, "Hello World\n", response.Content)
61+ })
62+
63+ t.Run("handles invalid parameters", func(t *testing.T) {
64+ permission.Default = newMockPermissionService(true)
65+
66+ tool := NewBashTool()
67+ call := ToolCall{
68+ Name: BashToolName,
69+ Input: "invalid json",
70+ }
71+
72+ response, err := tool.Run(context.Background(), call)
73+ require.NoError(t, err)
74+ assert.Contains(t, response.Content, "invalid parameters")
75+ })
76+
77+ t.Run("handles missing command", func(t *testing.T) {
78+ permission.Default = newMockPermissionService(true)
79+
80+ tool := NewBashTool()
81+ params := BashParams{
82+ Command: "",
83+ }
84+
85+ paramsJSON, err := json.Marshal(params)
86+ require.NoError(t, err)
87+
88+ call := ToolCall{
89+ Name: BashToolName,
90+ Input: string(paramsJSON),
91+ }
92+
93+ response, err := tool.Run(context.Background(), call)
94+ require.NoError(t, err)
95+ assert.Contains(t, response.Content, "missing command")
96+ })
97+
98+ t.Run("handles banned commands", func(t *testing.T) {
99+ permission.Default = newMockPermissionService(true)
100+
101+ tool := NewBashTool()
102+
103+ for _, bannedCmd := range BannedCommands {
104+ params := BashParams{
105+ Command: bannedCmd + " arg1 arg2",
106+ }
107+
108+ paramsJSON, err := json.Marshal(params)
109+ require.NoError(t, err)
110+
111+ call := ToolCall{
112+ Name: BashToolName,
113+ Input: string(paramsJSON),
114+ }
115+
116+ response, err := tool.Run(context.Background(), call)
117+ require.NoError(t, err)
118+ assert.Contains(t, response.Content, "not allowed", "Command %s should be blocked", bannedCmd)
119+ }
120+ })
121+
122+ t.Run("handles safe read-only commands without permission check", func(t *testing.T) {
123+ permission.Default = newMockPermissionService(false)
124+
125+ tool := NewBashTool()
126+
127+ // Test with a safe read-only command
128+ params := BashParams{
129+ Command: "echo 'test'",
130+ }
131+
132+ paramsJSON, err := json.Marshal(params)
133+ require.NoError(t, err)
134+
135+ call := ToolCall{
136+ Name: BashToolName,
137+ Input: string(paramsJSON),
138+ }
139+
140+ response, err := tool.Run(context.Background(), call)
141+ require.NoError(t, err)
142+ assert.Equal(t, "test\n", response.Content)
143+ })
144+
145+ t.Run("handles permission denied", func(t *testing.T) {
146+ permission.Default = newMockPermissionService(false)
147+
148+ tool := NewBashTool()
149+
150+ // Test with a command that requires permission
151+ params := BashParams{
152+ Command: "mkdir test_dir",
153+ }
154+
155+ paramsJSON, err := json.Marshal(params)
156+ require.NoError(t, err)
157+
158+ call := ToolCall{
159+ Name: BashToolName,
160+ Input: string(paramsJSON),
161+ }
162+
163+ response, err := tool.Run(context.Background(), call)
164+ require.NoError(t, err)
165+ assert.Contains(t, response.Content, "permission denied")
166+ })
167+
168+ t.Run("handles command timeout", func(t *testing.T) {
169+ permission.Default = newMockPermissionService(true)
170+ tool := NewBashTool()
171+ params := BashParams{
172+ Command: "sleep 2",
173+ Timeout: 100, // 100ms timeout
174+ }
175+
176+ paramsJSON, err := json.Marshal(params)
177+ require.NoError(t, err)
178+
179+ call := ToolCall{
180+ Name: BashToolName,
181+ Input: string(paramsJSON),
182+ }
183+
184+ response, err := tool.Run(context.Background(), call)
185+ require.NoError(t, err)
186+ assert.Contains(t, response.Content, "aborted")
187+ })
188+
189+ t.Run("handles command with stderr output", func(t *testing.T) {
190+ permission.Default = newMockPermissionService(true)
191+ tool := NewBashTool()
192+ params := BashParams{
193+ Command: "echo 'error message' >&2",
194+ }
195+
196+ paramsJSON, err := json.Marshal(params)
197+ require.NoError(t, err)
198+
199+ call := ToolCall{
200+ Name: BashToolName,
201+ Input: string(paramsJSON),
202+ }
203+
204+ response, err := tool.Run(context.Background(), call)
205+ require.NoError(t, err)
206+ assert.Contains(t, response.Content, "error message")
207+ })
208+
209+ t.Run("handles command with both stdout and stderr", func(t *testing.T) {
210+ permission.Default = newMockPermissionService(true)
211+ tool := NewBashTool()
212+ params := BashParams{
213+ Command: "echo 'stdout message' && echo 'stderr message' >&2",
214+ }
215+
216+ paramsJSON, err := json.Marshal(params)
217+ require.NoError(t, err)
218+
219+ call := ToolCall{
220+ Name: BashToolName,
221+ Input: string(paramsJSON),
222+ }
223+
224+ response, err := tool.Run(context.Background(), call)
225+ require.NoError(t, err)
226+ assert.Contains(t, response.Content, "stdout message")
227+ assert.Contains(t, response.Content, "stderr message")
228+ })
229+
230+ t.Run("handles context cancellation", func(t *testing.T) {
231+ permission.Default = newMockPermissionService(true)
232+ tool := NewBashTool()
233+ params := BashParams{
234+ Command: "sleep 5",
235+ }
236+
237+ paramsJSON, err := json.Marshal(params)
238+ require.NoError(t, err)
239+
240+ call := ToolCall{
241+ Name: BashToolName,
242+ Input: string(paramsJSON),
243+ }
244+
245+ ctx, cancel := context.WithCancel(context.Background())
246+
247+ // Cancel the context after a short delay
248+ go func() {
249+ time.Sleep(100 * time.Millisecond)
250+ cancel()
251+ }()
252+
253+ response, err := tool.Run(ctx, call)
254+ require.NoError(t, err)
255+ assert.Contains(t, response.Content, "aborted")
256+ })
257+
258+ t.Run("respects max timeout", func(t *testing.T) {
259+ permission.Default = newMockPermissionService(true)
260+ tool := NewBashTool()
261+ params := BashParams{
262+ Command: "echo 'test'",
263+ Timeout: MaxTimeout + 1000, // Exceeds max timeout
264+ }
265+
266+ paramsJSON, err := json.Marshal(params)
267+ require.NoError(t, err)
268+
269+ call := ToolCall{
270+ Name: BashToolName,
271+ Input: string(paramsJSON),
272+ }
273+
274+ response, err := tool.Run(context.Background(), call)
275+ require.NoError(t, err)
276+ assert.Equal(t, "test\n", response.Content)
277+ })
278+
279+ t.Run("uses default timeout for zero or negative timeout", func(t *testing.T) {
280+ permission.Default = newMockPermissionService(true)
281+ tool := NewBashTool()
282+ params := BashParams{
283+ Command: "echo 'test'",
284+ Timeout: -100, // Negative timeout
285+ }
286+
287+ paramsJSON, err := json.Marshal(params)
288+ require.NoError(t, err)
289+
290+ call := ToolCall{
291+ Name: BashToolName,
292+ Input: string(paramsJSON),
293+ }
294+
295+ response, err := tool.Run(context.Background(), call)
296+ require.NoError(t, err)
297+ assert.Equal(t, "test\n", response.Content)
298+ })
299+}
300+
301+func TestTruncateOutput(t *testing.T) {
302+ t.Run("does not truncate short output", func(t *testing.T) {
303+ output := "short output"
304+ result := truncateOutput(output)
305+ assert.Equal(t, output, result)
306+ })
307+
308+ t.Run("truncates long output", func(t *testing.T) {
309+ // Create a string longer than MaxOutputLength
310+ longOutput := strings.Repeat("a\n", MaxOutputLength)
311+ result := truncateOutput(longOutput)
312+
313+ // Check that the result is shorter than the original
314+ assert.Less(t, len(result), len(longOutput))
315+
316+ // Check that the truncation message is included
317+ assert.Contains(t, result, "lines truncated")
318+
319+ // Check that we have the beginning and end of the original string
320+ assert.True(t, strings.HasPrefix(result, "a\n"))
321+ assert.True(t, strings.HasSuffix(result, "a\n"))
322+ })
323+}
324+
325+func TestCountLines(t *testing.T) {
326+ testCases := []struct {
327+ name string
328+ input string
329+ expected int
330+ }{
331+ {
332+ name: "empty string",
333+ input: "",
334+ expected: 0,
335+ },
336+ {
337+ name: "single line",
338+ input: "line1",
339+ expected: 1,
340+ },
341+ {
342+ name: "multiple lines",
343+ input: "line1\nline2\nline3",
344+ expected: 3,
345+ },
346+ {
347+ name: "trailing newline",
348+ input: "line1\nline2\n",
349+ expected: 3, // Empty string after last newline counts as a line
350+ },
351+ }
352+
353+ for _, tc := range testCases {
354+ t.Run(tc.name, func(t *testing.T) {
355+ result := countLines(tc.input)
356+ assert.Equal(t, tc.expected, result)
357+ })
358+ }
359+}
360+
361+// Mock permission service for testing
362+type mockPermissionService struct {
363+ *pubsub.Broker[permission.PermissionRequest]
364+ allow bool
365+}
366+
367+func (m *mockPermissionService) GrantPersistant(permission permission.PermissionRequest) {
368+ // Not needed for tests
369+}
370+
371+func (m *mockPermissionService) Grant(permission permission.PermissionRequest) {
372+ // Not needed for tests
373+}
374+
375+func (m *mockPermissionService) Deny(permission permission.PermissionRequest) {
376+ // Not needed for tests
377+}
378+
379+func (m *mockPermissionService) Request(opts permission.CreatePermissionRequest) bool {
380+ return m.allow
381+}
382+
383+func newMockPermissionService(allow bool) permission.Service {
384+ return &mockPermissionService{
385+ Broker: pubsub.NewBroker[permission.PermissionRequest](),
386+ allow: allow,
387+ }
388+}
389+
internal/llm/tools/edit.go+149−198
@@ -3,22 +3,18 @@ package tools
3import (
3import (
4 "context"
4 "context"
5 "encoding/json"
5 "encoding/json"
6− "errors"
7 "fmt"
6 "fmt"
8 "os"
7 "os"
9 "path/filepath"
8 "path/filepath"
10 "strings"
9 "strings"
11 "time"
10 "time"
12
11
13− "github.com/cloudwego/eino/components/tool"
12+ "github.com/kujtimiihoxha/termai/internal/config"
14− "github.com/cloudwego/eino/schema"
15 "github.com/kujtimiihoxha/termai/internal/permission"
13 "github.com/kujtimiihoxha/termai/internal/permission"
16 "github.com/sergi/go-diff/diffmatchpatch"
14 "github.com/sergi/go-diff/diffmatchpatch"
17)
15)
18
16
19−type editTool struct {
17+type editTool struct{}
20− workingDir string
21−}
22
18
23const (
19const (
24 EditToolName = "edit"
20 EditToolName = "edit"
@@ -30,100 +26,72 @@ type EditParams struct {
30 NewString string `json:"new_string"`
26 NewString string `json:"new_string"`
31}
27}
32
28
33−func (b *editTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
29+type EditPermissionsParams struct {
34− return &schema.ToolInfo{
30+ FilePath string `json:"file_path"`
35− Name: EditToolName,
31+ OldString string `json:"old_string"`
36− Desc: `This is a tool for editing files. For moving or renaming files, you should generally use the Bash tool with the 'mv' command instead. For larger edits, use the Write tool to overwrite files. F.
32+ NewString string `json:"new_string"`
37−
33+ Diff string `json:"diff"`
38−Before using this tool:
34+}
39−
40−1. Use the View tool to understand the file's contents and context
41−
42−2. Verify the directory path is correct (only applicable when creating new files):
43− - Use the LS tool to verify the parent directory exists and is the correct location
44−
45−To make a file edit, provide the following:
46−1. file_path: The absolute path to the file to modify (must be absolute, not relative)
47−2. old_string: The text to replace (must be unique within the file, and must match the file contents exactly, including all whitespace and indentation)
48−3. new_string: The edited text to replace the old_string
49−
50−The tool will replace ONE occurrence of old_string with new_string in the specified file.
51−
52−CRITICAL REQUIREMENTS FOR USING THIS TOOL:
53−
54−1. UNIQUENESS: The old_string MUST uniquely identify the specific instance you want to change. This means:
55− - Include AT LEAST 3-5 lines of context BEFORE the change point
56− - Include AT LEAST 3-5 lines of context AFTER the change point
57− - Include all whitespace, indentation, and surrounding code exactly as it appears in the file
58−
59−2. SINGLE INSTANCE: This tool can only change ONE instance at a time. If you need to change multiple instances:
60− - Make separate calls to this tool for each instance
61− - Each call must uniquely identify its specific instance using extensive context
62−
63−3. VERIFICATION: Before using this tool:
64− - Check how many instances of the target text exist in the file
65− - If multiple instances exist, gather enough context to uniquely identify each one
66− - Plan separate tool calls for each instance
67−
68−WARNING: If you do not follow these requirements:
69− - The tool will fail if old_string matches multiple locations
70− - The tool will fail if old_string doesn't match exactly (including whitespace)
71− - You may change the wrong instance if you don't include enough context
72−
73−When making edits:
74− - Ensure the edit results in idiomatic, correct code
75− - Do not leave the code in a broken state
76− - Always use absolute file paths (starting with /)
77
35
78−If you want to create a new file, use:
36+func (e *editTool) Info() ToolInfo {
79− - A new file path, including dir name if needed
37+ return ToolInfo{
80− - An empty old_string
38+ Name: EditToolName,
81− - The new file's contents as new_string
39+ Description: editDescription(),
82−
40+ Parameters: map[string]any{
83−Remember: 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.`,
84− ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
85− "file_path": {
41+ "file_path": map[string]any{
86− Type: "string",
42+ "type": "string",
87− Desc: "The absolute path to the file to modify",
43+ "description": "The absolute path to the file to modify",
88− Required: true,
89 },
44 },
90− "old_string": {
45+ "old_string": map[string]any{
91− Type: "string",
46+ "type": "string",
92− Desc: "The text to replace",
47+ "description": "The text to replace",
93− Required: true,
94 },
48 },
95− "new_string": {
49+ "new_string": map[string]any{
96− Type: "string",
50+ "type": "string",
97− Desc: "The text to replace it with",
51+ "description": "The text to replace it with",
98− Required: true,
99 },
52 },
100− }),
53+ },
101− }, nil
54+ Required: []string{"file_path", "old_string", "new_string"},
55+ }
102}
56}
103
57
58+// Run implements Tool.
104−func (b *editTool) InvokableRun(ctx context.Context, args string, opts ...tool.Option) (string, error) {
59+func (e *editTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
105 var params EditParams
60 var params EditParams
106− if err := json.Unmarshal([]byte(args), &params); err != nil {
61+ if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
107− return "", err
62+ return NewTextErrorResponse("invalid parameters"), nil
108 }
63 }
109
64
110 if params.FilePath == "" {
65 if params.FilePath == "" {
111− return "", errors.New("file_path is required")
66+ return NewTextErrorResponse("file_path is required"), nil
112 }
67 }
113
68
114 if !filepath.IsAbs(params.FilePath) {
69 if !filepath.IsAbs(params.FilePath) {
115− return "", fmt.Errorf("file path must be absolute, got: %s", params.FilePath)
70+ wd := config.WorkingDirectory()
71+ params.FilePath = filepath.Join(wd, params.FilePath)
116 }
72 }
117
73
118 if params.OldString == "" {
74 if params.OldString == "" {
119− return createNewFile(params.FilePath, params.NewString)
75+ result, err := createNewFile(params.FilePath, params.NewString)
76+ if err != nil {
77+ return NewTextErrorResponse(fmt.Sprintf("error creating file: %s", err)), nil
78+ }
79+ return NewTextErrorResponse(result), nil
120 }
80 }
121
81
122 if params.NewString == "" {
82 if params.NewString == "" {
123− return deleteContent(params.FilePath, params.OldString)
83+ result, err := deleteContent(params.FilePath, params.OldString)
84+ if err != nil {
85+ return NewTextErrorResponse(fmt.Sprintf("error deleting content: %s", err)), nil
86+ }
87+ return NewTextErrorResponse(result), nil
124 }
88 }
125
89
126− return replaceContent(params.FilePath, params.OldString, params.NewString)
90+ result, err := replaceContent(params.FilePath, params.OldString, params.NewString)
91+ if err != nil {
92+ return NewTextErrorResponse(fmt.Sprintf("error replacing content: %s", err)), nil
93+ }
94+ return NewTextResponse(result), nil
127}
95}
128
96
129func createNewFile(filePath, content string) (string, error) {
97func createNewFile(filePath, content string) (string, error) {
@@ -148,9 +116,11 @@ func createNewFile(filePath, content string) (string, error) {
148 ToolName: EditToolName,
116 ToolName: EditToolName,
149 Action: "create",
117 Action: "create",
150 Description: fmt.Sprintf("Create file %s", filePath),
118 Description: fmt.Sprintf("Create file %s", filePath),
151− Params: map[string]interface{}{
119+ Params: EditPermissionsParams{
152− "file_path": filePath,
120+ FilePath: filePath,
121+ OldString: "",
153− "content": content,
122+ NewString: content,
123+ Diff: GenerateDiff("", content),
154 },
124 },
155 },
125 },
156 )
126 )
@@ -166,19 +136,6 @@ func createNewFile(filePath, content string) (string, error) {
166 recordFileWrite(filePath)
136 recordFileWrite(filePath)
167 recordFileRead(filePath)
137 recordFileRead(filePath)
168
138
169− // result := FileEditResult{
170− // FilePath: filePath,
171− // Created: true,
172− // Updated: false,
173− // Deleted: false,
174− // Diff: generateDiff("", content),
175− // }
176− //
177− // resultJSON, err := json.Marshal(result)
178− // if err != nil {
179− // return "", fmt.Errorf("failed to serialize result: %w", err)
180− // }
181− //
182 return "File created: " + filePath, nil
139 return "File created: " + filePath, nil
183}
140}
184
141
@@ -231,9 +188,11 @@ func deleteContent(filePath, oldString string) (string, error) {
231 ToolName: EditToolName,
188 ToolName: EditToolName,
232 Action: "delete",
189 Action: "delete",
233 Description: fmt.Sprintf("Delete content from file %s", filePath),
190 Description: fmt.Sprintf("Delete content from file %s", filePath),
234− Params: map[string]interface{}{
191+ Params: EditPermissionsParams{
235− "file_path": filePath,
192+ FilePath: filePath,
236− "content": content,
193+ OldString: oldString,
194+ NewString: "",
195+ Diff: GenerateDiff(oldContent, newContent),
237 },
196 },
238 },
197 },
239 )
198 )
@@ -247,21 +206,7 @@ func deleteContent(filePath, oldString string) (string, error) {
247 }
206 }
248
207
249 recordFileWrite(filePath)
208 recordFileWrite(filePath)
250−
251− // result := FileEditResult{
252− // FilePath: filePath,
209+ recordFileRead(filePath)
253− // Created: false,
254− // Updated: true,
255− // Deleted: true,
256− // Diff: generateDiff(oldContent, newContent),
257− // SnippetBefore: getContextSnippet(oldContent, index, len(oldString)),
258− // SnippetAfter: getContextSnippet(newContent, index, 0),
259− // }
260− //
261− // resultJSON, err := json.Marshal(result)
262− // if err != nil {
263− // return "", fmt.Errorf("failed to serialize result: %w", err)
264− // }
265
210
266 return "Content deleted from file: " + filePath, nil
211 return "Content deleted from file: " + filePath, nil
267}
212}
@@ -270,44 +215,45 @@ func replaceContent(filePath, oldString, newString string) (string, error) {
270 fileInfo, err := os.Stat(filePath)
215 fileInfo, err := os.Stat(filePath)
271 if err != nil {
216 if err != nil {
272 if os.IsNotExist(err) {
217 if os.IsNotExist(err) {
273− return fmt.Sprintf("file not found: %s", filePath), nil
218+ return "", fmt.Errorf("file not found: %s", filePath)
274 }
219 }
275− return fmt.Sprintf("failed to access file: %s", err), nil
220+ return "", fmt.Errorf("failed to access file: %w", err)
276 }
221 }
277
222
278 if fileInfo.IsDir() {
223 if fileInfo.IsDir() {
279− return fmt.Sprintf("path is a directory, not a file: %s", filePath), nil
224+ return "", fmt.Errorf("path is a directory, not a file: %s", filePath)
280 }
225 }
281
226
282 if getLastReadTime(filePath).IsZero() {
227 if getLastReadTime(filePath).IsZero() {
283− return "you must read the file before editing it. Use the View tool first", nil
228+ return "", fmt.Errorf("you must read the file before editing it. Use the View tool first")
284 }
229 }
285
230
286 modTime := fileInfo.ModTime()
231 modTime := fileInfo.ModTime()
287 lastRead := getLastReadTime(filePath)
232 lastRead := getLastReadTime(filePath)
288 if modTime.After(lastRead) {
233 if modTime.After(lastRead) {
289− return fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
234+ return "", fmt.Errorf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
290− filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339)), nil
235+ filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))
291 }
236 }
292
237
293 content, err := os.ReadFile(filePath)
238 content, err := os.ReadFile(filePath)
294 if err != nil {
239 if err != nil {
295− return fmt.Sprintf("failed to read file: %s", err), nil
240+ return "", fmt.Errorf("failed to read file: %w", err)
296 }
241 }
297
242
298 oldContent := string(content)
243 oldContent := string(content)
299
244
300 index := strings.Index(oldContent, oldString)
245 index := strings.Index(oldContent, oldString)
301 if index == -1 {
246 if index == -1 {
302− return "old_string not found in file. Make sure it matches exactly, including whitespace and line breaks", nil
247+ return "", fmt.Errorf("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks")
303 }
248 }
304
249
305 lastIndex := strings.LastIndex(oldContent, oldString)
250 lastIndex := strings.LastIndex(oldContent, oldString)
306 if index != lastIndex {
251 if index != lastIndex {
307− return "old_string appears multiple times in the file. Please provide more context to ensure a unique match", nil
252+ return "", fmt.Errorf("old_string appears multiple times in the file. Please provide more context to ensure a unique match")
308 }
253 }
309
254
310 newContent := oldContent[:index] + newString + oldContent[index+len(oldString):]
255 newContent := oldContent[:index] + newString + oldContent[index+len(oldString):]
256+ diff := GenerateDiff(oldString, newContent)
311
257
312 p := permission.Default.Request(
258 p := permission.Default.Request(
313 permission.CreatePermissionRequest{
259 permission.CreatePermissionRequest{
@@ -315,10 +261,11 @@ func replaceContent(filePath, oldString, newString string) (string, error) {
315 ToolName: EditToolName,
261 ToolName: EditToolName,
316 Action: "replace",
262 Action: "replace",
317 Description: fmt.Sprintf("Replace content in file %s", filePath),
263 Description: fmt.Sprintf("Replace content in file %s", filePath),
318− Params: map[string]interface{}{
264+ Params: EditPermissionsParams{
319− "file_path": filePath,
265+ FilePath: filePath,
320− "old_string": oldString,
266+ OldString: oldString,
321− "new_string": newString,
267+ NewString: newString,
268+ Diff: diff,
322 },
269 },
323 },
270 },
324 )
271 )
@@ -328,93 +275,97 @@ func replaceContent(filePath, oldString, newString string) (string, error) {
328
275
329 err = os.WriteFile(filePath, []byte(newContent), 0o644)
276 err = os.WriteFile(filePath, []byte(newContent), 0o644)
330 if err != nil {
277 if err != nil {
331− return fmt.Sprintf("failed to write file: %s", err), nil
278+ return "", fmt.Errorf("failed to write file: %w", err)
332 }
279 }
333
280
334 recordFileWrite(filePath)
281 recordFileWrite(filePath)
335−
336− // result := FileEditResult{
337− // FilePath: filePath,
282+ recordFileRead(filePath)
338− // Created: false,
339− // Updated: true,
340− // Deleted: false,
341− // Diff: generateDiff(oldContent, newContent),
342− // SnippetBefore: getContextSnippet(oldContent, index, len(oldString)),
343− // SnippetAfter: getContextSnippet(newContent, index, len(newString)),
344− // }
345− //
346− // resultJSON, err := json.Marshal(result)
347− // if err != nil {
348− // return "", fmt.Errorf("failed to serialize result: %w", err)
349− // }
350
283
351 return "Content replaced in file: " + filePath, nil
284 return "Content replaced in file: " + filePath, nil
352}
285}
353
286
354−func getContextSnippet(content string, position, length int) string {
287+func GenerateDiff(oldContent, newContent string) string {
355− contextLines := 3
288+ dmp := diffmatchpatch.New()
356−
289+ fileAdmp, fileBdmp, dmpStrings := dmp.DiffLinesToChars(oldContent, newContent)
290+ diffs := dmp.DiffMain(fileAdmp, fileBdmp, false)
291+ diffs = dmp.DiffCharsToLines(diffs, dmpStrings)
292+ diffs = dmp.DiffCleanupSemantic(diffs)
293+ buff := strings.Builder{}
294+ for _, diff := range diffs {
295+ text := diff.Text
296+
297+ switch diff.Type {
298+ case diffmatchpatch.DiffInsert:
357− lines := strings.Split(content, "\n")
299+ for _, line := range strings.Split(text, "\n") {
358− lineIndex := 0
300+ _, _ = buff.WriteString("+ " + line + "\n")
359− currentPos := 0
301+ }
360−
302+ case diffmatchpatch.DiffDelete:
361− for i, line := range lines {
303+ for _, line := range strings.Split(text, "\n") {
362− if currentPos <= position && position < currentPos+len(line)+1 {
304+ _, _ = buff.WriteString("- " + line + "\n")
363− lineIndex = i
305+ }
364− break
306+ case diffmatchpatch.DiffEqual:
307+ if len(text) > 40 {
308+ _, _ = buff.WriteString(" " + text[:20] + "..." + text[len(text)-20:] + "\n")
309+ } else {
310+ for _, line := range strings.Split(text, "\n") {
311+ _, _ = buff.WriteString(" " + line + "\n")
312+ }
313+ }
365 }
314 }
366− currentPos += len(line) + 1 // +1 for the newline
367 }
315 }
316+ return buff.String()
317+}
368
318
369− startLine := max(0, lineIndex-contextLines)
319+func editDescription() string {
370− endLine := min(len(lines), lineIndex+contextLines+1)
320+ return `Edits files by replacing text, creating new files, or deleting content. For moving or renaming files, use the Bash tool with the 'mv' command instead. For larger file edits, use the FileWrite tool to overwrite files.
371
321
372− var snippetBuilder strings.Builder
322+Before using this tool:
373− for i := startLine; i < endLine; i++ {
374− if i == lineIndex {
375− snippetBuilder.WriteString(fmt.Sprintf("> %s\n", lines[i]))
376− } else {
377− snippetBuilder.WriteString(fmt.Sprintf(" %s\n", lines[i]))
378− }
379− }
380
323
381− return snippetBuilder.String()
324+1. Use the FileRead tool to understand the file's contents and context
382−}
383
325
384−func generateDiff(oldContent, newContent string) string {
326+2. Verify the directory path is correct (only applicable when creating new files):
385− dmp := diffmatchpatch.New()
327+ - Use the LS tool to verify the parent directory exists and is the correct location
386
328
387− diffs := dmp.DiffMain(oldContent, newContent, false)
329+To make a file edit, provide the following:
388−
330+1. file_path: The absolute path to the file to modify (must be absolute, not relative)
389− patches := dmp.PatchMake(oldContent, diffs)
331+2. old_string: The text to replace (must be unique within the file, and must match the file contents exactly, including all whitespace and indentation)
390− patchText := dmp.PatchToText(patches)
332+3. new_string: The edited text to replace the old_string
391−
333+
392− if patchText == "" && (oldContent != newContent) {
334+Special cases:
393− var result strings.Builder
335+- To create a new file: provide file_path and new_string, leave old_string empty
394−
336+- To delete content: provide file_path and old_string, leave new_string empty
395− result.WriteString("@@ Diff @@\n")
396− for _, diff := range diffs {
397− switch diff.Type {
398− case diffmatchpatch.DiffInsert:
399− result.WriteString("+ " + diff.Text + "\n")
400− case diffmatchpatch.DiffDelete:
401− result.WriteString("- " + diff.Text + "\n")
402− case diffmatchpatch.DiffEqual:
403− if len(diff.Text) > 40 {
404− result.WriteString(" " + diff.Text[:20] + "..." + diff.Text[len(diff.Text)-20:] + "\n")
405− } else {
406− result.WriteString(" " + diff.Text + "\n")
407− }
408− }
409− }
410− return result.String()
411− }
412
337
413− return patchText
338+The tool will replace ONE occurrence of old_string with new_string in the specified file.
339+
340+CRITICAL REQUIREMENTS FOR USING THIS TOOL:
341+
342+1. UNIQUENESS: The old_string MUST uniquely identify the specific instance you want to change. This means:
343+ - Include AT LEAST 3-5 lines of context BEFORE the change point
344+ - Include AT LEAST 3-5 lines of context AFTER the change point
345+ - Include all whitespace, indentation, and surrounding code exactly as it appears in the file
346+
347+2. SINGLE INSTANCE: This tool can only change ONE instance at a time. If you need to change multiple instances:
348+ - Make separate calls to this tool for each instance
349+ - Each call must uniquely identify its specific instance using extensive context
350+
351+3. VERIFICATION: Before using this tool:
352+ - Check how many instances of the target text exist in the file
353+ - If multiple instances exist, gather enough context to uniquely identify each one
354+ - Plan separate tool calls for each instance
355+
356+WARNING: If you do not follow these requirements:
357+ - The tool will fail if old_string matches multiple locations
358+ - The tool will fail if old_string doesn't match exactly (including whitespace)
359+ - You may change the wrong instance if you don't include enough context
360+
361+When making edits:
362+ - Ensure the edit results in idiomatic, correct code
363+ - Do not leave the code in a broken state
364+ - Always use absolute file paths (starting with /)
365+
366+Remember: 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.`
414}
367}
415
368
416−func NewEditTool(workingDir string) tool.InvokableTool {
369+func NewEditTool() BaseTool {
417− return &editTool{
370+ return &editTool{}
418− workingDir: workingDir,
419− }
420}
371}
internal/llm/tools/glob.go+67−35
@@ -11,15 +11,11 @@ import (
11 "strings"
11 "strings"
12 "time"
12 "time"
13
13
14− "github.com/cloudwego/eino/components/tool"
15− "github.com/cloudwego/eino/schema"
16−
17 "github.com/bmatcuk/doublestar/v4"
14 "github.com/bmatcuk/doublestar/v4"
15+ "github.com/kujtimiihoxha/termai/internal/config"
18)
16)
19
17
20−type globTool struct {
18+type globTool struct{}
21− workingDir string
22−}
23
19
24const (
20const (
25 GlobToolName = "glob"
21 GlobToolName = "glob"
@@ -35,43 +31,44 @@ type GlobParams struct {
35 Path string `json:"path"`
31 Path string `json:"path"`
36}
32}
37
33
38−func (b *globTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
34+func (g *globTool) Info() ToolInfo {
39− return &schema.ToolInfo{
35+ return ToolInfo{
40− Name: GlobToolName,
36+ Name: GlobToolName,
41− Desc: `- Fast file pattern matching tool that works with any codebase size
37+ Description: globDescription(),
42−- Supports glob patterns like "**/*.js" or "src/**/*.ts"
38+ Parameters: map[string]any{
43−- Returns matching file paths sorted by modification time
44−- Use this tool when you need to find files by name patterns
45−- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead`,
46− ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
47− "pattern": {
39+ "pattern": map[string]any{
48− Type: "string",
40+ "type": "string",
49− Desc: "The glob pattern to match files against",
41+ "description": "The glob pattern to match files against",
50− Required: true,
51 },
42 },
52− "path": {
43+ "path": map[string]any{
53− Type: "string",
44+ "type": "string",
54− Desc: "The directory to search in. Defaults to the current working directory.",
45+ "description": "The directory to search in. Defaults to the current working directory.",
55 },
46 },
56− }),
47+ },
57− }, nil
48+ Required: []string{"pattern"},
49+ }
58}
50}
59
51
52+// Run implements Tool.
60−func (b *globTool) InvokableRun(ctx context.Context, args string, opts ...tool.Option) (string, error) {
53+func (g *globTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
61 var params GlobParams
54 var params GlobParams
62− if err := json.Unmarshal([]byte(args), &params); err != nil {
55+ if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
63− return fmt.Sprintf("error parsing parameters: %s", err), nil
56+ return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
57+ }
58+
59+ if params.Pattern == "" {
60+ return NewTextErrorResponse("pattern is required"), nil
64 }
61 }
65
62
66 // If path is empty, use current working directory
63 // If path is empty, use current working directory
67 searchPath := params.Path
64 searchPath := params.Path
68 if searchPath == "" {
65 if searchPath == "" {
69− searchPath = b.workingDir
66+ searchPath = config.WorkingDirectory()
70 }
67 }
71
68
72 files, truncated, err := globFiles(params.Pattern, searchPath, 100)
69 files, truncated, err := globFiles(params.Pattern, searchPath, 100)
73 if err != nil {
70 if err != nil {
74− return fmt.Sprintf("error performing glob search: %s", err), nil
71+ return NewTextErrorResponse(fmt.Sprintf("error performing glob search: %s", err)), nil
75 }
72 }
76
73
77 // Format the output for the assistant
74 // Format the output for the assistant
@@ -81,11 +78,11 @@ func (b *globTool) InvokableRun(ctx context.Context, args string, opts ...tool.O
81 } else {
78 } else {
82 output = strings.Join(files, "\n")
79 output = strings.Join(files, "\n")
83 if truncated {
80 if truncated {
84− output += "\n(Results are truncated. Consider using a more specific path or pattern.)"
81+ output += "\n\n(Results are truncated. Consider using a more specific path or pattern.)"
85 }
82 }
86 }
83 }
87
84
88− return output, nil
85+ return NewTextResponse(output), nil
89}
86}
90
87
91func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) {
88func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) {
@@ -167,8 +164,43 @@ func skipHidden(path string) bool {
167 return base != "." && strings.HasPrefix(base, ".")
164 return base != "." && strings.HasPrefix(base, ".")
168}
165}
169
166
170−func NewGlobTool(workingDir string) tool.InvokableTool {
167+func globDescription() string {
168+ return `Fast file pattern matching tool that finds files by name and pattern, returning matching paths sorted by modification time (newest first).
169+
170+WHEN TO USE THIS TOOL:
171+- Use when you need to find files by name patterns or extensions
172+- Great for finding specific file types across a directory structure
173+- Useful for discovering files that match certain naming conventions
174+
175+HOW TO USE:
176+- Provide a glob pattern to match against file paths
177+- Optionally specify a starting directory (defaults to current working directory)
178+- Results are sorted with most recently modified files first
179+
180+GLOB PATTERN SYNTAX:
181+- '*' matches any sequence of non-separator characters
182+- '**' matches any sequence of characters, including separators
183+- '?' matches any single non-separator character
184+- '[...]' matches any character in the brackets
185+- '[!...]' matches any character not in the brackets
186+
187+COMMON PATTERN EXAMPLES:
188+- '*.js' - Find all JavaScript files in the current directory
189+- '**/*.js' - Find all JavaScript files in any subdirectory
190+- 'src/**/*.{ts,tsx}' - Find all TypeScript files in the src directory
191+- '*.{html,css,js}' - Find all HTML, CSS, and JS files
192+
193+LIMITATIONS:
194+- Results are limited to 100 files (newest first)
195+- Does not search file contents (use Grep tool for that)
196+- Hidden files (starting with '.') are skipped
197+
198+TIPS:
199+- For the most useful results, combine with the Grep tool: first find files with Glob, then search their contents with Grep
200+- When doing iterative exploration that may require multiple rounds of searching, consider using the Agent tool instead
201+- Always check if results are truncated and refine your search pattern if needed`
202+}
203+
204+func NewGlobTool() BaseTool {
171− return &globTool{
205+ return &globTool{}
172− workingDir,
173− }
174}
206}
internal/llm/tools/grep.go+127−74
@@ -13,18 +13,13 @@ import (
13 "strings"
13 "strings"
14 "time"
14 "time"
15
15
16− "github.com/cloudwego/eino/components/tool"
16+ "github.com/kujtimiihoxha/termai/internal/config"
17− "github.com/cloudwego/eino/schema"
18)
17)
19
18
20−type grepTool struct {
19+type grepTool struct{}
21− workingDir string
22−}
23
20
24const (
21const (
25 GrepToolName = "grep"
22 GrepToolName = "grep"
26−
27− MaxGrepResults = 100
28)
23)
29
24
30type GrepParams struct {
25type GrepParams struct {
@@ -38,83 +33,66 @@ type grepMatch struct {
38 modTime time.Time
33 modTime time.Time
39}
34}
40
35
41−func (b *grepTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
36+func (g *grepTool) Info() ToolInfo {
42− return &schema.ToolInfo{
37+ return ToolInfo{
43− Name: GrepToolName,
38+ Name: GrepToolName,
44− Desc: `- Fast content search tool that works with any codebase size
39+ Description: grepDescription(),
45−- Searches file contents using regular expressions
40+ Parameters: map[string]any{
46−- Supports full regex syntax (eg. "log.*Error", "function\\s+\\w+", etc.)
41+ "pattern": map[string]any{
47−- Filter files by pattern with the include parameter (eg. "*.js", "*.{ts,tsx}")
42+ "type": "string",
48−- Returns matching file paths sorted by modification time
43+ "description": "The regex pattern to search for in file contents",
49−- Use this tool when you need to find files containing specific patterns
44+ },
50−- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead`,
45+ "path": map[string]any{
51− ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
52− "command": {
53− Type: "string",
46+ "type": "string",
54− Desc: "The command to execute",
47+ "description": "The directory to search in. Defaults to the current working directory.",
55− Required: true,
56 },
48 },
57− "timeout": {
49+ "include": map[string]any{
58− Type: "number",
50+ "type": "string",
59− Desc: "Optional timeout in milliseconds (max 600000)",
51+ "description": "File pattern to include in the search (e.g. \"*.js\", \"*.{ts,tsx}\")",
60 },
52 },
61− }),
53+ },
62− }, nil
54+ Required: []string{"pattern"},
55+ }
63}
56}
64
57
58+// Run implements Tool.
65−func (b *grepTool) InvokableRun(ctx context.Context, args string, opts ...tool.Option) (string, error) {
59+func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
66 var params GrepParams
60 var params GrepParams
67− if err := json.Unmarshal([]byte(args), &params); err != nil {
61+ if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
68− return "", err
62+ return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
63+ }
64+
65+ if params.Pattern == "" {
66+ return NewTextErrorResponse("pattern is required"), nil
69 }
67 }
70
68
69+ // If path is empty, use current working directory
71 searchPath := params.Path
70 searchPath := params.Path
72 if searchPath == "" {
71 if searchPath == "" {
73− var err error
72+ searchPath = config.WorkingDirectory()
74− searchPath, err = os.Getwd()
75− if err != nil {
76− return fmt.Sprintf("unable to get current working directory: %s", err), nil
77− }
78 }
73 }
79
74
80− matches, err := searchWithRipgrep(params.Pattern, searchPath, params.Include)
75+ matches, truncated, err := searchFiles(params.Pattern, searchPath, params.Include, 100)
81 if err != nil {
76 if err != nil {
82− matches, err = searchFilesWithRegex(params.Pattern, searchPath, params.Include)
83− if err != nil {
84− return fmt.Sprintf("error searching files: %s", err), nil
77+ return NewTextErrorResponse(fmt.Sprintf("error searching files: %s", err)), nil
85− }
86− }
87−
88− sort.Slice(matches, func(i, j int) bool {
89− return matches[i].modTime.After(matches[j].modTime)
90− })
91−
92− truncated := false
93− if len(matches) > MaxGrepResults {
94− truncated = true
95− matches = matches[:MaxGrepResults]
96− }
97−
98− filenames := make([]string, len(matches))
99− for i, m := range matches {
100− filenames[i] = m.path
101 }
78 }
102
79
80+ // Format the output for the assistant
103 var output string
81 var output string
104− if len(filenames) == 0 {
82+ if len(matches) == 0 {
105 output = "No files found"
83 output = "No files found"
106 } else {
84 } else {
107 output = fmt.Sprintf("Found %d file%s\n%s",
85 output = fmt.Sprintf("Found %d file%s\n%s",
108− len(filenames),
86+ len(matches),
109− pluralize(len(filenames)),
87+ pluralize(len(matches)),
110− strings.Join(filenames, "\n"))
88+ strings.Join(matches, "\n"))
111
89
112 if truncated {
90 if truncated {
113− output += "\n(Results are truncated. Consider using a more specific path or pattern.)"
91+ output += "\n\n(Results are truncated. Consider using a more specific path or pattern.)"
114 }
92 }
115 }
93 }
116
94
117− return output, nil
95+ return NewTextResponse(output), nil
118}
96}
119
97
120func pluralize(count int) string {
98func pluralize(count int) string {
@@ -124,6 +102,37 @@ func pluralize(count int) string {
124 return "s"
102 return "s"
125}
103}
126
104
105+func searchFiles(pattern, rootPath, include string, limit int) ([]string, bool, error) {
106+ // First try using ripgrep if available for better performance
107+ matches, err := searchWithRipgrep(pattern, rootPath, include)
108+ if err != nil {
109+ // Fall back to manual regex search if ripgrep is not available
110+ matches, err = searchFilesWithRegex(pattern, rootPath, include)
111+ if err != nil {
112+ return nil, false, err
113+ }
114+ }
115+
116+ // Sort files by modification time (newest first)
117+ sort.Slice(matches, func(i, j int) bool {
118+ return matches[i].modTime.After(matches[j].modTime)
119+ })
120+
121+ // Check if we need to truncate the results
122+ truncated := len(matches) > limit
123+ if truncated {
124+ matches = matches[:limit]
125+ }
126+
127+ // Extract just the paths
128+ results := make([]string, len(matches))
129+ for i, m := range matches {
130+ results[i] = m.path
131+ }
132+
133+ return results, truncated, nil
134+}
135+
127func searchWithRipgrep(pattern, path, include string) ([]grepMatch, error) {
136func searchWithRipgrep(pattern, path, include string) ([]grepMatch, error) {
128 _, err := exec.LookPath("rg")
137 _, err := exec.LookPath("rg")
129 if err != nil {
138 if err != nil {
@@ -140,6 +149,7 @@ func searchWithRipgrep(pattern, path, include string) ([]grepMatch, error) {
140 output, err := cmd.Output()
149 output, err := cmd.Output()
141 if err != nil {
150 if err != nil {
142 if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
151 if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
152+ // Exit code 1 means no matches, which isn't an error for our purposes
143 return []grepMatch{}, nil
153 return []grepMatch{}, nil
144 }
154 }
145 return nil, err
155 return nil, err
@@ -155,7 +165,7 @@ func searchWithRipgrep(pattern, path, include string) ([]grepMatch, error) {
155
165
156 fileInfo, err := os.Stat(line)
166 fileInfo, err := os.Stat(line)
157 if err != nil {
167 if err != nil {
158− continue
168+ continue // Skip files we can't access
159 }
169 }
160
170
161 matches = append(matches, grepMatch{
171 matches = append(matches, grepMatch{
@@ -186,20 +196,27 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
186
196
187 err = filepath.Walk(rootPath, func(path string, info os.FileInfo, err error) error {
197 err = filepath.Walk(rootPath, func(path string, info os.FileInfo, err error) error {
188 if err != nil {
198 if err != nil {
189− return nil
199+ return nil // Skip errors
190 }
200 }
191
201
192 if info.IsDir() {
202 if info.IsDir() {
203+ return nil // Skip directories
204+ }
205+
206+ // Skip hidden files
207+ if skipHidden(path) {
193 return nil
208 return nil
194 }
209 }
195
210
211+ // Check include pattern if provided
196 if includePattern != nil && !includePattern.MatchString(path) {
212 if includePattern != nil && !includePattern.MatchString(path) {
197 return nil
213 return nil
198 }
214 }
199
215
216+ // Check file contents for the pattern
200 match, err := fileContainsPattern(path, regex)
217 match, err := fileContainsPattern(path, regex)
201 if err != nil {
218 if err != nil {
202− return nil
219+ return nil // Skip files we can't read
203 }
220 }
204
221
205 if match {
222 if match {
@@ -207,6 +224,11 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
207 path: path,
224 path: path,
208 modTime: info.ModTime(),
225 modTime: info.ModTime(),
209 })
226 })
227+
228+ // Check if we've hit the limit (collect double for sorting)
229+ if len(matches) >= 200 {
230+ return filepath.SkipAll
231+ }
210 }
232 }
211
233
212 return nil
234 return nil
@@ -232,11 +254,7 @@ func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, error)
232 }
254 }
233 }
255 }
234
256
235− if err := scanner.Err(); err != nil {
257+ return false, scanner.Err()
236− return false, err
237− }
238−
239− return false, nil
240}
258}
241
259
242func globToRegex(glob string) string {
260func globToRegex(glob string) string {
@@ -250,11 +268,46 @@ func globToRegex(glob string) string {
250 return "(" + strings.ReplaceAll(inner, ",", "|") + ")"
268 return "(" + strings.ReplaceAll(inner, ",", "|") + ")"
251 })
269 })
252
270
253− return "^" + regexPattern + "$"
271+ return regexPattern
254}
272}
255
273
256−func NewGrepTool(workingDir string) tool.InvokableTool {
274+func grepDescription() string {
275+ return `Fast content search tool that finds files containing specific text or patterns, returning matching file paths sorted by modification time (newest first).
276+
277+WHEN TO USE THIS TOOL:
278+- Use when you need to find files containing specific text or patterns
279+- Great for searching code bases for function names, variable declarations, or error messages
280+- Useful for finding all files that use a particular API or pattern
281+
282+HOW TO USE:
283+- Provide a regex pattern to search for within file contents
284+- Optionally specify a starting directory (defaults to current working directory)
285+- Optionally provide an include pattern to filter which files to search
286+- Results are sorted with most recently modified files first
287+
288+REGEX PATTERN SYNTAX:
289+- Supports standard regular expression syntax
290+- 'function' searches for the literal text "function"
291+- 'log\..*Error' finds text starting with "log." and ending with "Error"
292+- 'import\s+.*\s+from' finds import statements in JavaScript/TypeScript
293+
294+COMMON INCLUDE PATTERN EXAMPLES:
295+- '*.js' - Only search JavaScript files
296+- '*.{ts,tsx}' - Only search TypeScript files
297+- '*.go' - Only search Go files
298+
299+LIMITATIONS:
300+- Results are limited to 100 files (newest first)
301+- Performance depends on the number of files being searched
302+- Very large binary files may be skipped
303+- Hidden files (starting with '.') are skipped
304+
305+TIPS:
306+- For faster, more targeted searches, first use Glob to find relevant files, then use Grep
307+- When doing iterative exploration that may require multiple rounds of searching, consider using the Agent tool instead
308+- Always check if results are truncated and refine your search pattern if needed`
309+}
310+
311+func NewGrepTool() BaseTool {
257− return &grepTool{
312+ return &grepTool{}
258− workingDir,
259− }
260}
313}
internal/llm/tools/ls.go+198−107
@@ -8,19 +8,14 @@ import (
8 "path/filepath"
8 "path/filepath"
9 "strings"
9 "strings"
10
10
11− "github.com/cloudwego/eino/components/tool"
11+ "github.com/kujtimiihoxha/termai/internal/config"
12− "github.com/cloudwego/eino/schema"
13)
12)
14
13
15−type lsTool struct {
14+type lsTool struct{}
16− workingDir string
17−}
18
15
19const (
16const (
20 LSToolName = "ls"
17 LSToolName = "ls"
21−
18+ MaxLSFiles = 1000
22− MaxFiles = 1000
23− TruncatedMessage = "There are more than 1000 files in the repository. Use the LS tool (passing a specific path), Bash tool, and other tools to explore nested directories. The first 1000 files and directories are included below:\n\n"
24)
19)
25
20
26type LSParams struct {
21type LSParams struct {
@@ -28,61 +23,82 @@ type LSParams struct {
28 Ignore []string `json:"ignore"`
23 Ignore []string `json:"ignore"`
29}
24}
30
25
26+type TreeNode struct {
27+ Name string `json:"name"`
28+ Path string `json:"path"`
29+ Type string `json:"type"` // "file" or "directory"
30+ Children []*TreeNode `json:"children,omitempty"`
31+}
32+
31−func (b *lsTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
33+func (l *lsTool) Info() ToolInfo {
32− return &schema.ToolInfo{
34+ return ToolInfo{
33− Name: LSToolName,
35+ Name: LSToolName,
34− Desc: "Lists files and directories in a given path. The path parameter must be an absolute path, not a relative path. You can optionally provide an array of glob patterns to ignore with the ignore parameter. You should generally prefer the Glob and Grep tools, if you know which directories to search.",
36+ Description: lsDescription(),
35− ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
37+ Parameters: map[string]any{
36− "path": {
38+ "path": map[string]any{
37− Type: "string",
39+ "type": "string",
38− Desc: "The absolute path to the directory to list (must be absolute, not relative)",
40+ "description": "The path to the directory to list (defaults to current working directory)",
39− Required: true,
40 },
41 },
41− "ignore": {
42+ "ignore": map[string]any{
42− Type: "array",
43+ "type": "array",
43− ElemInfo: &schema.ParameterInfo{
44− Type: schema.String,
45− Desc: "List of glob patterns to ignore",
44+ "description": "List of glob patterns to ignore",
45+ "items": map[string]any{
46+ "type": "string",
46 },
47 },
47 },
48 },
48− }),
49+ },
49− }, nil
50+ Required: []string{"path"},
51+ }
50}
52}
51
53
54+// Run implements Tool.
52−func (b *lsTool) InvokableRun(ctx context.Context, args string, opts ...tool.Option) (string, error) {
55+func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
53 var params LSParams
56 var params LSParams
54− if err := json.Unmarshal([]byte(args), &params); err != nil {
57+ if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
55− return "", err
58+ return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
59+ }
60+
61+ // If path is empty, use current working directory
62+ searchPath := params.Path
63+ if searchPath == "" {
64+ searchPath = config.WorkingDirectory()
65+ }
66+
67+ // Ensure the path is absolute
68+ if !filepath.IsAbs(searchPath) {
69+ searchPath = filepath.Join(config.WorkingDirectory(), searchPath)
56 }
70 }
57
71
58− if !filepath.IsAbs(params.Path) {
72+ // Check if the path exists
73+ if _, err := os.Stat(searchPath); os.IsNotExist(err) {
59− return fmt.Sprintf("path must be absolute, got: %s", params.Path), nil
74+ return NewTextErrorResponse(fmt.Sprintf("path does not exist: %s", searchPath)), nil
60 }
75 }
61
76
62− files, err := b.listDirectory(params.Path)
77+ files, truncated, err := listDirectory(searchPath, params.Ignore, MaxLSFiles)
63 if err != nil {
78 if err != nil {
64− return fmt.Sprintf("error listing directory: %s", err), nil
79+ return NewTextErrorResponse(fmt.Sprintf("error listing directory: %s", err)), nil
65 }
80 }
66
81
67 tree := createFileTree(files)
82 tree := createFileTree(files)
68− output := printTree(tree, params.Path)
83+ output := printTree(tree, searchPath)
69
84
70− if len(files) >= MaxFiles {
85+ if truncated {
71− output = TruncatedMessage + output
86+ output = fmt.Sprintf("There are more than %d files in the directory. Use a more specific path or use the Glob tool to find specific files. The first %d files and directories are included below:\n\n%s", MaxLSFiles, MaxLSFiles, output)
72 }
87 }
73
88
74− return output, nil
89+ return NewTextResponse(output), nil
75}
90}
76
91
77−func (b *lsTool) listDirectory(initialPath string) ([]string, error) {
92+func listDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) {
78 var results []string
93 var results []string
94+ truncated := false
79
95
80 err := filepath.Walk(initialPath, func(path string, info os.FileInfo, err error) error {
96 err := filepath.Walk(initialPath, func(path string, info os.FileInfo, err error) error {
81 if err != nil {
97 if err != nil {
82 return nil // Skip files we don't have permission to access
98 return nil // Skip files we don't have permission to access
83 }
99 }
84
100
85− if shouldSkip(path) {
101+ if shouldSkip(path, ignorePatterns) {
86 if info.IsDir() {
102 if info.IsDir() {
87 return filepath.SkipDir
103 return filepath.SkipDir
88 }
104 }
@@ -93,137 +109,212 @@ func (b *lsTool) listDirectory(initialPath string) ([]string, error) {
93 if info.IsDir() {
109 if info.IsDir() {
94 path = path + string(filepath.Separator)
110 path = path + string(filepath.Separator)
95 }
111 }
96−
97− relPath, err := filepath.Rel(b.workingDir, path)
98− if err == nil {
99− results = append(results, relPath)
112+ results = append(results, path)
100− } else {
101− results = append(results, path)
102− }
103 }
113 }
104
114
105− if len(results) >= MaxFiles {
115+ if len(results) >= limit {
106− return fmt.Errorf("max files reached")
116+ truncated = true
117+ return filepath.SkipAll
107 }
118 }
108
119
109 return nil
120 return nil
110 })
121 })
111−
112− if err != nil && err.Error() != "max files reached" {
122+ if err != nil {
113− return nil, err
123+ return nil, truncated, err
114 }
124 }
115
125
116− return results, nil
126+ return results, truncated, nil
117}
127}
118
128
119−func shouldSkip(path string) bool {
129+func shouldSkip(path string, ignorePatterns []string) bool {
120 base := filepath.Base(path)
130 base := filepath.Base(path)
121
131
132+ // Skip hidden files and directories
122 if base != "." && strings.HasPrefix(base, ".") {
133 if base != "." && strings.HasPrefix(base, ".") {
123 return true
134 return true
124 }
135 }
125
136
137+ // Skip common directories and files
138+ commonIgnored := []string{
139+ "__pycache__",
140+ "node_modules",
141+ "dist",
142+ "build",
143+ "target",
144+ "vendor",
145+ "bin",
146+ "obj",
147+ ".git",
148+ ".idea",
149+ ".vscode",
150+ ".DS_Store",
151+ "*.pyc",
152+ "*.pyo",
153+ "*.pyd",
154+ "*.so",
155+ "*.dll",
156+ "*.exe",
157+ }
158+
159+ // Skip __pycache__ directories
126 if strings.Contains(path, filepath.Join("__pycache__", "")) {
160 if strings.Contains(path, filepath.Join("__pycache__", "")) {
127 return true
161 return true
128 }
162 }
129
163
164+ // Check against common ignored patterns
165+ for _, ignored := range commonIgnored {
166+ if strings.HasSuffix(ignored, "/") {
167+ // Directory pattern
168+ if strings.Contains(path, filepath.Join(ignored[:len(ignored)-1], "")) {
169+ return true
170+ }
171+ } else if strings.HasPrefix(ignored, "*.") {
172+ // File extension pattern
173+ if strings.HasSuffix(base, ignored[1:]) {
174+ return true
175+ }
176+ } else {
177+ // Exact match
178+ if base == ignored {
130− return false
179+ return true
180+ }
181+ }
131−}
182+ }
132
183
133−type TreeNode struct {
184+ // Check against ignore patterns
134− Name string `json:"name"`
185+ for _, pattern := range ignorePatterns {
135− Path string `json:"path"`
186+ matched, err := filepath.Match(pattern, base)
136− Type string `json:"type"` // "file" or "directory"
187+ if err == nil && matched {
137− Children []TreeNode `json:"children,omitempty"`
188+ return true
189+ }
190+ }
191+
192+ return false
138}
193}
139
194
140−func createFileTree(sortedPaths []string) []TreeNode {
195+func createFileTree(sortedPaths []string) []*TreeNode {
141− root := []TreeNode{}
196+ root := []*TreeNode{}
197+ pathMap := make(map[string]*TreeNode)
142
198
143 for _, path := range sortedPaths {
199 for _, path := range sortedPaths {
144 parts := strings.Split(path, string(filepath.Separator))
200 parts := strings.Split(path, string(filepath.Separator))
145− currentLevel := &root
146 currentPath := ""
201 currentPath := ""
202+ var parentPath string
147
203
204+ var cleanParts []string
148− for i, part := range parts {
205+ for _, part := range parts {
149− if part == "" {
206+ if part != "" {
150− continue
207+ cleanParts = append(cleanParts, part)
151 }
208 }
209+ }
210+ parts = cleanParts
152
211
212+ if len(parts) == 0 {
213+ continue
214+ }
215+
216+ for i, part := range parts {
153 if currentPath == "" {
217 if currentPath == "" {
154 currentPath = part
218 currentPath = part
155 } else {
219 } else {
156 currentPath = filepath.Join(currentPath, part)
220 currentPath = filepath.Join(currentPath, part)
157 }
221 }
158
222
223+ if _, exists := pathMap[currentPath]; exists {
224+ parentPath = currentPath
225+ continue
226+ }
227+
159 isLastPart := i == len(parts)-1
228 isLastPart := i == len(parts)-1
160 isDir := !isLastPart || strings.HasSuffix(path, string(filepath.Separator))
229 isDir := !isLastPart || strings.HasSuffix(path, string(filepath.Separator))
161−
230+ nodeType := "file"
162− found := false
231+ if isDir {
163− for i := range *currentLevel {
232+ nodeType = "directory"
233+ }
234+ newNode := &TreeNode{
164− if (*currentLevel)[i].Name == part {
235+ Name: part,
165− found = true
236+ Path: currentPath,
166− if (*currentLevel)[i].Children != nil {
237+ Type: nodeType,
167− currentLevel = &(*currentLevel)[i].Children
238+ Children: []*TreeNode{},
168− }
169− break
170− }
171 }
239 }
172
240
173− if !found {
241+ pathMap[currentPath] = newNode
174− nodeType := "file"
175− if isDir {
176− nodeType = "directory"
177− }
178−
179− newNode := TreeNode{
180− Name: part,
181− Path: currentPath,
182− Type: nodeType,
183− }
184
242
185− if isDir {
243+ if i > 0 && parentPath != "" {
186− newNode.Children = []TreeNode{}
244+ if parent, ok := pathMap[parentPath]; ok {
187− *currentLevel = append(*currentLevel, newNode)
245+ parent.Children = append(parent.Children, newNode)
188− currentLevel = &(*currentLevel)[len(*currentLevel)-1].Children
189− } else {
190− *currentLevel = append(*currentLevel, newNode)
191 }
246 }
247+ } else {
248+ root = append(root, newNode)
192 }
249 }
250+
251+ parentPath = currentPath
193 }
252 }
194 }
253 }
195
254
196 return root
255 return root
197}
256}
198
257
199−func printTree(tree []TreeNode, rootPath string) string {
258+func printTree(tree []*TreeNode, rootPath string) string {
200 var result strings.Builder
259 var result strings.Builder
201
260
202 result.WriteString(fmt.Sprintf("- %s%s\n", rootPath, string(filepath.Separator)))
261 result.WriteString(fmt.Sprintf("- %s%s\n", rootPath, string(filepath.Separator)))
203
262
204− printTreeRecursive(&result, tree, 0, " ")
263+ for _, node := range tree {
264+ printNode(&result, node, 1)
265+ }
205
266
206 return result.String()
267 return result.String()
207}
268}
208
269
209−func printTreeRecursive(builder *strings.Builder, tree []TreeNode, level int, prefix string) {
270+func printNode(builder *strings.Builder, node *TreeNode, level int) {
210− for _, node := range tree {
271+ indent := strings.Repeat(" ", level)
211− linePrefix := prefix + "- "
212
272
213− nodeName := node.Name
273+ nodeName := node.Name
214− if node.Type == "directory" {
274+ if node.Type == "directory" {
215− nodeName += string(filepath.Separator)
275+ nodeName += string(filepath.Separator)
216− }
276+ }
277+
217− fmt.Fprintf(builder, "%s%s\n", linePrefix, nodeName)
278+ fmt.Fprintf(builder, "%s- %s\n", indent, nodeName)
218
279
219− if node.Type == "directory" && len(node.Children) > 0 {
280+ if node.Type == "directory" && len(node.Children) > 0 {
220− printTreeRecursive(builder, node.Children, level+1, prefix+" ")
281+ for _, child := range node.Children {
282+ printNode(builder, child, level+1)
221 }
283 }
222 }
284 }
223}
285}
224
286
225−func NewLsTool(workingDir string) tool.InvokableTool {
287+func lsDescription() string {
288+ return `Directory listing tool that shows files and subdirectories in a tree structure, helping you explore and understand the project organization.
289+
290+WHEN TO USE THIS TOOL:
291+- Use when you need to explore the structure of a directory
292+- Helpful for understanding the organization of a project
293+- Good first step when getting familiar with a new codebase
294+
295+HOW TO USE:
296+- Provide a path to list (defaults to current working directory)
297+- Optionally specify glob patterns to ignore
298+- Results are displayed in a tree structure
299+
300+FEATURES:
301+- Displays a hierarchical view of files and directories
302+- Automatically skips hidden files/directories (starting with '.')
303+- Skips common system directories like __pycache__
304+- Can filter out files matching specific patterns
305+
306+LIMITATIONS:
307+- Results are limited to 1000 files
308+- Very large directories will be truncated
309+- Does not show file sizes or permissions
310+- Cannot recursively list all directories in a large project
311+
312+TIPS:
313+- Use Glob tool for finding files by name patterns instead of browsing
314+- Use Grep tool for searching file contents
315+- Combine with other tools for more effective exploration`
316+}
317+
318+func NewLsTool() BaseTool {
226− return &lsTool{
319+ return &lsTool{}
227− workingDir,
228− }
229}
320}
internal/llm/tools/ls_test.gonew file+457
@@ -0,0 +1,457 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestLsTool_Info(t *testing.T) {
16+ tool := NewLsTool()
17+ info := tool.Info()
18+
19+ assert.Equal(t, LSToolName, info.Name)
20+ assert.NotEmpty(t, info.Description)
21+ assert.Contains(t, info.Parameters, "path")
22+ assert.Contains(t, info.Parameters, "ignore")
23+ assert.Contains(t, info.Required, "path")
24+}
25+
26+func TestLsTool_Run(t *testing.T) {
27+ // Create a temporary directory for testing
28+ tempDir, err := os.MkdirTemp("", "ls_tool_test")
29+ require.NoError(t, err)
30+ defer os.RemoveAll(tempDir)
31+
32+ // Create a test directory structure
33+ testDirs := []string{
34+ "dir1",
35+ "dir2",
36+ "dir2/subdir1",
37+ "dir2/subdir2",
38+ "dir3",
39+ "dir3/.hidden_dir",
40+ "__pycache__",
41+ }
42+
43+ testFiles := []string{
44+ "file1.txt",
45+ "file2.txt",
46+ "dir1/file3.txt",
47+ "dir2/file4.txt",
48+ "dir2/subdir1/file5.txt",
49+ "dir2/subdir2/file6.txt",
50+ "dir3/file7.txt",
51+ "dir3/.hidden_file.txt",
52+ "__pycache__/cache.pyc",
53+ ".hidden_root_file.txt",
54+ }
55+
56+ // Create directories
57+ for _, dir := range testDirs {
58+ dirPath := filepath.Join(tempDir, dir)
59+ err := os.MkdirAll(dirPath, 0755)
60+ require.NoError(t, err)
61+ }
62+
63+ // Create files
64+ for _, file := range testFiles {
65+ filePath := filepath.Join(tempDir, file)
66+ err := os.WriteFile(filePath, []byte("test content"), 0644)
67+ require.NoError(t, err)
68+ }
69+
70+ t.Run("lists directory successfully", func(t *testing.T) {
71+ tool := NewLsTool()
72+ params := LSParams{
73+ Path: tempDir,
74+ }
75+
76+ paramsJSON, err := json.Marshal(params)
77+ require.NoError(t, err)
78+
79+ call := ToolCall{
80+ Name: LSToolName,
81+ Input: string(paramsJSON),
82+ }
83+
84+ response, err := tool.Run(context.Background(), call)
85+ require.NoError(t, err)
86+
87+ // Check that visible directories and files are included
88+ assert.Contains(t, response.Content, "dir1")
89+ assert.Contains(t, response.Content, "dir2")
90+ assert.Contains(t, response.Content, "dir3")
91+ assert.Contains(t, response.Content, "file1.txt")
92+ assert.Contains(t, response.Content, "file2.txt")
93+
94+ // Check that hidden files and directories are not included
95+ assert.NotContains(t, response.Content, ".hidden_dir")
96+ assert.NotContains(t, response.Content, ".hidden_file.txt")
97+ assert.NotContains(t, response.Content, ".hidden_root_file.txt")
98+
99+ // Check that __pycache__ is not included
100+ assert.NotContains(t, response.Content, "__pycache__")
101+ })
102+
103+ t.Run("handles non-existent path", func(t *testing.T) {
104+ tool := NewLsTool()
105+ params := LSParams{
106+ Path: filepath.Join(tempDir, "non_existent_dir"),
107+ }
108+
109+ paramsJSON, err := json.Marshal(params)
110+ require.NoError(t, err)
111+
112+ call := ToolCall{
113+ Name: LSToolName,
114+ Input: string(paramsJSON),
115+ }
116+
117+ response, err := tool.Run(context.Background(), call)
118+ require.NoError(t, err)
119+ assert.Contains(t, response.Content, "path does not exist")
120+ })
121+
122+ t.Run("handles empty path parameter", func(t *testing.T) {
123+ // For this test, we need to mock the config.WorkingDirectory function
124+ // Since we can't easily do that, we'll just check that the response doesn't contain an error message
125+
126+ tool := NewLsTool()
127+ params := LSParams{
128+ Path: "",
129+ }
130+
131+ paramsJSON, err := json.Marshal(params)
132+ require.NoError(t, err)
133+
134+ call := ToolCall{
135+ Name: LSToolName,
136+ Input: string(paramsJSON),
137+ }
138+
139+ response, err := tool.Run(context.Background(), call)
140+ require.NoError(t, err)
141+
142+ // The response should either contain a valid directory listing or an error
143+ // We'll just check that it's not empty
144+ assert.NotEmpty(t, response.Content)
145+ })
146+
147+ t.Run("handles invalid parameters", func(t *testing.T) {
148+ tool := NewLsTool()
149+ call := ToolCall{
150+ Name: LSToolName,
151+ Input: "invalid json",
152+ }
153+
154+ response, err := tool.Run(context.Background(), call)
155+ require.NoError(t, err)
156+ assert.Contains(t, response.Content, "error parsing parameters")
157+ })
158+
159+ t.Run("respects ignore patterns", func(t *testing.T) {
160+ tool := NewLsTool()
161+ params := LSParams{
162+ Path: tempDir,
163+ Ignore: []string{"file1.txt", "dir1"},
164+ }
165+
166+ paramsJSON, err := json.Marshal(params)
167+ require.NoError(t, err)
168+
169+ call := ToolCall{
170+ Name: LSToolName,
171+ Input: string(paramsJSON),
172+ }
173+
174+ response, err := tool.Run(context.Background(), call)
175+ require.NoError(t, err)
176+
177+ // The output format is a tree, so we need to check for specific patterns
178+ // Check that file1.txt is not directly mentioned
179+ assert.NotContains(t, response.Content, "- file1.txt")
180+
181+ // Check that dir1/ is not directly mentioned
182+ assert.NotContains(t, response.Content, "- dir1/")
183+ })
184+
185+ t.Run("handles relative path", func(t *testing.T) {
186+ // Save original working directory
187+ origWd, err := os.Getwd()
188+ require.NoError(t, err)
189+ defer func() {
190+ os.Chdir(origWd)
191+ }()
192+
193+ // Change to a directory above the temp directory
194+ parentDir := filepath.Dir(tempDir)
195+ err = os.Chdir(parentDir)
196+ require.NoError(t, err)
197+
198+ tool := NewLsTool()
199+ params := LSParams{
200+ Path: filepath.Base(tempDir),
201+ }
202+
203+ paramsJSON, err := json.Marshal(params)
204+ require.NoError(t, err)
205+
206+ call := ToolCall{
207+ Name: LSToolName,
208+ Input: string(paramsJSON),
209+ }
210+
211+ response, err := tool.Run(context.Background(), call)
212+ require.NoError(t, err)
213+
214+ // Should list the temp directory contents
215+ assert.Contains(t, response.Content, "dir1")
216+ assert.Contains(t, response.Content, "file1.txt")
217+ })
218+}
219+
220+func TestShouldSkip(t *testing.T) {
221+ testCases := []struct {
222+ name string
223+ path string
224+ ignorePatterns []string
225+ expected bool
226+ }{
227+ {
228+ name: "hidden file",
229+ path: "/path/to/.hidden_file",
230+ ignorePatterns: []string{},
231+ expected: true,
232+ },
233+ {
234+ name: "hidden directory",
235+ path: "/path/to/.hidden_dir",
236+ ignorePatterns: []string{},
237+ expected: true,
238+ },
239+ {
240+ name: "pycache directory",
241+ path: "/path/to/__pycache__/file.pyc",
242+ ignorePatterns: []string{},
243+ expected: true,
244+ },
245+ {
246+ name: "node_modules directory",
247+ path: "/path/to/node_modules/package",
248+ ignorePatterns: []string{},
249+ expected: false, // The shouldSkip function doesn't directly check for node_modules in the path
250+ },
251+ {
252+ name: "normal file",
253+ path: "/path/to/normal_file.txt",
254+ ignorePatterns: []string{},
255+ expected: false,
256+ },
257+ {
258+ name: "normal directory",
259+ path: "/path/to/normal_dir",
260+ ignorePatterns: []string{},
261+ expected: false,
262+ },
263+ {
264+ name: "ignored by pattern",
265+ path: "/path/to/ignore_me.txt",
266+ ignorePatterns: []string{"ignore_*.txt"},
267+ expected: true,
268+ },
269+ {
270+ name: "not ignored by pattern",
271+ path: "/path/to/keep_me.txt",
272+ ignorePatterns: []string{"ignore_*.txt"},
273+ expected: false,
274+ },
275+ }
276+
277+ for _, tc := range testCases {
278+ t.Run(tc.name, func(t *testing.T) {
279+ result := shouldSkip(tc.path, tc.ignorePatterns)
280+ assert.Equal(t, tc.expected, result)
281+ })
282+ }
283+}
284+
285+func TestCreateFileTree(t *testing.T) {
286+ paths := []string{
287+ "/path/to/file1.txt",
288+ "/path/to/dir1/file2.txt",
289+ "/path/to/dir1/subdir/file3.txt",
290+ "/path/to/dir2/file4.txt",
291+ }
292+
293+ tree := createFileTree(paths)
294+
295+ // Check the structure of the tree
296+ assert.Len(t, tree, 1) // Should have one root node
297+
298+ // Check the root node
299+ rootNode := tree[0]
300+ assert.Equal(t, "path", rootNode.Name)
301+ assert.Equal(t, "directory", rootNode.Type)
302+ assert.Len(t, rootNode.Children, 1)
303+
304+ // Check the "to" node
305+ toNode := rootNode.Children[0]
306+ assert.Equal(t, "to", toNode.Name)
307+ assert.Equal(t, "directory", toNode.Type)
308+ assert.Len(t, toNode.Children, 3) // file1.txt, dir1, dir2
309+
310+ // Find the dir1 node
311+ var dir1Node *TreeNode
312+ for _, child := range toNode.Children {
313+ if child.Name == "dir1" {
314+ dir1Node = child
315+ break
316+ }
317+ }
318+
319+ require.NotNil(t, dir1Node)
320+ assert.Equal(t, "directory", dir1Node.Type)
321+ assert.Len(t, dir1Node.Children, 2) // file2.txt and subdir
322+}
323+
324+func TestPrintTree(t *testing.T) {
325+ // Create a simple tree
326+ tree := []*TreeNode{
327+ {
328+ Name: "dir1",
329+ Path: "dir1",
330+ Type: "directory",
331+ Children: []*TreeNode{
332+ {
333+ Name: "file1.txt",
334+ Path: "dir1/file1.txt",
335+ Type: "file",
336+ },
337+ {
338+ Name: "subdir",
339+ Path: "dir1/subdir",
340+ Type: "directory",
341+ Children: []*TreeNode{
342+ {
343+ Name: "file2.txt",
344+ Path: "dir1/subdir/file2.txt",
345+ Type: "file",
346+ },
347+ },
348+ },
349+ },
350+ },
351+ {
352+ Name: "file3.txt",
353+ Path: "file3.txt",
354+ Type: "file",
355+ },
356+ }
357+
358+ result := printTree(tree, "/root")
359+
360+ // Check the output format
361+ assert.Contains(t, result, "- /root/")
362+ assert.Contains(t, result, " - dir1/")
363+ assert.Contains(t, result, " - file1.txt")
364+ assert.Contains(t, result, " - subdir/")
365+ assert.Contains(t, result, " - file2.txt")
366+ assert.Contains(t, result, " - file3.txt")
367+}
368+
369+func TestListDirectory(t *testing.T) {
370+ // Create a temporary directory for testing
371+ tempDir, err := os.MkdirTemp("", "list_directory_test")
372+ require.NoError(t, err)
373+ defer os.RemoveAll(tempDir)
374+
375+ // Create a test directory structure
376+ testDirs := []string{
377+ "dir1",
378+ "dir1/subdir1",
379+ ".hidden_dir",
380+ }
381+
382+ testFiles := []string{
383+ "file1.txt",
384+ "file2.txt",
385+ "dir1/file3.txt",
386+ "dir1/subdir1/file4.txt",
387+ ".hidden_file.txt",
388+ }
389+
390+ // Create directories
391+ for _, dir := range testDirs {
392+ dirPath := filepath.Join(tempDir, dir)
393+ err := os.MkdirAll(dirPath, 0755)
394+ require.NoError(t, err)
395+ }
396+
397+ // Create files
398+ for _, file := range testFiles {
399+ filePath := filepath.Join(tempDir, file)
400+ err := os.WriteFile(filePath, []byte("test content"), 0644)
401+ require.NoError(t, err)
402+ }
403+
404+ t.Run("lists files with no limit", func(t *testing.T) {
405+ files, truncated, err := listDirectory(tempDir, []string{}, 1000)
406+ require.NoError(t, err)
407+ assert.False(t, truncated)
408+
409+ // Check that visible files and directories are included
410+ containsPath := func(paths []string, target string) bool {
411+ targetPath := filepath.Join(tempDir, target)
412+ for _, path := range paths {
413+ if strings.HasPrefix(path, targetPath) {
414+ return true
415+ }
416+ }
417+ return false
418+ }
419+
420+ assert.True(t, containsPath(files, "dir1"))
421+ assert.True(t, containsPath(files, "file1.txt"))
422+ assert.True(t, containsPath(files, "file2.txt"))
423+ assert.True(t, containsPath(files, "dir1/file3.txt"))
424+
425+ // Check that hidden files and directories are not included
426+ assert.False(t, containsPath(files, ".hidden_dir"))
427+ assert.False(t, containsPath(files, ".hidden_file.txt"))
428+ })
429+
430+ t.Run("respects limit and returns truncated flag", func(t *testing.T) {
431+ files, truncated, err := listDirectory(tempDir, []string{}, 2)
432+ require.NoError(t, err)
433+ assert.True(t, truncated)
434+ assert.Len(t, files, 2)
435+ })
436+
437+ t.Run("respects ignore patterns", func(t *testing.T) {
438+ files, truncated, err := listDirectory(tempDir, []string{"*.txt"}, 1000)
439+ require.NoError(t, err)
440+ assert.False(t, truncated)
441+
442+ // Check that no .txt files are included
443+ for _, file := range files {
444+ assert.False(t, strings.HasSuffix(file, ".txt"), "Found .txt file: %s", file)
445+ }
446+
447+ // But directories should still be included
448+ containsDir := false
449+ for _, file := range files {
450+ if strings.Contains(file, "dir1") {
451+ containsDir = true
452+ break
453+ }
454+ }
455+ assert.True(t, containsDir)
456+ })
457+}
internal/llm/tools/shell/shell.go+4−4
@@ -116,10 +116,10 @@ func (s *PersistentShell) execCommand(command string, timeout time.Duration, ctx
116 }
116 }
117
117
118 tempDir := os.TempDir()
118 tempDir := os.TempDir()
119− stdoutFile := filepath.Join(tempDir, fmt.Sprintf("orbitowl-stdout-%d", time.Now().UnixNano()))
119+ stdoutFile := filepath.Join(tempDir, fmt.Sprintf("termai-stdout-%d", time.Now().UnixNano()))
120− stderrFile := filepath.Join(tempDir, fmt.Sprintf("orbitowl-stderr-%d", time.Now().UnixNano()))
120+ stderrFile := filepath.Join(tempDir, fmt.Sprintf("termai-stderr-%d", time.Now().UnixNano()))
121− statusFile := filepath.Join(tempDir, fmt.Sprintf("orbitowl-status-%d", time.Now().UnixNano()))
121+ statusFile := filepath.Join(tempDir, fmt.Sprintf("termai-status-%d", time.Now().UnixNano()))
122− cwdFile := filepath.Join(tempDir, fmt.Sprintf("orbitowl-cwd-%d", time.Now().UnixNano()))
122+ cwdFile := filepath.Join(tempDir, fmt.Sprintf("termai-cwd-%d", time.Now().UnixNano()))
123
123
124 defer func() {
124 defer func() {
125 os.Remove(stdoutFile)
125 os.Remove(stdoutFile)
internal/llm/tools/tools.gonew file+49
@@ -0,0 +1,49 @@
1+package tools
2+
3+import "context"
4+
5+type ToolInfo struct {
6+ Name string
7+ Description string
8+ Parameters map[string]any
9+ Required []string
10+}
11+
12+type toolResponseType string
13+
14+const (
15+ ToolResponseTypeText toolResponseType = "text"
16+ ToolResponseTypeImage toolResponseType = "image"
17+)
18+
19+type ToolResponse struct {
20+ Type toolResponseType `json:"type"`
21+ Content string `json:"content"`
22+ IsError bool `json:"is_error"`
23+}
24+
25+func NewTextResponse(content string) ToolResponse {
26+ return ToolResponse{
27+ Type: ToolResponseTypeText,
28+ Content: content,
29+ }
30+}
31+
32+func NewTextErrorResponse(content string) ToolResponse {
33+ return ToolResponse{
34+ Type: ToolResponseTypeText,
35+ Content: content,
36+ IsError: true,
37+ }
38+}
39+
40+type ToolCall struct {
41+ ID string `json:"id"`
42+ Name string `json:"name"`
43+ Input string `json:"input"`
44+}
45+
46+type BaseTool interface {
47+ Info() ToolInfo
48+ Run(ctx context.Context, params ToolCall) (ToolResponse, error)
49+}
internal/llm/tools/view.go+112−64
@@ -10,77 +10,77 @@ import (
10 "path/filepath"
10 "path/filepath"
11 "strings"
11 "strings"
12
12
13− "github.com/cloudwego/eino/components/tool"
13+ "github.com/kujtimiihoxha/termai/internal/config"
14− "github.com/cloudwego/eino/schema"
15)
14)
16
15
17−type viewTool struct {
16+type viewTool struct{}
18− workingDir string
19−}
20
17
21const (
18const (
22− ViewToolName = "view"
19+ ViewToolName = "view"
23−
24− MaxReadSize = 250 * 1024
20+ MaxReadSize = 250 * 1024
25−
26 DefaultReadLimit = 2000
21 DefaultReadLimit = 2000
27−
28− MaxLineLength = 2000
22+ MaxLineLength = 2000
29)
23)
30
24
31−type ViewPatams struct {
25+type ViewParams struct {
32 FilePath string `json:"file_path"`
26 FilePath string `json:"file_path"`
33 Offset int `json:"offset"`
27 Offset int `json:"offset"`
34 Limit int `json:"limit"`
28 Limit int `json:"limit"`
35}
29}
36
30
37−func (b *viewTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
31+func (v *viewTool) Info() ToolInfo {
38− return &schema.ToolInfo{
32+ return ToolInfo{
39− Name: ViewToolName,
33+ Name: ViewToolName,
40− Desc: `Reads a file from the local filesystem. The file_path parameter must be an absolute path, not a relative path. By default, it reads up to 2000 lines starting from the beginning of the file. You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters. Any lines longer than 2000 characters will be truncated. For image files, the tool will display the image for you.`,
34+ Description: viewDescription(),
41− ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
35+ Parameters: map[string]any{
42− "file_path": {
36+ "file_path": map[string]any{
43− Type: "string",
37+ "type": "string",
44− Desc: "The absolute path to the file to read",
38+ "description": "The path to the file to read",
45− Required: true,
46 },
39 },
47− "offset": {
40+ "offset": map[string]any{
48− Type: "int",
41+ "type": "integer",
49− Desc: "The line number to start reading from. Only provide if the file is too large to read at once",
42+ "description": "The line number to start reading from (0-based)",
50 },
43 },
51− "limit": {
44+ "limit": map[string]any{
52− Type: "int",
45+ "type": "integer",
53− Desc: "The number of lines to read. Only provide if the file is too large to read at once.",
46+ "description": "The number of lines to read (defaults to 2000)",
54 },
47 },
55− }),
48+ },
56− }, nil
49+ Required: []string{"file_path"},
50+ }
57}
51}
58
52
53+// Run implements Tool.
59−func (b *viewTool) InvokableRun(ctx context.Context, args string, opts ...tool.Option) (string, error) {
54+func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
60− var params ViewPatams
55+ var params ViewParams
61− if err := json.Unmarshal([]byte(args), &params); err != nil {
56+ if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
62− return fmt.Sprintf("failed to parse parameters: %s", err), nil
57+ return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
63 }
58 }
64
59
65 if params.FilePath == "" {
60 if params.FilePath == "" {
66− return "file_path is required", nil
61+ return NewTextErrorResponse("file_path is required"), nil
67 }
62 }
68
63
64+ // Handle relative paths
65+ filePath := params.FilePath
69− if !filepath.IsAbs(params.FilePath) {
66+ if !filepath.IsAbs(filePath) {
70− return fmt.Sprintf("file path must be absolute, got: %s", params.FilePath), nil
67+ filePath = filepath.Join(config.WorkingDirectory(), filePath)
71 }
68 }
72
69
70+ // Check if file exists
73− fileInfo, err := os.Stat(params.FilePath)
71+ fileInfo, err := os.Stat(filePath)
74 if err != nil {
72 if err != nil {
75 if os.IsNotExist(err) {
73 if os.IsNotExist(err) {
74+ // Try to offer suggestions for similarly named files
76− dir := filepath.Dir(params.FilePath)
75+ dir := filepath.Dir(filePath)
77− base := filepath.Base(params.FilePath)
76+ base := filepath.Base(filePath)
78
77
79 dirEntries, dirErr := os.ReadDir(dir)
78 dirEntries, dirErr := os.ReadDir(dir)
80 if dirErr == nil {
79 if dirErr == nil {
81 var suggestions []string
80 var suggestions []string
82 for _, entry := range dirEntries {
81 for _, entry := range dirEntries {
83− if strings.Contains(entry.Name(), base) || strings.Contains(base, entry.Name()) {
82+ if strings.Contains(strings.ToLower(entry.Name()), strings.ToLower(base)) ||
83+ strings.Contains(strings.ToLower(base), strings.ToLower(entry.Name())) {
84 suggestions = append(suggestions, filepath.Join(dir, entry.Name()))
84 suggestions = append(suggestions, filepath.Join(dir, entry.Name()))
85 if len(suggestions) >= 3 {
85 if len(suggestions) >= 3 {
86 break
86 break
@@ -89,43 +89,55 @@ func (b *viewTool) InvokableRun(ctx context.Context, args string, opts ...tool.O
89 }
89 }
90
90
91 if len(suggestions) > 0 {
91 if len(suggestions) > 0 {
92− return fmt.Sprintf("file not found: %s. Did you mean one of these?\n%s",
92+ return NewTextErrorResponse(fmt.Sprintf("File not found: %s\n\nDid you mean one of these?\n%s",
93− params.FilePath, strings.Join(suggestions, "\n")), nil
93+ filePath, strings.Join(suggestions, "\n"))), nil
94 }
94 }
95 }
95 }
96
96
97− return fmt.Sprintf("file not found: %s", params.FilePath), nil
97+ return NewTextErrorResponse(fmt.Sprintf("File not found: %s", filePath)), nil
98 }
98 }
99− return fmt.Sprintf("failed to access file: %s", err), nil
99+ return NewTextErrorResponse(fmt.Sprintf("Failed to access file: %s", err)), nil
100 }
100 }
101
101
102+ // Check if it's a directory
102 if fileInfo.IsDir() {
103 if fileInfo.IsDir() {
103− return fmt.Sprintf("path is a directory, not a file: %s", params.FilePath), nil
104+ return NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
104 }
105 }
105
106
107+ // Check file size
106 if fileInfo.Size() > MaxReadSize {
108 if fileInfo.Size() > MaxReadSize {
107− return fmt.Sprintf("file is too large (%d bytes). Maximum size is %d bytes",
109+ return NewTextErrorResponse(fmt.Sprintf("File is too large (%d bytes). Maximum size is %d bytes",
108− fileInfo.Size(), MaxReadSize), nil
110+ fileInfo.Size(), MaxReadSize)), nil
109 }
111 }
110
112
113+ // Set default limit if not provided
111 if params.Limit <= 0 {
114 if params.Limit <= 0 {
112 params.Limit = DefaultReadLimit
115 params.Limit = DefaultReadLimit
113 }
116 }
114
117
118+ // Check if it's an image file
115− isImage, _ := isImageFile(params.FilePath)
119+ isImage, imageType := isImageFile(filePath)
116 if isImage {
120 if isImage {
117− // TODO: Implement image reading
121+ return NewTextErrorResponse(fmt.Sprintf("This is an image file of type: %s\nUse a different tool to process images", imageType)), nil
118− return "reading images is not supported", nil
119 }
122 }
120
123
124+ // Read the file content
121− content, _, err := readTextFile(params.FilePath, params.Offset, params.Limit)
125+ content, lineCount, err := readTextFile(filePath, params.Offset, params.Limit)
122 if err != nil {
126 if err != nil {
123− return fmt.Sprintf("failed to read file: %s", err), nil
127+ return NewTextErrorResponse(fmt.Sprintf("Failed to read file: %s", err)), nil
124 }
128 }
125
129
126− recordFileRead(params.FilePath)
130+ // Format the output with line numbers
131+ output := addLineNumbers(content, params.Offset+1)
132+
133+ // Add a note if the content was truncated
134+ if lineCount > params.Offset+len(strings.Split(content, "\n")) {
135+ output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)",
136+ params.Offset+len(strings.Split(content, "\n")))
137+ }
127
138
128− return addLineNumbers(content, params.Offset+1), nil
139+ recordFileRead(filePath)
140+ return NewTextResponse(output), nil
129}
141}
130
142
131func addLineNumbers(content string, startLine int) string {
143func addLineNumbers(content string, startLine int) string {
@@ -191,6 +203,11 @@ func readTextFile(filePath string, offset, limit int) (string, int, error) {
191 lines = append(lines, lineText)
203 lines = append(lines, lineText)
192 }
204 }
193
205
206+ // Continue scanning to get total line count
207+ for scanner.Scan() {
208+ lineCount++
209+ }
210+
194 if err := scanner.Err(); err != nil {
211 if err := scanner.Err(); err != nil {
195 return "", 0, err
212 return "", 0, err
196 }
213 }
@@ -202,17 +219,17 @@ func isImageFile(filePath string) (bool, string) {
202 ext := strings.ToLower(filepath.Ext(filePath))
219 ext := strings.ToLower(filepath.Ext(filePath))
203 switch ext {
220 switch ext {
204 case ".jpg", ".jpeg":
221 case ".jpg", ".jpeg":
205− return true, "jpeg"
222+ return true, "JPEG"
206 case ".png":
223 case ".png":
207− return true, "png"
224+ return true, "PNG"
208 case ".gif":
225 case ".gif":
209− return true, "gif"
226+ return true, "GIF"
210 case ".bmp":
227 case ".bmp":
211− return true, "bmp"
228+ return true, "BMP"
212 case ".svg":
229 case ".svg":
213− return true, "svg"
230+ return true, "SVG"
214 case ".webp":
231 case ".webp":
215− return true, "webp"
232+ return true, "WebP"
216 default:
233 default:
217 return false, ""
234 return false, ""
218 }
235 }
@@ -240,8 +257,39 @@ func (s *LineScanner) Err() error {
240 return s.scanner.Err()
257 return s.scanner.Err()
241}
258}
242
259
243−func NewViewTool(workingDir string) tool.InvokableTool {
260+func viewDescription() string {
261+ return `File viewing tool that reads and displays the contents of files with line numbers, allowing you to examine code, logs, or text data.
262+
263+WHEN TO USE THIS TOOL:
264+- Use when you need to read the contents of a specific file
265+- Helpful for examining source code, configuration files, or log files
266+- Perfect for looking at text-based file formats
267+
268+HOW TO USE:
269+- Provide the path to the file you want to view
270+- Optionally specify an offset to start reading from a specific line
271+- Optionally specify a limit to control how many lines are read
272+
273+FEATURES:
274+- Displays file contents with line numbers for easy reference
275+- Can read from any position in a file using the offset parameter
276+- Handles large files by limiting the number of lines read
277+- Automatically truncates very long lines for better display
278+- Suggests similar file names when the requested file isn't found
279+
280+LIMITATIONS:
281+- Maximum file size is 250KB
282+- Default reading limit is 2000 lines
283+- Lines longer than 2000 characters are truncated
284+- Cannot display binary files or images
285+- Images can be identified but not displayed
286+
287+TIPS:
288+- Use with Glob tool to first find files you want to view
289+- For code exploration, first use Grep to find relevant files, then View to examine them
290+- When viewing large files, use the offset parameter to read specific sections`
291+}
292+
293+func NewViewTool() BaseTool {
244− return &viewTool{
294+ return &viewTool{}
245− workingDir,
246− }
247}
295}
internal/llm/tools/write.go+91−95
@@ -6,17 +6,13 @@ import (
6 "fmt"
6 "fmt"
7 "os"
7 "os"
8 "path/filepath"
8 "path/filepath"
9− "strings"
10 "time"
9 "time"
11
10
12− "github.com/cloudwego/eino/components/tool"
11+ "github.com/kujtimiihoxha/termai/internal/config"
13− "github.com/cloudwego/eino/schema"
14 "github.com/kujtimiihoxha/termai/internal/permission"
12 "github.com/kujtimiihoxha/termai/internal/permission"
15)
13)
16
14
17−type writeTool struct {
15+type writeTool struct{}
18− workingDir string
19−}
20
16
21const (
17const (
22 WriteToolName = "write"
18 WriteToolName = "write"
@@ -27,139 +23,139 @@ type WriteParams struct {
27 Content string `json:"content"`
23 Content string `json:"content"`
28}
24}
29
25
26+type WritePermissionsParams struct {
27+ FilePath string `json:"file_path"`
28+ Content string `json:"content"`
29+}
30+
30−func (b *writeTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
31+func (w *writeTool) Info() ToolInfo {
31− return &schema.ToolInfo{
32+ return ToolInfo{
32− Name: WriteToolName,
33+ Name: WriteToolName,
33− Desc: "Write a file to the local filesystem. Overwrites the existing file if there is one.\n\nBefore using this tool:\n\n1. Use the ReadFile tool to understand the file's contents and context\n\n2. Directory Verification (only applicable when creating new files):\n - Use the LS tool to verify the parent directory exists and is the correct location",
34+ Description: writeDescription(),
34− ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
35+ Parameters: map[string]any{
35− "file_path": {
36+ "file_path": map[string]any{
36− Type: "string",
37+ "type": "string",
37− Desc: "The absolute path to the file to write (must be absolute, not relative)",
38+ "description": "The path to the file to write",
38− Required: true,
39 },
39 },
40− "content": {
40+ "content": map[string]any{
41− Type: "string",
41+ "type": "string",
42− Desc: "The content to write to the file",
42+ "description": "The content to write to the file",
43− Required: true,
44 },
43 },
45− }),
44+ },
46− }, nil
45+ Required: []string{"file_path", "content"},
46+ }
47}
47}
48
48
49+// Run implements Tool.
49−func (b *writeTool) InvokableRun(ctx context.Context, args string, opts ...tool.Option) (string, error) {
50+func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
50 var params WriteParams
51 var params WriteParams
51− if err := json.Unmarshal([]byte(args), &params); err != nil {
52+ if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
52− return "", fmt.Errorf("failed to parse parameters: %w", err)
53+ return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
53 }
54 }
54
55
55 if params.FilePath == "" {
56 if params.FilePath == "" {
56− return "file_path is required", nil
57+ return NewTextErrorResponse("file_path is required"), nil
58+ }
59+
60+ if params.Content == "" {
61+ return NewTextErrorResponse("content is required"), nil
57 }
62 }
58
63
64+ // Handle relative paths
65+ filePath := params.FilePath
59− if !filepath.IsAbs(params.FilePath) {
66+ if !filepath.IsAbs(filePath) {
60− return fmt.Sprintf("file path must be absolute, got: %s", params.FilePath), nil
67+ filePath = filepath.Join(config.WorkingDirectory(), filePath)
61 }
68 }
62
69
63− // fileExists := false
70+ // Check if file exists and is a directory
64− // oldContent := ""
65− fileInfo, err := os.Stat(params.FilePath)
71+ fileInfo, err := os.Stat(filePath)
66 if err == nil {
72 if err == nil {
67 if fileInfo.IsDir() {
73 if fileInfo.IsDir() {
68− return fmt.Sprintf("path is a directory, not a file: %s", params.FilePath), nil
74+ return NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
69 }
75 }
70
76
77+ // Check if file was modified since last read
71 modTime := fileInfo.ModTime()
78 modTime := fileInfo.ModTime()
72− lastRead := getLastReadTime(params.FilePath)
79+ lastRead := getLastReadTime(filePath)
73 if modTime.After(lastRead) {
80 if modTime.After(lastRead) {
74− return fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
81+ return NewTextErrorResponse(fmt.Sprintf("File %s has been modified since it was last read.\nLast modification: %s\nLast read: %s\n\nPlease read the file again before modifying it.",
75− params.FilePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339)), nil
82+ filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))), nil
76 }
83 }
77
84
85+ // Optional: Get old content for diff
78− // oldContentBytes, readErr := os.ReadFile(params.FilePath)
86+ oldContent, readErr := os.ReadFile(filePath)
79− // if readErr != nil {
87+ if readErr == nil && string(oldContent) == params.Content {
80− // oldContent = string(oldContentBytes)
88+ return NewTextErrorResponse(fmt.Sprintf("File %s already contains the exact content. No changes made.", filePath)), nil
81− // }
89+ }
82 } else if !os.IsNotExist(err) {
90 } else if !os.IsNotExist(err) {
83− return fmt.Sprintf("failed to access file: %s", err), nil
91+ return NewTextErrorResponse(fmt.Sprintf("Failed to access file: %s", err)), nil
84 }
92 }
85
93
94+ // Create parent directories if needed
95+ dir := filepath.Dir(filePath)
96+ if err = os.MkdirAll(dir, 0o755); err != nil {
97+ return NewTextErrorResponse(fmt.Sprintf("Failed to create parent directories: %s", err)), nil
98+ }
86 p := permission.Default.Request(
99 p := permission.Default.Request(
87 permission.CreatePermissionRequest{
100 permission.CreatePermissionRequest{
88− Path: b.workingDir,
101+ Path: filePath,
89 ToolName: WriteToolName,
102 ToolName: WriteToolName,
90− Action: "write",
103+ Action: "create",
91− Description: fmt.Sprintf("Write to file %s", params.FilePath),
104+ Description: fmt.Sprintf("Create file %s", filePath),
92− Params: map[string]interface{}{
105+ Params: WritePermissionsParams{
93− "file_path": params.FilePath,
106+ FilePath: filePath,
94− "contnet": params.Content,
107+ Content: GenerateDiff("", params.Content),
95 },
108 },
96 },
109 },
97 )
110 )
98 if !p {
111 if !p {
99− return "", fmt.Errorf("permission denied")
112+ return NewTextErrorResponse(fmt.Sprintf("Permission denied to create file: %s", filePath)), nil
100− }
101− dir := filepath.Dir(params.FilePath)
102− if err = os.MkdirAll(dir, 0o755); err != nil {
103− return fmt.Sprintf("failed to create parent directories: %s", err), nil
104 }
113 }
105
114
115+ // Write the file
106− err = os.WriteFile(params.FilePath, []byte(params.Content), 0o644)
116+ err = os.WriteFile(filePath, []byte(params.Content), 0o644)
107 if err != nil {
117 if err != nil {
108− return fmt.Sprintf("failed to write file: %s", err), nil
118+ return NewTextErrorResponse(fmt.Sprintf("Failed to write file: %s", err)), nil
109 }
119 }
110
120
121+ // Record the file write
111− recordFileWrite(params.FilePath)
122+ recordFileWrite(filePath)
112−
123+ recordFileRead(filePath)
113− output := "File written: " + params.FilePath
114
124
115− // if fileExists && oldContent != params.Content {
125+ return NewTextResponse(fmt.Sprintf("File successfully written: %s", filePath)), nil
116− // output = generateSimpleDiff(oldContent, params.Content)
117− // }
118−
119− return output, nil
120}
126}
121
127
122−func generateSimpleDiff(oldContent, newContent string) string {
128+func writeDescription() string {
123− if oldContent == newContent {
129+ return `File writing tool that creates or updates files in the filesystem, allowing you to save or modify text content.
124− return "[No changes]"
125− }
126
130
127− oldLines := strings.Split(oldContent, "\n")
131+WHEN TO USE THIS TOOL:
128− newLines := strings.Split(newContent, "\n")
132+- Use when you need to create a new file
133+- Helpful for updating existing files with modified content
134+- Perfect for saving generated code, configurations, or text data
129
135
130− var diffBuilder strings.Builder
136+HOW TO USE:
131− diffBuilder.WriteString(fmt.Sprintf("@@ -%d,+%d @@\n", len(oldLines), len(newLines)))
137+- Provide the path to the file you want to write
138+- Include the content to be written to the file
139+- The tool will create any necessary parent directories
132
140
133− maxLines := max(len(oldLines), len(newLines))
141+FEATURES:
134− for i := range maxLines {
142+- Can create new files or overwrite existing ones
135− oldLine := ""
143+- Creates parent directories automatically if they don't exist
136− newLine := ""
144+- Checks if the file has been modified since last read for safety
145+- Avoids unnecessary writes when content hasn't changed
137
146
138− if i < len(oldLines) {
147+LIMITATIONS:
139− oldLine = oldLines[i]
148+- You should read a file before writing to it to avoid conflicts
140− }
149+- Cannot append to files (rewrites the entire file)
141
150
142− if i < len(newLines) {
143− newLine = newLines[i]
144− }
145
151
146− if oldLine != newLine {
152+TIPS:
147− if i < len(oldLines) {
153+- Use the View tool first to examine existing files before modifying them
148− diffBuilder.WriteString(fmt.Sprintf("- %s\n", oldLine))
154+- Use the LS tool to verify the correct location when creating new files
149− }
155+- Combine with Glob and Grep tools to find and modify multiple files
150− if i < len(newLines) {
156+- Always include descriptive comments when making changes to existing code`
151− diffBuilder.WriteString(fmt.Sprintf("+ %s\n", newLine))
152− }
153− } else {
154− diffBuilder.WriteString(fmt.Sprintf(" %s\n", oldLine))
155− }
156− }
157−
158− return diffBuilder.String()
159}
157}
160
158
161−func NewWriteTool(workingDir string) tool.InvokableTool {
159+func NewWriteTool() BaseTool {
162− return &writeTool{
160+ return &writeTool{}
163− workingDir: workingDir,
164− }
165}
161}
internal/llm/tools/write_test.gonew file+324
@@ -0,0 +1,324 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+ "time"
10+
11+ "github.com/kujtimiihoxha/termai/internal/permission"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestWriteTool_Info(t *testing.T) {
17+ tool := NewWriteTool()
18+ info := tool.Info()
19+
20+ assert.Equal(t, WriteToolName, info.Name)
21+ assert.NotEmpty(t, info.Description)
22+ assert.Contains(t, info.Parameters, "file_path")
23+ assert.Contains(t, info.Parameters, "content")
24+ assert.Contains(t, info.Required, "file_path")
25+ assert.Contains(t, info.Required, "content")
26+}
27+
28+func TestWriteTool_Run(t *testing.T) {
29+ // Setup a mock permission handler that always allows
30+ origPermission := permission.Default
31+ defer func() {
32+ permission.Default = origPermission
33+ }()
34+ permission.Default = newMockPermissionService(true)
35+
36+ // Create a temporary directory for testing
37+ tempDir, err := os.MkdirTemp("", "write_tool_test")
38+ require.NoError(t, err)
39+ defer os.RemoveAll(tempDir)
40+
41+ t.Run("creates a new file successfully", func(t *testing.T) {
42+ permission.Default = newMockPermissionService(true)
43+ tool := NewWriteTool()
44+
45+ filePath := filepath.Join(tempDir, "new_file.txt")
46+ content := "This is a test content"
47+
48+ params := WriteParams{
49+ FilePath: filePath,
50+ Content: content,
51+ }
52+
53+ paramsJSON, err := json.Marshal(params)
54+ require.NoError(t, err)
55+
56+ call := ToolCall{
57+ Name: WriteToolName,
58+ Input: string(paramsJSON),
59+ }
60+
61+ response, err := tool.Run(context.Background(), call)
62+ require.NoError(t, err)
63+ assert.Contains(t, response.Content, "successfully written")
64+
65+ // Verify file was created with correct content
66+ fileContent, err := os.ReadFile(filePath)
67+ require.NoError(t, err)
68+ assert.Equal(t, content, string(fileContent))
69+ })
70+
71+ t.Run("creates file with nested directories", func(t *testing.T) {
72+ permission.Default = newMockPermissionService(true)
73+ tool := NewWriteTool()
74+
75+ filePath := filepath.Join(tempDir, "nested/dirs/new_file.txt")
76+ content := "Content in nested directory"
77+
78+ params := WriteParams{
79+ FilePath: filePath,
80+ Content: content,
81+ }
82+
83+ paramsJSON, err := json.Marshal(params)
84+ require.NoError(t, err)
85+
86+ call := ToolCall{
87+ Name: WriteToolName,
88+ Input: string(paramsJSON),
89+ }
90+
91+ response, err := tool.Run(context.Background(), call)
92+ require.NoError(t, err)
93+ assert.Contains(t, response.Content, "successfully written")
94+
95+ // Verify file was created with correct content
96+ fileContent, err := os.ReadFile(filePath)
97+ require.NoError(t, err)
98+ assert.Equal(t, content, string(fileContent))
99+ })
100+
101+ t.Run("updates existing file", func(t *testing.T) {
102+ permission.Default = newMockPermissionService(true)
103+ tool := NewWriteTool()
104+
105+ // Create a file first
106+ filePath := filepath.Join(tempDir, "existing_file.txt")
107+ initialContent := "Initial content"
108+ err := os.WriteFile(filePath, []byte(initialContent), 0644)
109+ require.NoError(t, err)
110+
111+ // Record the file read to avoid modification time check failure
112+ recordFileRead(filePath)
113+
114+ // Update the file
115+ updatedContent := "Updated content"
116+ params := WriteParams{
117+ FilePath: filePath,
118+ Content: updatedContent,
119+ }
120+
121+ paramsJSON, err := json.Marshal(params)
122+ require.NoError(t, err)
123+
124+ call := ToolCall{
125+ Name: WriteToolName,
126+ Input: string(paramsJSON),
127+ }
128+
129+ response, err := tool.Run(context.Background(), call)
130+ require.NoError(t, err)
131+ assert.Contains(t, response.Content, "successfully written")
132+
133+ // Verify file was updated with correct content
134+ fileContent, err := os.ReadFile(filePath)
135+ require.NoError(t, err)
136+ assert.Equal(t, updatedContent, string(fileContent))
137+ })
138+
139+ t.Run("handles invalid parameters", func(t *testing.T) {
140+ permission.Default = newMockPermissionService(true)
141+ tool := NewWriteTool()
142+
143+ call := ToolCall{
144+ Name: WriteToolName,
145+ Input: "invalid json",
146+ }
147+
148+ response, err := tool.Run(context.Background(), call)
149+ require.NoError(t, err)
150+ assert.Contains(t, response.Content, "error parsing parameters")
151+ })
152+
153+ t.Run("handles missing file_path", func(t *testing.T) {
154+ permission.Default = newMockPermissionService(true)
155+ tool := NewWriteTool()
156+
157+ params := WriteParams{
158+ FilePath: "",
159+ Content: "Some content",
160+ }
161+
162+ paramsJSON, err := json.Marshal(params)
163+ require.NoError(t, err)
164+
165+ call := ToolCall{
166+ Name: WriteToolName,
167+ Input: string(paramsJSON),
168+ }
169+
170+ response, err := tool.Run(context.Background(), call)
171+ require.NoError(t, err)
172+ assert.Contains(t, response.Content, "file_path is required")
173+ })
174+
175+ t.Run("handles missing content", func(t *testing.T) {
176+ permission.Default = newMockPermissionService(true)
177+ tool := NewWriteTool()
178+
179+ params := WriteParams{
180+ FilePath: filepath.Join(tempDir, "file.txt"),
181+ Content: "",
182+ }
183+
184+ paramsJSON, err := json.Marshal(params)
185+ require.NoError(t, err)
186+
187+ call := ToolCall{
188+ Name: WriteToolName,
189+ Input: string(paramsJSON),
190+ }
191+
192+ response, err := tool.Run(context.Background(), call)
193+ require.NoError(t, err)
194+ assert.Contains(t, response.Content, "content is required")
195+ })
196+
197+ t.Run("handles writing to a directory path", func(t *testing.T) {
198+ permission.Default = newMockPermissionService(true)
199+ tool := NewWriteTool()
200+
201+ // Create a directory
202+ dirPath := filepath.Join(tempDir, "test_dir")
203+ err := os.Mkdir(dirPath, 0755)
204+ require.NoError(t, err)
205+
206+ params := WriteParams{
207+ FilePath: dirPath,
208+ Content: "Some content",
209+ }
210+
211+ paramsJSON, err := json.Marshal(params)
212+ require.NoError(t, err)
213+
214+ call := ToolCall{
215+ Name: WriteToolName,
216+ Input: string(paramsJSON),
217+ }
218+
219+ response, err := tool.Run(context.Background(), call)
220+ require.NoError(t, err)
221+ assert.Contains(t, response.Content, "Path is a directory")
222+ })
223+
224+ t.Run("handles permission denied", func(t *testing.T) {
225+ permission.Default = newMockPermissionService(false)
226+ tool := NewWriteTool()
227+
228+ filePath := filepath.Join(tempDir, "permission_denied.txt")
229+ params := WriteParams{
230+ FilePath: filePath,
231+ Content: "Content that should not be written",
232+ }
233+
234+ paramsJSON, err := json.Marshal(params)
235+ require.NoError(t, err)
236+
237+ call := ToolCall{
238+ Name: WriteToolName,
239+ Input: string(paramsJSON),
240+ }
241+
242+ response, err := tool.Run(context.Background(), call)
243+ require.NoError(t, err)
244+ assert.Contains(t, response.Content, "Permission denied")
245+
246+ // Verify file was not created
247+ _, err = os.Stat(filePath)
248+ assert.True(t, os.IsNotExist(err))
249+ })
250+
251+ t.Run("detects file modified since last read", func(t *testing.T) {
252+ permission.Default = newMockPermissionService(true)
253+ tool := NewWriteTool()
254+
255+ // Create a file
256+ filePath := filepath.Join(tempDir, "modified_file.txt")
257+ initialContent := "Initial content"
258+ err := os.WriteFile(filePath, []byte(initialContent), 0644)
259+ require.NoError(t, err)
260+
261+ // Record an old read time
262+ fileRecordMutex.Lock()
263+ fileRecords[filePath] = fileRecord{
264+ path: filePath,
265+ readTime: time.Now().Add(-1 * time.Hour),
266+ }
267+ fileRecordMutex.Unlock()
268+
269+ // Try to update the file
270+ params := WriteParams{
271+ FilePath: filePath,
272+ Content: "Updated content",
273+ }
274+
275+ paramsJSON, err := json.Marshal(params)
276+ require.NoError(t, err)
277+
278+ call := ToolCall{
279+ Name: WriteToolName,
280+ Input: string(paramsJSON),
281+ }
282+
283+ response, err := tool.Run(context.Background(), call)
284+ require.NoError(t, err)
285+ assert.Contains(t, response.Content, "has been modified since it was last read")
286+
287+ // Verify file was not modified
288+ fileContent, err := os.ReadFile(filePath)
289+ require.NoError(t, err)
290+ assert.Equal(t, initialContent, string(fileContent))
291+ })
292+
293+ t.Run("skips writing when content is identical", func(t *testing.T) {
294+ permission.Default = newMockPermissionService(true)
295+ tool := NewWriteTool()
296+
297+ // Create a file
298+ filePath := filepath.Join(tempDir, "identical_content.txt")
299+ content := "Content that won't change"
300+ err := os.WriteFile(filePath, []byte(content), 0644)
301+ require.NoError(t, err)
302+
303+ // Record a read time
304+ recordFileRead(filePath)
305+
306+ // Try to write the same content
307+ params := WriteParams{
308+ FilePath: filePath,
309+ Content: content,
310+ }
311+
312+ paramsJSON, err := json.Marshal(params)
313+ require.NoError(t, err)
314+
315+ call := ToolCall{
316+ Name: WriteToolName,
317+ Input: string(paramsJSON),
318+ }
319+
320+ response, err := tool.Run(context.Background(), call)
321+ require.NoError(t, err)
322+ assert.Contains(t, response.Content, "already contains the exact content")
323+ })
324+}
internal/message/message.go+127−31
@@ -2,26 +2,65 @@ package message
2
2
3import (
3import (
4 "context"
4 "context"
5+ "database/sql"
5 "encoding/json"
6 "encoding/json"
6
7
7− "github.com/cloudwego/eino/schema"
8 "github.com/google/uuid"
8 "github.com/google/uuid"
9 "github.com/kujtimiihoxha/termai/internal/db"
9 "github.com/kujtimiihoxha/termai/internal/db"
10 "github.com/kujtimiihoxha/termai/internal/pubsub"
10 "github.com/kujtimiihoxha/termai/internal/pubsub"
11)
11)
12
12
13+type MessageRole string
14+
15+const (
16+ Assistant MessageRole = "assistant"
17+ User MessageRole = "user"
18+ System MessageRole = "system"
19+ Tool MessageRole = "tool"
20+)
21+
22+type ToolResult struct {
23+ ToolCallID string
24+ Content string
25+ IsError bool
26+ // TODO: support for images
27+}
28+
29+type ToolCall struct {
30+ ID string
31+ Name string
32+ Input string
33+ Type string
34+}
35+
13type Message struct {
36type Message struct {
14− ID string
37+ ID string
15− SessionID string
38+ SessionID string
16− MessageData schema.Message
39+
40+ // NEW
41+ Role MessageRole
42+ Content string
43+ Thinking string
17
44
45+ Finished bool
46+
47+ ToolResults []ToolResult
48+ ToolCalls []ToolCall
18− CreatedAt int64
49+ CreatedAt int64
19− UpdatedAt int64
50+ UpdatedAt int64
51+}
52+
53+type CreateMessageParams struct {
54+ Role MessageRole
55+ Content string
56+ ToolCalls []ToolCall
57+ ToolResults []ToolResult
20}
58}
21
59
22type Service interface {
60type Service interface {
23 pubsub.Suscriber[Message]
61 pubsub.Suscriber[Message]
24− Create(sessionID string, messageData schema.Message) (Message, error)
62+ Create(sessionID string, params CreateMessageParams) (Message, error)
63+ Update(message Message) error
25 Get(id string) (Message, error)
64 Get(id string) (Message, error)
26 List(sessionID string) ([]Message, error)
65 List(sessionID string) ([]Message, error)
27 Delete(id string) error
66 Delete(id string) error
@@ -34,35 +73,46 @@ type service struct {
34 ctx context.Context
73 ctx context.Context
35}
74}
36
75
76+func (s *service) Delete(id string) error {
77+ message, err := s.Get(id)
78+ if err != nil {
79+ return err
80+ }
81+ err = s.q.DeleteMessage(s.ctx, message.ID)
82+ if err != nil {
83+ return err
84+ }
85+ s.Publish(pubsub.DeletedEvent, message)
86+ return nil
87+}
88+
37−func (s *service) Create(sessionID string, messageData schema.Message) (Message, error) {
89+func (s *service) Create(sessionID string, params CreateMessageParams) (Message, error) {
38− messageDataJSON, err := json.Marshal(messageData)
90+ toolCallsStr, err := json.Marshal(params.ToolCalls)
91+ if err != nil {
92+ return Message{}, err
93+ }
94+ toolResultsStr, err := json.Marshal(params.ToolResults)
39 if err != nil {
95 if err != nil {
40 return Message{}, err
96 return Message{}, err
41 }
97 }
42 dbMessage, err := s.q.CreateMessage(s.ctx, db.CreateMessageParams{
98 dbMessage, err := s.q.CreateMessage(s.ctx, db.CreateMessageParams{
43 ID: uuid.New().String(),
99 ID: uuid.New().String(),
44 SessionID: sessionID,
100 SessionID: sessionID,
45− MessageData: string(messageDataJSON),
101+ Role: string(params.Role),
102+ Finished: params.Role != Assistant,
103+ Content: params.Content,
104+ ToolCalls: sql.NullString{String: string(toolCallsStr), Valid: true},
105+ ToolResults: sql.NullString{String: string(toolResultsStr), Valid: true},
46 })
106 })
47 if err != nil {
107 if err != nil {
48 return Message{}, err
108 return Message{}, err
49 }
109 }
50− message := s.fromDBItem(dbMessage)
110+ message, err := s.fromDBItem(dbMessage)
51− s.Publish(pubsub.CreatedEvent, message)
52− return message, nil
53−}
54−
55−func (s *service) Delete(id string) error {
56− message, err := s.Get(id)
57− if err != nil {
58− return err
59− }
60− err = s.q.DeleteMessage(s.ctx, message.ID)
61 if err != nil {
111 if err != nil {
62− return err
112+ return Message{}, err
63 }
113 }
64− s.Publish(pubsub.DeletedEvent, message)
114+ s.Publish(pubsub.CreatedEvent, message)
65− return nil
115+ return message, nil
66}
116}
67
117
68func (s *service) DeleteSessionMessages(sessionID string) error {
118func (s *service) DeleteSessionMessages(sessionID string) error {
@@ -81,12 +131,36 @@ func (s *service) DeleteSessionMessages(sessionID string) error {
81 return nil
131 return nil
82}
132}
83
133
134+func (s *service) Update(message Message) error {
135+ toolCallsStr, err := json.Marshal(message.ToolCalls)
136+ if err != nil {
137+ return err
138+ }
139+ toolResultsStr, err := json.Marshal(message.ToolResults)
140+ if err != nil {
141+ return err
142+ }
143+ err = s.q.UpdateMessage(s.ctx, db.UpdateMessageParams{
144+ ID: message.ID,
145+ Content: message.Content,
146+ Thinking: message.Thinking,
147+ Finished: message.Finished,
148+ ToolCalls: sql.NullString{String: string(toolCallsStr), Valid: true},
149+ ToolResults: sql.NullString{String: string(toolResultsStr), Valid: true},
150+ })
151+ if err != nil {
152+ return err
153+ }
154+ s.Publish(pubsub.UpdatedEvent, message)
155+ return nil
156+}
157+
84func (s *service) Get(id string) (Message, error) {
158func (s *service) Get(id string) (Message, error) {
85 dbMessage, err := s.q.GetMessage(s.ctx, id)
159 dbMessage, err := s.q.GetMessage(s.ctx, id)
86 if err != nil {
160 if err != nil {
87 return Message{}, err
161 return Message{}, err
88 }
162 }
89− return s.fromDBItem(dbMessage), nil
163+ return s.fromDBItem(dbMessage)
90}
164}
91
165
92func (s *service) List(sessionID string) ([]Message, error) {
166func (s *service) List(sessionID string) ([]Message, error) {
@@ -96,21 +170,43 @@ func (s *service) List(sessionID string) ([]Message, error) {
96 }
170 }
97 messages := make([]Message, len(dbMessages))
171 messages := make([]Message, len(dbMessages))
98 for i, dbMessage := range dbMessages {
172 for i, dbMessage := range dbMessages {
99− messages[i] = s.fromDBItem(dbMessage)
173+ messages[i], err = s.fromDBItem(dbMessage)
174+ if err != nil {
175+ return nil, err
176+ }
100 }
177 }
101 return messages, nil
178 return messages, nil
102}
179}
103
180
104−func (s *service) fromDBItem(item db.Message) Message {
181+func (s *service) fromDBItem(item db.Message) (Message, error) {
105− var messageData schema.Message
182+ toolCalls := make([]ToolCall, 0)
183+ if item.ToolCalls.Valid {
184+ err := json.Unmarshal([]byte(item.ToolCalls.String), &toolCalls)
185+ if err != nil {
186+ return Message{}, err
187+ }
188+ }
189+
190+ toolResults := make([]ToolResult, 0)
191+ if item.ToolResults.Valid {
106− json.Unmarshal([]byte(item.MessageData), &messageData)
192+ err := json.Unmarshal([]byte(item.ToolResults.String), &toolResults)
193+ if err != nil {
194+ return Message{}, err
195+ }
196+ }
197+
107 return Message{
198 return Message{
108 ID: item.ID,
199 ID: item.ID,
109 SessionID: item.SessionID,
200 SessionID: item.SessionID,
110− MessageData: messageData,
201+ Role: MessageRole(item.Role),
202+ Content: item.Content,
203+ Thinking: item.Thinking,
204+ Finished: item.Finished,
205+ ToolCalls: toolCalls,
206+ ToolResults: toolResults,
111 CreatedAt: item.CreatedAt,
207 CreatedAt: item.CreatedAt,
112 UpdatedAt: item.UpdatedAt,
208 UpdatedAt: item.UpdatedAt,
113− }
209+ }, nil
114}
210}
115
211
116func NewService(ctx context.Context, q db.Querier) Service {
212func NewService(ctx context.Context, q db.Querier) Service {
internal/permission/permission.go+1
@@ -65,6 +65,7 @@ func (s *permissionService) Deny(permission PermissionRequest) {
65func (s *permissionService) Request(opts CreatePermissionRequest) bool {
65func (s *permissionService) Request(opts CreatePermissionRequest) bool {
66 permission := PermissionRequest{
66 permission := PermissionRequest{
67 ID: uuid.New().String(),
67 ID: uuid.New().String(),
68+ Path: opts.Path,
68 ToolName: opts.ToolName,
69 ToolName: opts.ToolName,
69 Description: opts.Description,
70 Description: opts.Description,
70 Action: opts.Action,
71 Action: opts.Action,
internal/session/session.go+18
@@ -2,6 +2,7 @@ package session
2
2
3import (
3import (
4 "context"
4 "context"
5+ "database/sql"
5
6
6 "github.com/google/uuid"
7 "github.com/google/uuid"
7 "github.com/kujtimiihoxha/termai/internal/db"
8 "github.com/kujtimiihoxha/termai/internal/db"
@@ -10,6 +11,7 @@ import (
10
11
11type Session struct {
12type Session struct {
12 ID string
13 ID string
14+ ParentSessionID string
13 Title string
15 Title string
14 MessageCount int64
16 MessageCount int64
15 PromptTokens int64
17 PromptTokens int64
@@ -22,6 +24,7 @@ type Session struct {
22type Service interface {
24type Service interface {
23 pubsub.Suscriber[Session]
25 pubsub.Suscriber[Session]
24 Create(title string) (Session, error)
26 Create(title string) (Session, error)
27+ CreateTaskSession(toolCallID, parentSessionID, title string) (Session, error)
25 Get(id string) (Session, error)
28 Get(id string) (Session, error)
26 List() ([]Session, error)
29 List() ([]Session, error)
27 Save(session Session) (Session, error)
30 Save(session Session) (Session, error)
@@ -47,6 +50,20 @@ func (s *service) Create(title string) (Session, error) {
47 return session, nil
50 return session, nil
48}
51}
49
52
53+func (s *service) CreateTaskSession(toolCallID, parentSessionID, title string) (Session, error) {
54+ dbSession, err := s.q.CreateSession(s.ctx, db.CreateSessionParams{
55+ ID: toolCallID,
56+ ParentSessionID: sql.NullString{String: parentSessionID, Valid: true},
57+ Title: title,
58+ })
59+ if err != nil {
60+ return Session{}, err
61+ }
62+ session := s.fromDBItem(dbSession)
63+ s.Publish(pubsub.CreatedEvent, session)
64+ return session, nil
65+}
66+
50func (s *service) Delete(id string) error {
67func (s *service) Delete(id string) error {
51 session, err := s.Get(id)
68 session, err := s.Get(id)
52 if err != nil {
69 if err != nil {
@@ -99,6 +116,7 @@ func (s *service) List() ([]Session, error) {
99func (s service) fromDBItem(item db.Session) Session {
116func (s service) fromDBItem(item db.Session) Session {
100 return Session{
117 return Session{
101 ID: item.ID,
118 ID: item.ID,
119+ ParentSessionID: item.ParentSessionID.String,
102 Title: item.Title,
120 Title: item.Title,
103 MessageCount: item.MessageCount,
121 MessageCount: item.MessageCount,
104 PromptTokens: item.PromptTokens,
122 PromptTokens: item.PromptTokens,
internal/tui/components/core/dialog.go+37−4
@@ -14,7 +14,12 @@ type SizeableModel interface {
14}
14}
15
15
16type DialogMsg struct {
16type DialogMsg struct {
17− Content SizeableModel
17+ Content SizeableModel
18+ WidthRatio float64
19+ HeightRatio float64
20+
21+ MinWidth int
22+ MinHeight int
18}
23}
19
24
20type DialogCloseMsg struct{}
25type DialogCloseMsg struct{}
@@ -36,7 +41,18 @@ type DialogCmp interface {
36}
41}
37
42
38type dialogCmp struct {
43type dialogCmp struct {
39− content SizeableModel
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
40}
56}
41
57
42func (d *dialogCmp) Init() tea.Cmd {
58func (d *dialogCmp) Init() tea.Cmd {
@@ -45,8 +61,26 @@ func (d *dialogCmp) Init() tea.Cmd {
45
61
46func (d *dialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
62func (d *dialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
47 switch msg := msg.(type) {
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
48 case DialogMsg:
73 case DialogMsg:
49 d.content = msg.Content
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+ }
50 case DialogCloseMsg:
84 case DialogCloseMsg:
51 d.content = nil
85 d.content = nil
52 return d, nil
86 return d, nil
@@ -75,8 +109,7 @@ func (d *dialogCmp) BindingKeys() []key.Binding {
75}
109}
76
110
77func (d *dialogCmp) View() string {
111func (d *dialogCmp) View() string {
78− w, h := d.content.GetSize()
79− return lipgloss.NewStyle().Width(w).Height(h).Render(d.content.View())
112+ return lipgloss.NewStyle().Width(d.width).Height(d.height).Render(d.content.View())
80}
113}
81
114
82func NewDialogCmp() DialogCmp {
115func NewDialogCmp() DialogCmp {
internal/tui/components/core/status.go+9−2
@@ -3,6 +3,8 @@ package core
3import (
3import (
4 tea "github.com/charmbracelet/bubbletea"
4 tea "github.com/charmbracelet/bubbletea"
5 "github.com/charmbracelet/lipgloss"
5 "github.com/charmbracelet/lipgloss"
6+ "github.com/kujtimiihoxha/termai/internal/config"
7+ "github.com/kujtimiihoxha/termai/internal/llm/models"
6 "github.com/kujtimiihoxha/termai/internal/tui/styles"
8 "github.com/kujtimiihoxha/termai/internal/tui/styles"
7 "github.com/kujtimiihoxha/termai/internal/tui/util"
9 "github.com/kujtimiihoxha/termai/internal/tui/util"
8 "github.com/kujtimiihoxha/termai/internal/version"
10 "github.com/kujtimiihoxha/termai/internal/version"
@@ -57,14 +59,19 @@ func (m statusCmp) View() string {
57 Width(m.availableFooterMsgWidth()).
59 Width(m.availableFooterMsgWidth()).
58 Render(m.info)
60 Render(m.info)
59 }
61 }
60−
62+ status += m.model()
61 status += versionWidget
63 status += versionWidget
62 return status
64 return status
63}
65}
64
66
65func (m statusCmp) availableFooterMsgWidth() int {
67func (m statusCmp) availableFooterMsgWidth() int {
66 // -2 to accommodate padding
68 // -2 to accommodate padding
67− return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(versionWidget))
69+ return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(versionWidget)-lipgloss.Width(m.model()))
70+}
71+
72+func (m statusCmp) model() string {
73+ model := models.SupportedModels[config.Get().Model.Coder]
74+ return styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render(model.Name)
68}
75}
69
76
70func NewStatusCmp() tea.Model {
77func NewStatusCmp() tea.Model {
internal/tui/components/dialog/permission.go+135−50
@@ -1,9 +1,14 @@
1package dialog
1package dialog
2
2
3import (
3import (
4+ "fmt"
5+
4 "github.com/charmbracelet/bubbles/key"
6 "github.com/charmbracelet/bubbles/key"
7+ "github.com/charmbracelet/bubbles/viewport"
5 tea "github.com/charmbracelet/bubbletea"
8 tea "github.com/charmbracelet/bubbletea"
9+ "github.com/charmbracelet/glamour"
6 "github.com/charmbracelet/lipgloss"
10 "github.com/charmbracelet/lipgloss"
11+ "github.com/kujtimiihoxha/termai/internal/llm/tools"
7 "github.com/kujtimiihoxha/termai/internal/permission"
12 "github.com/kujtimiihoxha/termai/internal/permission"
8 "github.com/kujtimiihoxha/termai/internal/tui/components/core"
13 "github.com/kujtimiihoxha/termai/internal/tui/components/core"
9 "github.com/kujtimiihoxha/termai/internal/tui/layout"
14 "github.com/kujtimiihoxha/termai/internal/tui/layout"
@@ -28,12 +33,6 @@ type PermissionResponseMsg struct {
28 Action PermissionAction
33 Action PermissionAction
29}
34}
30
35
31−// Width and height constants for the dialog
32−var (
33− permissionWidth = 60
34− permissionHeight = 10
35−)
36−
37// PermissionDialog interface for permission dialog component
36// PermissionDialog interface for permission dialog component
38type PermissionDialog interface {
37type PermissionDialog interface {
39 tea.Model
38 tea.Model
@@ -41,13 +40,28 @@ type PermissionDialog interface {
41 layout.Bindings
40 layout.Bindings
42}
41}
43
42
43+type keyMap struct {
44+ ChangeFocus key.Binding
45+}
46+
47+var keyMapValue = keyMap{
48+ ChangeFocus: key.NewBinding(
49+ key.WithKeys("tab"),
50+ key.WithHelp("tab", "change focus"),
51+ ),
52+}
53+
44// permissionDialogCmp is the implementation of PermissionDialog
54// permissionDialogCmp is the implementation of PermissionDialog
45type permissionDialogCmp struct {
55type permissionDialogCmp struct {
46− form *huh.Form
56+ form *huh.Form
47− content string
48− width int
57+ width int
49− height int
58+ height int
50− permission permission.PermissionRequest
59+ permission permission.PermissionRequest
60+ windowSize tea.WindowSizeMsg
61+ r *glamour.TermRenderer
62+ contentViewPort viewport.Model
63+ isViewportFocus bool
64+ selectOption *huh.Select[string]
51}
65}
52
66
53func (p *permissionDialogCmp) Init() tea.Cmd {
67func (p *permissionDialogCmp) Init() tea.Cmd {
@@ -57,41 +71,101 @@ func (p *permissionDialogCmp) Init() tea.Cmd {
57func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
71func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
58 var cmds []tea.Cmd
72 var cmds []tea.Cmd
59
73
60− // Process the form
74+ switch msg := msg.(type) {
61− form, cmd := p.form.Update(msg)
75+ case tea.WindowSizeMsg:
62− if f, ok := form.(*huh.Form); ok {
76+ p.windowSize = msg
63− p.form = f
77+ case tea.KeyMsg:
64− cmds = append(cmds, cmd)
78+ if key.Matches(msg, keyMapValue.ChangeFocus) {
79+ p.isViewportFocus = !p.isViewportFocus
80+ if p.isViewportFocus {
81+ p.selectOption.Blur()
82+ } else {
83+ p.selectOption.Focus()
84+ }
85+ return p, nil
86+ }
65 }
87 }
66
88
89+ if p.isViewportFocus {
90+ viewPort, cmd := p.contentViewPort.Update(msg)
91+ p.contentViewPort = viewPort
92+ cmds = append(cmds, cmd)
93+ } else {
94+ form, cmd := p.form.Update(msg)
95+ if f, ok := form.(*huh.Form); ok {
96+ p.form = f
97+ cmds = append(cmds, cmd)
98+ }
99+
67− if p.form.State == huh.StateCompleted {
100+ if p.form.State == huh.StateCompleted {
68− // Get the selected action
101+ // Get the selected action
69− action := p.form.GetString("action")
102+ action := p.form.GetString("action")
70−
103+
71− // Close the dialog and return the response
104+ // Close the dialog and return the response
72− return p, tea.Batch(
105+ return p, tea.Batch(
73− util.CmdHandler(core.DialogCloseMsg{}),
106+ util.CmdHandler(core.DialogCloseMsg{}),
74− util.CmdHandler(PermissionResponseMsg{Action: PermissionAction(action), Permission: p.permission}),
107+ util.CmdHandler(PermissionResponseMsg{Action: PermissionAction(action), Permission: p.permission}),
75− )
108+ )
109+ }
76 }
110 }
77−
78 return p, tea.Batch(cmds...)
111 return p, tea.Batch(cmds...)
79}
112}
80
113
81−func (p *permissionDialogCmp) View() string {
114+func (p *permissionDialogCmp) render() string {
82− contentStyle := lipgloss.NewStyle().
115+ form := p.form.View()
83− Width(p.width).
116+ keyStyle := lipgloss.NewStyle().Bold(true).Foreground(styles.Rosewater)
84− Padding(1, 0).
117+ valueStyle := lipgloss.NewStyle().Foreground(styles.Peach)
85− Foreground(styles.Text).
118+
86− Align(lipgloss.Center)
119+ headerParts := []string{
120+ lipgloss.JoinHorizontal(lipgloss.Left, keyStyle.Render("Tool:"), " ", valueStyle.Render(p.permission.ToolName)),
121+ " ",
122+ lipgloss.JoinHorizontal(lipgloss.Left, keyStyle.Render("Path:"), " ", valueStyle.Render(p.permission.Path)),
123+ " ",
124+ }
125+ r, _ := glamour.NewTermRenderer(
126+ glamour.WithStyles(styles.CatppuccinMarkdownStyle()),
127+ glamour.WithWordWrap(p.width-10),
128+ glamour.WithEmoji(),
129+ )
130+ content := ""
131+ switch p.permission.ToolName {
132+ case tools.BashToolName:
133+ pr := p.permission.Params.(tools.BashPermissionsParams)
134+ headerParts = append(headerParts, keyStyle.Render("Command:"))
135+ content, _ = r.Render(fmt.Sprintf("```bash\n%s\n```", pr.Command))
136+ case tools.EditToolName:
137+ pr := p.permission.Params.(tools.EditPermissionsParams)
138+ headerParts = append(headerParts, keyStyle.Render("Update:"))
139+ content, _ = r.Render(fmt.Sprintf("```diff\n%s\n```", pr.Diff))
140+ case tools.WriteToolName:
141+ pr := p.permission.Params.(tools.WritePermissionsParams)
142+ headerParts = append(headerParts, keyStyle.Render("Content:"))
143+ content, _ = r.Render(fmt.Sprintf("```diff\n%s\n```", pr.Content))
144+ default:
145+ content, _ = r.Render(p.permission.Description)
146+ }
147+ headerContent := lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
148+ p.contentViewPort.Width = p.width - 2 - 2
149+ p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
150+ p.contentViewPort.SetContent(content)
151+ contentBorder := lipgloss.RoundedBorder()
152+ if p.isViewportFocus {
153+ contentBorder = lipgloss.DoubleBorder()
154+ }
155+ cotentStyle := lipgloss.NewStyle().MarginTop(1).Padding(0, 1).Border(contentBorder).BorderForeground(styles.Flamingo)
87
156
88 return lipgloss.JoinVertical(
157 return lipgloss.JoinVertical(
89− lipgloss.Center,
158+ lipgloss.Top,
90− contentStyle.Render(p.content),
159+ headerContent,
91− p.form.View(),
160+ cotentStyle.Render(p.contentViewPort.View()),
161+ form,
92 )
162 )
93}
163}
94
164
165+func (p *permissionDialogCmp) View() string {
166+ return p.render()
167+}
168+
95func (p *permissionDialogCmp) GetSize() (int, int) {
169func (p *permissionDialogCmp) GetSize() (int, int) {
96 return p.width, p.height
170 return p.width, p.height
97}
171}
@@ -99,13 +173,14 @@ func (p *permissionDialogCmp) GetSize() (int, int) {
99func (p *permissionDialogCmp) SetSize(width int, height int) {
173func (p *permissionDialogCmp) SetSize(width int, height int) {
100 p.width = width
174 p.width = width
101 p.height = height
175 p.height = height
176+ p.form = p.form.WithWidth(width)
102}
177}
103
178
104func (p *permissionDialogCmp) BindingKeys() []key.Binding {
179func (p *permissionDialogCmp) BindingKeys() []key.Binding {
105 return p.form.KeyBinds()
180 return p.form.KeyBinds()
106}
181}
107
182
108−func newPermissionDialogCmp(permission permission.PermissionRequest, content string) PermissionDialog {
183+func newPermissionDialogCmp(permission permission.PermissionRequest) PermissionDialog {
109 // Create a note field for displaying the content
184 // Create a note field for displaying the content
110
185
111 // Create select field for the permission options
186 // Create select field for the permission options
@@ -116,14 +191,13 @@ func newPermissionDialogCmp(permission permission.PermissionRequest, content str
116 huh.NewOption("Allow for this session", string(PermissionAllowForSession)),
191 huh.NewOption("Allow for this session", string(PermissionAllowForSession)),
117 huh.NewOption("Deny", string(PermissionDeny)),
192 huh.NewOption("Deny", string(PermissionDeny)),
118 ).
193 ).
119− Title("Permission Request")
194+ Title("Select an action")
120
195
121 // Apply theme
196 // Apply theme
122 theme := styles.HuhTheme()
197 theme := styles.HuhTheme()
123
198
124 // Setup form width and height
199 // Setup form width and height
125 form := huh.NewForm(huh.NewGroup(selectOption)).
200 form := huh.NewForm(huh.NewGroup(selectOption)).
126− WithWidth(permissionWidth - 2).
127 WithShowHelp(false).
201 WithShowHelp(false).
128 WithTheme(theme).
202 WithTheme(theme).
129 WithShowErrors(false)
203 WithShowErrors(false)
@@ -132,25 +206,22 @@ func newPermissionDialogCmp(permission permission.PermissionRequest, content str
132 selectOption.Focus()
206 selectOption.Focus()
133
207
134 return &permissionDialogCmp{
208 return &permissionDialogCmp{
135− permission: permission,
209+ permission: permission,
136− form: form,
210+ form: form,
137− content: content,
211+ selectOption: selectOption,
138− width: permissionWidth,
139− height: permissionHeight,
140 }
212 }
141}
213}
142
214
143// NewPermissionDialogCmd creates a new permission dialog command
215// NewPermissionDialogCmd creates a new permission dialog command
144−func NewPermissionDialogCmd(permission permission.PermissionRequest, content string) tea.Cmd {
216+func NewPermissionDialogCmd(permission permission.PermissionRequest) tea.Cmd {
145− permDialog := newPermissionDialogCmp(permission, content)
217+ permDialog := newPermissionDialogCmp(permission)
146
218
147 // Create the dialog layout
219 // Create the dialog layout
148 dialogPane := layout.NewSinglePane(
220 dialogPane := layout.NewSinglePane(
149 permDialog.(*permissionDialogCmp),
221 permDialog.(*permissionDialogCmp),
150− layout.WithSignlePaneSize(permissionWidth+2, permissionHeight+2),
151 layout.WithSinglePaneBordered(true),
222 layout.WithSinglePaneBordered(true),
152 layout.WithSinglePaneFocusable(true),
223 layout.WithSinglePaneFocusable(true),
153− layout.WithSinglePaneActiveColor(styles.Blue),
224+ layout.WithSinglePaneActiveColor(styles.Warning),
154 layout.WithSignlePaneBorderText(map[layout.BorderPosition]string{
225 layout.WithSignlePaneBorderText(map[layout.BorderPosition]string{
155 layout.TopMiddleBorder: " Permission Required ",
226 layout.TopMiddleBorder: " Permission Required ",
156 }),
227 }),
@@ -158,10 +229,24 @@ func NewPermissionDialogCmd(permission permission.PermissionRequest, content str
158
229
159 // Focus the dialog
230 // Focus the dialog
160 dialogPane.Focus()
231 dialogPane.Focus()
161−
232+ widthRatio := 0.7
233+ heightRatio := 0.6
234+ minWidth := 100
235+ minHeight := 30
236+
237+ switch permission.ToolName {
238+ case tools.BashToolName:
239+ widthRatio = 0.5
240+ heightRatio = 0.3
241+ minWidth = 80
242+ minHeight = 20
243+ }
162 // Return the dialog command
244 // Return the dialog command
163 return util.CmdHandler(core.DialogMsg{
245 return util.CmdHandler(core.DialogMsg{
164− Content: dialogPane,
246+ Content: dialogPane,
247+ WidthRatio: widthRatio,
248+ HeightRatio: heightRatio,
249+ MinWidth: minWidth,
250+ MinHeight: minHeight,
165 })
251 })
166}
252}
167−
internal/tui/components/dialog/quit.go+9−14
@@ -3,7 +3,6 @@ package dialog
3import (
3import (
4 "github.com/charmbracelet/bubbles/key"
4 "github.com/charmbracelet/bubbles/key"
5 tea "github.com/charmbracelet/bubbletea"
5 tea "github.com/charmbracelet/bubbletea"
6− "github.com/charmbracelet/lipgloss"
7 "github.com/kujtimiihoxha/termai/internal/tui/components/core"
6 "github.com/kujtimiihoxha/termai/internal/tui/components/core"
8 "github.com/kujtimiihoxha/termai/internal/tui/layout"
7 "github.com/kujtimiihoxha/termai/internal/tui/layout"
9 "github.com/kujtimiihoxha/termai/internal/tui/styles"
8 "github.com/kujtimiihoxha/termai/internal/tui/styles"
@@ -14,11 +13,6 @@ import (
14
13
15const question = "Are you sure you want to quit?"
14const question = "Are you sure you want to quit?"
16
15
17−var (
18− width = lipgloss.Width(question) + 6
19− height = 3
20−)
21−
22type QuitDialog interface {
16type QuitDialog interface {
23 tea.Model
17 tea.Model
24 layout.Sizeable
18 layout.Sizeable
@@ -37,8 +31,6 @@ func (q *quitDialogCmp) Init() tea.Cmd {
37
31
38func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
32func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
39 var cmds []tea.Cmd
33 var cmds []tea.Cmd
40−
41− // Process the form
42 form, cmd := q.form.Update(msg)
34 form, cmd := q.form.Update(msg)
43 if f, ok := form.(*huh.Form); ok {
35 if f, ok := form.(*huh.Form); ok {
44 q.form = f
36 q.form = f
@@ -67,6 +59,7 @@ func (q *quitDialogCmp) GetSize() (int, int) {
67func (q *quitDialogCmp) SetSize(width int, height int) {
59func (q *quitDialogCmp) SetSize(width int, height int) {
68 q.width = width
60 q.width = width
69 q.height = height
61 q.height = height
62+ q.form = q.form.WithWidth(width).WithHeight(height)
70}
63}
71
64
72func (q *quitDialogCmp) BindingKeys() []key.Binding {
65func (q *quitDialogCmp) BindingKeys() []key.Binding {
@@ -84,28 +77,30 @@ func newQuitDialogCmp() QuitDialog {
84 theme.Focused.FocusedButton = theme.Focused.FocusedButton.Background(styles.Warning)
77 theme.Focused.FocusedButton = theme.Focused.FocusedButton.Background(styles.Warning)
85 theme.Blurred.FocusedButton = theme.Blurred.FocusedButton.Background(styles.Warning)
78 theme.Blurred.FocusedButton = theme.Blurred.FocusedButton.Background(styles.Warning)
86 form := huh.NewForm(huh.NewGroup(confirm)).
79 form := huh.NewForm(huh.NewGroup(confirm)).
87− WithWidth(width).
88− WithHeight(height).
89 WithShowHelp(false).
80 WithShowHelp(false).
81+ WithWidth(0).
82+ WithHeight(0).
90 WithTheme(theme).
83 WithTheme(theme).
91 WithShowErrors(false)
84 WithShowErrors(false)
92 confirm.Focus()
85 confirm.Focus()
93 return &quitDialogCmp{
86 return &quitDialogCmp{
94− form: form,
87+ form: form,
95− width: width,
96 }
88 }
97}
89}
98
90
99func NewQuitDialogCmd() tea.Cmd {
91func NewQuitDialogCmd() tea.Cmd {
100 content := layout.NewSinglePane(
92 content := layout.NewSinglePane(
101 newQuitDialogCmp().(*quitDialogCmp),
93 newQuitDialogCmp().(*quitDialogCmp),
102− layout.WithSignlePaneSize(width+2, height+2),
103 layout.WithSinglePaneBordered(true),
94 layout.WithSinglePaneBordered(true),
104 layout.WithSinglePaneFocusable(true),
95 layout.WithSinglePaneFocusable(true),
105 layout.WithSinglePaneActiveColor(styles.Warning),
96 layout.WithSinglePaneActiveColor(styles.Warning),
106 )
97 )
107 content.Focus()
98 content.Focus()
108 return util.CmdHandler(core.DialogMsg{
99 return util.CmdHandler(core.DialogMsg{
109− Content: content,
100+ Content: content,
101+ WidthRatio: 0.2,
102+ HeightRatio: 0.1,
103+ MinWidth: 40,
104+ MinHeight: 5,
110 })
105 })
111}
106}
internal/tui/components/messages/message.godeleted−108
@@ -1,108 +0,0 @@
1−package messages
2−
3−import (
4− "fmt"
5−
6− tea "github.com/charmbracelet/bubbletea"
7− "github.com/charmbracelet/lipgloss"
8− "github.com/cloudwego/eino/schema"
9− "github.com/kujtimiihoxha/termai/internal/message"
10− "github.com/kujtimiihoxha/termai/internal/tui/layout"
11− "github.com/kujtimiihoxha/termai/internal/tui/styles"
12−)
13−
14−const (
15− maxHeight = 10
16−)
17−
18−type MessagesCmp interface {
19− tea.Model
20− layout.Focusable
21− layout.Bordered
22− layout.Sizeable
23−}
24−
25−type messageCmp struct {
26− message message.Message
27− width int
28− height int
29− focused bool
30− expanded bool
31−}
32−
33−func (m *messageCmp) Init() tea.Cmd {
34− return nil
35−}
36−
37−func (m *messageCmp) Update(tea.Msg) (tea.Model, tea.Cmd) {
38− return m, nil
39−}
40−
41−func (m *messageCmp) View() string {
42− wrapper := layout.NewSinglePane(
43− m,
44− layout.WithSinglePaneBordered(true),
45− layout.WithSinglePaneFocusable(true),
46− layout.WithSinglePanePadding(1),
47− layout.WithSinglePaneActiveColor(m.borderColor()),
48− )
49− if m.focused {
50− wrapper.Focus()
51− }
52− wrapper.SetSize(m.width, m.height)
53− return wrapper.View()
54−}
55−
56−func (m *messageCmp) Blur() tea.Cmd {
57− m.focused = false
58− return nil
59−}
60−
61−func (m *messageCmp) borderColor() lipgloss.TerminalColor {
62− switch m.message.MessageData.Role {
63− case schema.Assistant:
64− return styles.Mauve
65− case schema.User:
66− return styles.Flamingo
67− }
68− return styles.Blue
69−}
70−
71−func (m *messageCmp) BorderText() map[layout.BorderPosition]string {
72− role := ""
73− icon := ""
74− switch m.message.MessageData.Role {
75− case schema.Assistant:
76− role = "Assistant"
77− icon = styles.BotIcon
78− case schema.User:
79− role = "User"
80− icon = styles.UserIcon
81− }
82− return map[layout.BorderPosition]string{
83− layout.TopLeftBorder: fmt.Sprintf("%s %s ", role, icon),
84− }
85−}
86−
87−func (m *messageCmp) Focus() tea.Cmd {
88− m.focused = true
89− return nil
90−}
91−
92−func (m *messageCmp) IsFocused() bool {
93− return m.focused
94−}
95−
96−func (m *messageCmp) GetSize() (int, int) {
97− return m.width, 0
98−}
99−
100−func (m *messageCmp) SetSize(width int, height int) {
101− m.width = width
102−}
103−
104−func NewMessageCmp(msg message.Message) MessagesCmp {
105− return &messageCmp{
106− message: msg,
107− }
108−}
internal/tui/components/repl/editor.go+16−8
@@ -6,10 +6,11 @@ import (
6 "github.com/charmbracelet/bubbles/key"
6 "github.com/charmbracelet/bubbles/key"
7 tea "github.com/charmbracelet/bubbletea"
7 tea "github.com/charmbracelet/bubbletea"
8 "github.com/charmbracelet/lipgloss"
8 "github.com/charmbracelet/lipgloss"
9− "github.com/cloudwego/eino/schema"
10 "github.com/kujtimiihoxha/termai/internal/app"
9 "github.com/kujtimiihoxha/termai/internal/app"
10+ "github.com/kujtimiihoxha/termai/internal/llm/agent"
11 "github.com/kujtimiihoxha/termai/internal/tui/layout"
11 "github.com/kujtimiihoxha/termai/internal/tui/layout"
12 "github.com/kujtimiihoxha/termai/internal/tui/styles"
12 "github.com/kujtimiihoxha/termai/internal/tui/styles"
13+ "github.com/kujtimiihoxha/termai/internal/tui/util"
13 "github.com/kujtimiihoxha/vimtea"
14 "github.com/kujtimiihoxha/vimtea"
14)
15)
15
16
@@ -112,7 +113,7 @@ func (m *editorCmp) BorderText() map[layout.BorderPosition]string {
112 title = lipgloss.NewStyle().Foreground(styles.Primary).Render(title)
113 title = lipgloss.NewStyle().Foreground(styles.Primary).Render(title)
113 }
114 }
114 return map[layout.BorderPosition]string{
115 return map[layout.BorderPosition]string{
115− layout.TopLeftBorder: title,
116+ layout.BottomLeftBorder: title,
116 }
117 }
117}
118}
118
119
@@ -137,9 +138,15 @@ func (m *editorCmp) SetSize(width int, height int) {
137
138
138func (m *editorCmp) Send() tea.Cmd {
139func (m *editorCmp) Send() tea.Cmd {
139 return func() tea.Msg {
140 return func() tea.Msg {
141+ messages, _ := m.app.Messages.List(m.sessionID)
142+ if hasUnfinishedMessages(messages) {
143+ return util.InfoMsg("Assistant is still working on the previous message")
144+ }
145+ a, _ := agent.NewCoderAgent(m.app)
146+
140 content := strings.Join(m.editor.GetBuffer().Lines(), "\n")
147 content := strings.Join(m.editor.GetBuffer().Lines(), "\n")
141− m.app.Messages.Create(m.sessionID, *schema.UserMessage(content))
148+ go a.Generate(m.sessionID, content)
142− m.app.LLM.SendRequest(m.sessionID, content)
149+
143 return m.editor.Reset()
150 return m.editor.Reset()
144 }
151 }
145}
152}
@@ -153,10 +160,11 @@ func (m *editorCmp) BindingKeys() []key.Binding {
153}
160}
154
161
155func NewEditorCmp(app *app.App) EditorCmp {
162func NewEditorCmp(app *app.App) EditorCmp {
163+ editor := vimtea.NewEditor(
164+ vimtea.WithFileName("message.md"),
165+ )
156 return &editorCmp{
166 return &editorCmp{
157− app: app,
167+ app: app,
158− editor: vimtea.NewEditor(
168+ editor: editor,
159− vimtea.WithFileName("message.md"),
160− ),
161 }
169 }
162}
170}
internal/tui/components/repl/messages.go+296−89
@@ -1,8 +1,9 @@
1package repl
1package repl
2
2
3import (
3import (
4+ "encoding/json"
4 "fmt"
5 "fmt"
5− "slices"
6+ "sort"
6 "strings"
7 "strings"
7
8
8 "github.com/charmbracelet/bubbles/key"
9 "github.com/charmbracelet/bubbles/key"
@@ -10,8 +11,8 @@ import (
10 tea "github.com/charmbracelet/bubbletea"
11 tea "github.com/charmbracelet/bubbletea"
11 "github.com/charmbracelet/glamour"
12 "github.com/charmbracelet/glamour"
12 "github.com/charmbracelet/lipgloss"
13 "github.com/charmbracelet/lipgloss"
13− "github.com/cloudwego/eino/schema"
14 "github.com/kujtimiihoxha/termai/internal/app"
14 "github.com/kujtimiihoxha/termai/internal/app"
15+ "github.com/kujtimiihoxha/termai/internal/llm/agent"
15 "github.com/kujtimiihoxha/termai/internal/message"
16 "github.com/kujtimiihoxha/termai/internal/message"
16 "github.com/kujtimiihoxha/termai/internal/pubsub"
17 "github.com/kujtimiihoxha/termai/internal/pubsub"
17 "github.com/kujtimiihoxha/termai/internal/session"
18 "github.com/kujtimiihoxha/termai/internal/session"
@@ -28,30 +29,50 @@ type MessagesCmp interface {
28}
29}
29
30
30type messagesCmp struct {
31type messagesCmp struct {
31− app *app.App
32+ app *app.App
32− messages []message.Message
33+ messages []message.Message
34+ selectedMsgIdx int // Index of the selected message
33− session session.Session
35+ session session.Session
34− viewport viewport.Model
36+ viewport viewport.Model
35− mdRenderer *glamour.TermRenderer
37+ mdRenderer *glamour.TermRenderer
36− width int
38+ width int
37− height int
39+ height int
38− focused bool
40+ focused bool
39− cachedView string
41+ cachedView string
40}
42}
41
43
42func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
44func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
43 switch msg := msg.(type) {
45 switch msg := msg.(type) {
44 case pubsub.Event[message.Message]:
46 case pubsub.Event[message.Message]:
45 if msg.Type == pubsub.CreatedEvent {
47 if msg.Type == pubsub.CreatedEvent {
48+ if msg.Payload.SessionID == m.session.ID {
46− m.messages = append(m.messages, msg.Payload)
49+ m.messages = append(m.messages, msg.Payload)
50+ m.renderView()
51+ m.viewport.GotoBottom()
52+ }
53+ for _, v := range m.messages {
54+ for _, c := range v.ToolCalls {
55+ if c.ID == msg.Payload.SessionID {
47− m.renderView()
56+ m.renderView()
57+ m.viewport.GotoBottom()
58+ }
59+ }
60+ }
61+ } else if msg.Type == pubsub.UpdatedEvent && msg.Payload.SessionID == m.session.ID {
62+ for i, v := range m.messages {
63+ if v.ID == msg.Payload.ID {
64+ m.messages[i] = msg.Payload
65+ m.renderView()
66+ if i == len(m.messages)-1 {
48− m.viewport.GotoBottom()
67+ m.viewport.GotoBottom()
68+ }
69+ break
70+ }
71+ }
49 }
72 }
50 case pubsub.Event[session.Session]:
73 case pubsub.Event[session.Session]:
51− if msg.Type == pubsub.UpdatedEvent {
74+ if msg.Type == pubsub.UpdatedEvent && m.session.ID == msg.Payload.ID {
52− if m.session.ID == msg.Payload.ID {
53− m.session = msg.Payload
75+ m.session = msg.Payload
54− }
55 }
76 }
56 case SelectedSessionMsg:
77 case SelectedSessionMsg:
57 m.session, _ = m.app.Sessions.Get(msg.SessionID)
78 m.session, _ = m.app.Sessions.Get(msg.SessionID)
@@ -67,26 +88,24 @@ func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
67 return m, nil
88 return m, nil
68}
89}
69
90
70−func borderColor(role schema.RoleType) lipgloss.TerminalColor {
91+func borderColor(role message.MessageRole) lipgloss.TerminalColor {
71 switch role {
92 switch role {
72− case schema.Assistant:
93+ case message.Assistant:
73 return styles.Mauve
94 return styles.Mauve
74− case schema.User:
95+ case message.User:
75 return styles.Rosewater
96 return styles.Rosewater
76− case schema.Tool:
77− return styles.Peach
78 }
97 }
79 return styles.Blue
98 return styles.Blue
80}
99}
81
100
82−func borderText(msgRole schema.RoleType, currentMessage int) map[layout.BorderPosition]string {
101+func borderText(msgRole message.MessageRole, currentMessage int) map[layout.BorderPosition]string {
83 role := ""
102 role := ""
84 icon := ""
103 icon := ""
85 switch msgRole {
104 switch msgRole {
86− case schema.Assistant:
105+ case message.Assistant:
87 role = "Assistant"
106 role = "Assistant"
88 icon = styles.BotIcon
107 icon = styles.BotIcon
89− case schema.User:
108+ case message.User:
90 role = "User"
109 role = "User"
91 icon = styles.UserIcon
110 icon = styles.UserIcon
92 }
111 }
@@ -106,81 +125,259 @@ func borderText(msgRole schema.RoleType, currentMessage int) map[layout.BorderPo
106 }
125 }
107}
126}
108
127
128+func hasUnfinishedMessages(messages []message.Message) bool {
129+ if len(messages) == 0 {
130+ return false
131+ }
132+ for _, msg := range messages {
133+ if !msg.Finished {
134+ return true
135+ }
136+ }
137+ lastMessage := messages[len(messages)-1]
138+ return lastMessage.Role != message.Assistant
139+}
140+
141+func (m *messagesCmp) renderMessageWithToolCall(content string, tools []message.ToolCall, futureMessages []message.Message) string {
142+ allParts := []string{content}
143+
144+ leftPaddingValue := 4
145+ connectorStyle := lipgloss.NewStyle().
146+ Foreground(styles.Peach).
147+ Bold(true)
148+
149+ toolCallStyle := lipgloss.NewStyle().
150+ Border(lipgloss.RoundedBorder()).
151+ BorderForeground(styles.Peach).
152+ Width(m.width-leftPaddingValue-5).
153+ Padding(0, 1)
154+
155+ toolResultStyle := lipgloss.NewStyle().
156+ Border(lipgloss.RoundedBorder()).
157+ BorderForeground(styles.Green).
158+ Width(m.width-leftPaddingValue-5).
159+ Padding(0, 1)
160+
161+ leftPadding := lipgloss.NewStyle().Padding(0, 0, 0, leftPaddingValue)
162+
163+ runningStyle := lipgloss.NewStyle().
164+ Foreground(styles.Peach).
165+ Bold(true)
166+
167+ renderTool := func(toolCall message.ToolCall) string {
168+ toolHeader := lipgloss.NewStyle().
169+ Bold(true).
170+ Foreground(styles.Blue).
171+ Render(fmt.Sprintf("%s %s", styles.ToolIcon, toolCall.Name))
172+
173+ var paramLines []string
174+ var args map[string]interface{}
175+ var paramOrder []string
176+
177+ json.Unmarshal([]byte(toolCall.Input), &args)
178+
179+ for key := range args {
180+ paramOrder = append(paramOrder, key)
181+ }
182+ sort.Strings(paramOrder)
183+
184+ for _, name := range paramOrder {
185+ value := args[name]
186+ paramName := lipgloss.NewStyle().
187+ Foreground(styles.Peach).
188+ Bold(true).
189+ Render(name)
190+
191+ truncate := m.width - leftPaddingValue*2 - 10
192+ if len(fmt.Sprintf("%v", value)) > truncate {
193+ value = fmt.Sprintf("%v", value)[:truncate] + lipgloss.NewStyle().Foreground(styles.Blue).Render("... (truncated)")
194+ }
195+ paramValue := fmt.Sprintf("%v", value)
196+ paramLines = append(paramLines, fmt.Sprintf(" %s: %s", paramName, paramValue))
197+ }
198+
199+ paramBlock := lipgloss.JoinVertical(lipgloss.Left, paramLines...)
200+
201+ toolContent := lipgloss.JoinVertical(lipgloss.Left, toolHeader, paramBlock)
202+ return toolCallStyle.Render(toolContent)
203+ }
204+
205+ findToolResult := func(toolCallID string, messages []message.Message) *message.ToolResult {
206+ for _, msg := range messages {
207+ if msg.Role == message.Tool {
208+ for _, result := range msg.ToolResults {
209+ if result.ToolCallID == toolCallID {
210+ return &result
211+ }
212+ }
213+ }
214+ }
215+ return nil
216+ }
217+
218+ renderToolResult := func(result message.ToolResult) string {
219+ resultHeader := lipgloss.NewStyle().
220+ Bold(true).
221+ Foreground(styles.Green).
222+ Render(fmt.Sprintf("%s Result", styles.CheckIcon))
223+ if result.IsError {
224+ resultHeader = lipgloss.NewStyle().
225+ Bold(true).
226+ Foreground(styles.Red).
227+ Render(fmt.Sprintf("%s Error", styles.ErrorIcon))
228+ }
229+
230+ truncate := 200
231+ content := result.Content
232+ if len(content) > truncate {
233+ content = content[:truncate] + lipgloss.NewStyle().Foreground(styles.Blue).Render("... (truncated)")
234+ }
235+
236+ resultContent := lipgloss.JoinVertical(lipgloss.Left, resultHeader, content)
237+ return toolResultStyle.Render(resultContent)
238+ }
239+
240+ connector := connectorStyle.Render("└─> Tool Calls:")
241+ allParts = append(allParts, connector)
242+
243+ for _, toolCall := range tools {
244+ toolOutput := renderTool(toolCall)
245+ allParts = append(allParts, leftPadding.Render(toolOutput))
246+
247+ result := findToolResult(toolCall.ID, futureMessages)
248+ if result != nil {
249+
250+ resultOutput := renderToolResult(*result)
251+ allParts = append(allParts, leftPadding.Render(resultOutput))
252+
253+ } else if toolCall.Name == agent.AgentToolName {
254+
255+ runningIndicator := runningStyle.Render(fmt.Sprintf("%s Running...", styles.SpinnerIcon))
256+ allParts = append(allParts, leftPadding.Render(runningIndicator))
257+ taskSessionMessages, _ := m.app.Messages.List(toolCall.ID)
258+ for _, msg := range taskSessionMessages {
259+ if msg.Role == message.Assistant {
260+ for _, toolCall := range msg.ToolCalls {
261+ toolHeader := lipgloss.NewStyle().
262+ Bold(true).
263+ Foreground(styles.Blue).
264+ Render(fmt.Sprintf("%s %s", styles.ToolIcon, toolCall.Name))
265+
266+ var paramLines []string
267+ var args map[string]interface{}
268+ var paramOrder []string
269+
270+ json.Unmarshal([]byte(toolCall.Input), &args)
271+
272+ for key := range args {
273+ paramOrder = append(paramOrder, key)
274+ }
275+ sort.Strings(paramOrder)
276+
277+ for _, name := range paramOrder {
278+ value := args[name]
279+ paramName := lipgloss.NewStyle().
280+ Foreground(styles.Peach).
281+ Bold(true).
282+ Render(name)
283+
284+ truncate := 50
285+ if len(fmt.Sprintf("%v", value)) > truncate {
286+ value = fmt.Sprintf("%v", value)[:truncate] + lipgloss.NewStyle().Foreground(styles.Blue).Render("... (truncated)")
287+ }
288+ paramValue := fmt.Sprintf("%v", value)
289+ paramLines = append(paramLines, fmt.Sprintf(" %s: %s", paramName, paramValue))
290+ }
291+
292+ paramBlock := lipgloss.JoinVertical(lipgloss.Left, paramLines...)
293+ toolContent := lipgloss.JoinVertical(lipgloss.Left, toolHeader, paramBlock)
294+ toolOutput := toolCallStyle.BorderForeground(styles.Teal).MaxWidth(m.width - leftPaddingValue*2 - 2).Render(toolContent)
295+ allParts = append(allParts, lipgloss.NewStyle().Padding(0, 0, 0, leftPaddingValue*2).Render(toolOutput))
296+ }
297+ }
298+ }
299+
300+ } else {
301+ runningIndicator := runningStyle.Render(fmt.Sprintf("%s Running...", styles.SpinnerIcon))
302+ allParts = append(allParts, " "+runningIndicator)
303+ }
304+ }
305+
306+ for _, msg := range futureMessages {
307+ if msg.Content != "" {
308+ break
309+ }
310+
311+ for _, toolCall := range msg.ToolCalls {
312+ toolOutput := renderTool(toolCall)
313+ allParts = append(allParts, " "+strings.ReplaceAll(toolOutput, "\n", "\n "))
314+
315+ result := findToolResult(toolCall.ID, futureMessages)
316+ if result != nil {
317+ resultOutput := renderToolResult(*result)
318+ allParts = append(allParts, " "+strings.ReplaceAll(resultOutput, "\n", "\n "))
319+ } else {
320+ runningIndicator := runningStyle.Render(fmt.Sprintf("%s Running...", styles.SpinnerIcon))
321+ allParts = append(allParts, " "+runningIndicator)
322+ }
323+ }
324+ }
325+
326+ return lipgloss.JoinVertical(lipgloss.Left, allParts...)
327+}
328+
109func (m *messagesCmp) renderView() {
329func (m *messagesCmp) renderView() {
110 stringMessages := make([]string, 0)
330 stringMessages := make([]string, 0)
111 r, _ := glamour.NewTermRenderer(
331 r, _ := glamour.NewTermRenderer(
112 glamour.WithStyles(styles.CatppuccinMarkdownStyle()),
332 glamour.WithStyles(styles.CatppuccinMarkdownStyle()),
113− glamour.WithWordWrap(m.width-10),
333+ glamour.WithWordWrap(m.width-20),
114 glamour.WithEmoji(),
334 glamour.WithEmoji(),
115 )
335 )
116 textStyle := lipgloss.NewStyle().Width(m.width - 4)
336 textStyle := lipgloss.NewStyle().Width(m.width - 4)
117 currentMessage := 1
337 currentMessage := 1
118− for _, msg := range m.messages {
338+ displayedMsgCount := 0 // Track the actual displayed messages count
119− if msg.MessageData.Role == schema.Tool {
339+
120− continue
340+ prevMessageWasUser := false
121− }
341+ for inx, msg := range m.messages {
122− content := msg.MessageData.Content
342+ content := msg.Content
123− if content != "" {
343+ if content != "" || prevMessageWasUser {
124− content, _ = r.Render(msg.MessageData.Content)
344+ if msg.Thinking != "" && content == "" {
125− stringMessages = append(stringMessages, layout.Borderize(
126− textStyle.Render(content),
127− layout.BorderOptions{
128− InactiveBorder: lipgloss.DoubleBorder(),
129− ActiveBorder: lipgloss.DoubleBorder(),
130− ActiveColor: borderColor(msg.MessageData.Role),
131− InactiveColor: borderColor(msg.MessageData.Role),
132− EmbeddedText: borderText(msg.MessageData.Role, currentMessage),
133− },
134− ))
135− currentMessage++
136− }
137− for _, toolCall := range msg.MessageData.ToolCalls {
138− resultInx := slices.IndexFunc(m.messages, func(m message.Message) bool {
139− return m.MessageData.ToolCallID == toolCall.ID
140− })
141− content := fmt.Sprintf("**Arguments**\n```json\n%s\n```\n", toolCall.Function.Arguments)
142− if resultInx == -1 {
143− content += "Running..."
345+ content = msg.Thinking
144− } else {
346+ } else if content == "" {
145− result := m.messages[resultInx].MessageData.Content
347+ content = "..."
146− if result != "" {
147− lines := strings.Split(result, "\n")
148− if len(lines) > 15 {
149− result = strings.Join(lines[:15], "\n")
150− }
151− content += fmt.Sprintf("**Result**\n```\n%s\n```\n", result)
152− if len(lines) > 15 {
153− content += fmt.Sprintf("\n\n *...%d lines are truncated* ", len(lines)-15)
154− }
155− }
156 }
348 }
157 content, _ = r.Render(content)
349 content, _ = r.Render(content)
350+
351+ isSelected := inx == m.selectedMsgIdx
352+
353+ border := lipgloss.DoubleBorder()
354+ activeColor := borderColor(msg.Role)
355+
356+ if isSelected {
357+ activeColor = styles.Primary // Use primary color for selected message
358+ }
359+
158− stringMessages = append(stringMessages, layout.Borderize(
360+ content = layout.Borderize(
159 textStyle.Render(content),
361 textStyle.Render(content),
160 layout.BorderOptions{
362 layout.BorderOptions{
161− InactiveBorder: lipgloss.DoubleBorder(),
363+ InactiveBorder: border,
162− ActiveBorder: lipgloss.DoubleBorder(),
364+ ActiveBorder: border,
163− ActiveColor: borderColor(schema.Tool),
365+ ActiveColor: activeColor,
164− InactiveColor: borderColor(schema.Tool),
366+ InactiveColor: borderColor(msg.Role),
165− EmbeddedText: map[layout.BorderPosition]string{
367+ EmbeddedText: borderText(msg.Role, currentMessage),
166− layout.TopLeftBorder: lipgloss.NewStyle().
167− Padding(0, 1).
168− Bold(true).
169− Foreground(styles.Crust).
170− Background(borderColor(schema.Tool)).
171− Render(
172− fmt.Sprintf("Tool [%s] %s ", toolCall.Function.Name, styles.ToolIcon),
173− ),
174− layout.TopRightBorder: lipgloss.NewStyle().
175− Padding(0, 1).
176− Bold(true).
177− Foreground(styles.Crust).
178− Background(borderColor(schema.Tool)).
179− Render(fmt.Sprintf("#%d ", currentMessage)),
180− },
181 },
368 },
182− ))
369+ )
370+ if len(msg.ToolCalls) > 0 {
371+ content = m.renderMessageWithToolCall(content, msg.ToolCalls, m.messages[inx+1:])
372+ }
373+ stringMessages = append(stringMessages, content)
183 currentMessage++
374 currentMessage++
375+ displayedMsgCount++
376+ }
377+ if msg.Role == message.User && msg.Content != "" {
378+ prevMessageWasUser = true
379+ } else {
380+ prevMessageWasUser = false
184 }
381 }
185 }
382 }
186 m.viewport.SetContent(lipgloss.JoinVertical(lipgloss.Top, stringMessages...))
383 m.viewport.SetContent(lipgloss.JoinVertical(lipgloss.Top, stringMessages...))
@@ -191,7 +388,9 @@ func (m *messagesCmp) View() string {
191}
388}
192
389
193func (m *messagesCmp) BindingKeys() []key.Binding {
390func (m *messagesCmp) BindingKeys() []key.Binding {
194− return layout.KeyMapToSlice(m.viewport.KeyMap)
391+ keys := layout.KeyMapToSlice(m.viewport.KeyMap)
392+
393+ return keys
195}
394}
196
395
197func (m *messagesCmp) Blur() tea.Cmd {
396func (m *messagesCmp) Blur() tea.Cmd {
@@ -208,10 +407,17 @@ func (m *messagesCmp) BorderText() map[layout.BorderPosition]string {
208 if m.focused {
407 if m.focused {
209 title = lipgloss.NewStyle().Foreground(styles.Primary).Render(title)
408 title = lipgloss.NewStyle().Foreground(styles.Primary).Render(title)
210 }
409 }
211− return map[layout.BorderPosition]string{
410+ borderTest := map[layout.BorderPosition]string{
212 layout.TopLeftBorder: title,
411 layout.TopLeftBorder: title,
213 layout.BottomRightBorder: formatTokensAndCost(m.session.CompletionTokens+m.session.PromptTokens, m.session.Cost),
412 layout.BottomRightBorder: formatTokensAndCost(m.session.CompletionTokens+m.session.PromptTokens, m.session.Cost),
214 }
413 }
414+ if hasUnfinishedMessages(m.messages) {
415+ borderTest[layout.BottomLeftBorder] = lipgloss.NewStyle().Foreground(styles.Peach).Render("Thinking...")
416+ } else {
417+ borderTest[layout.BottomLeftBorder] = lipgloss.NewStyle().Foreground(styles.Text).Render("Sleeping " + styles.SleepIcon + " ")
418+ }
419+
420+ return borderTest
215}
421}
216
422
217func (m *messagesCmp) Focus() tea.Cmd {
423func (m *messagesCmp) Focus() tea.Cmd {
@@ -232,6 +438,7 @@ func (m *messagesCmp) SetSize(width int, height int) {
232 m.height = height
438 m.height = height
233 m.viewport.Width = width - 2 // padding
439 m.viewport.Width = width - 2 // padding
234 m.viewport.Height = height - 2 // padding
440 m.viewport.Height = height - 2 // padding
441+ m.renderView()
235}
442}
236
443
237func (m *messagesCmp) Init() tea.Cmd {
444func (m *messagesCmp) Init() tea.Cmd {
internal/tui/components/repl/sessions.go+18−1
@@ -89,7 +89,23 @@ func (i *sessionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
89 }
89 }
90 return i, i.list.SetItems(items)
90 return i, i.list.SetItems(items)
91 case pubsub.Event[session.Session]:
91 case pubsub.Event[session.Session]:
92+ if msg.Type == pubsub.CreatedEvent && msg.Payload.ParentSessionID == "" {
93+ // Check if the session is already in the list
94+ items := i.list.Items()
95+ for _, item := range items {
96+ s := item.(listItem)
97+ if s.id == msg.Payload.ID {
98+ return i, nil
99+ }
100+ }
101+ // insert the new session at the top of the list
102+ items = append([]list.Item{listItem{
103+ id: msg.Payload.ID,
104+ title: msg.Payload.Title,
105+ desc: formatTokensAndCost(msg.Payload.PromptTokens+msg.Payload.CompletionTokens, msg.Payload.Cost),
106+ }}, items...)
107+ return i, i.list.SetItems(items)
92− if msg.Type == pubsub.UpdatedEvent {
108+ } else if msg.Type == pubsub.UpdatedEvent {
93 // update the session in the list
109 // update the session in the list
94 items := i.list.Items()
110 items := i.list.Items()
95 for idx, item := range items {
111 for idx, item := range items {
@@ -229,3 +245,4 @@ func NewSessionsCmp(app *app.App) SessionsCmp {
229 focused: false,
245 focused: false,
230 }
246 }
231}
247}
248+
internal/tui/page/init.go+4−4
@@ -78,12 +78,12 @@ func (i *initPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
78 // Save configuration to file
78 // Save configuration to file
79 configPath := filepath.Join(os.Getenv("HOME"), ".termai.yaml")
79 configPath := filepath.Join(os.Getenv("HOME"), ".termai.yaml")
80 maxTokens, _ := strconv.Atoi(i.maxTokens)
80 maxTokens, _ := strconv.Atoi(i.maxTokens)
81− config := map[string]interface{}{
81+ config := map[string]any{
82 "models": map[string]string{
82 "models": map[string]string{
83 "big": i.bigModel,
83 "big": i.bigModel,
84 "small": i.smallModel,
84 "small": i.smallModel,
85 },
85 },
86− "providers": map[string]interface{}{
86+ "providers": map[string]any{
87 "openai": map[string]string{
87 "openai": map[string]string{
88 "key": i.openAIKey,
88 "key": i.openAIKey,
89 },
89 },
@@ -192,8 +192,8 @@ func NewInitPage() tea.Model {
192 // Init page with form
192 // Init page with form
193 initModel := &initPage{
193 initModel := &initPage{
194 modelOpts: modelOpts,
194 modelOpts: modelOpts,
195− bigModel: string(models.DefaultBigModel),
195+ bigModel: string(models.Claude37Sonnet),
196− smallModel: string(models.DefaultLittleModel),
196+ smallModel: string(models.Claude37Sonnet),
197 maxTokens: "4000",
197 maxTokens: "4000",
198 dataDir: ".termai",
198 dataDir: ".termai",
199 agent: "coder",
199 agent: "coder",
internal/tui/styles/icons.go+4
@@ -8,5 +8,9 @@ const (
8 ToolIcon string = ""
8 ToolIcon string = ""
9 UserIcon string = ""
9 UserIcon string = ""
10
10
11+ CheckIcon string = "✓"
12+ ErrorIcon string = "✗"
13+ SpinnerIcon string = "..."
14+
11 SleepIcon string = "󰒲"
15 SleepIcon string = "󰒲"
12)
16)
internal/tui/styles/markdown.go+3−3
@@ -5,7 +5,7 @@ import (
5 "github.com/charmbracelet/lipgloss"
5 "github.com/charmbracelet/lipgloss"
6)
6)
7
7
8−const defaultMargin = 2
8+const defaultMargin = 1
9
9
10// Helper functions for style pointers
10// Helper functions for style pointers
11func boolPtr(b bool) *bool { return &b }
11func boolPtr(b bool) *bool { return &b }
@@ -25,7 +25,7 @@ var catppuccinDark = ansi.StyleConfig{
25 Document: ansi.StyleBlock{
25 Document: ansi.StyleBlock{
26 StylePrimitive: ansi.StylePrimitive{
26 StylePrimitive: ansi.StylePrimitive{
27 BlockPrefix: "\n",
27 BlockPrefix: "\n",
28− BlockSuffix: "\n",
28+ BlockSuffix: "",
29 Color: stringPtr(dark.Text().Hex),
29 Color: stringPtr(dark.Text().Hex),
30 },
30 },
31 Margin: uintPtr(defaultMargin),
31 Margin: uintPtr(defaultMargin),
@@ -153,7 +153,7 @@ var catppuccinDark = ansi.StyleConfig{
153 CodeBlock: ansi.StyleCodeBlock{
153 CodeBlock: ansi.StyleCodeBlock{
154 StyleBlock: ansi.StyleBlock{
154 StyleBlock: ansi.StyleBlock{
155 StylePrimitive: ansi.StylePrimitive{
155 StylePrimitive: ansi.StylePrimitive{
156− Prefix: " ",
156+ Prefix: " ",
157 Color: stringPtr(dark.Text().Hex),
157 Color: stringPtr(dark.Text().Hex),
158 },
158 },
159
159
internal/tui/styles/styles.go+1−2
@@ -20,8 +20,7 @@ var (
20 DoubleBorder = Regular.Border(lipgloss.DoubleBorder())
20 DoubleBorder = Regular.Border(lipgloss.DoubleBorder())
21
21
22 // Colors
22 // Colors
23− White = lipgloss.Color("#ffffff")
23+ White = lipgloss.Color("#ffffff")
24−
25 Surface0 = lipgloss.AdaptiveColor{
24 Surface0 = lipgloss.AdaptiveColor{
26 Dark: dark.Surface0().Hex,
25 Dark: dark.Surface0().Hex,
27 Light: light.Surface0().Hex,
26 Light: light.Surface0().Hex,
internal/tui/tui.go+40−29
@@ -1,20 +1,15 @@
1package tui
1package tui
2
2
3import (
3import (
4− "fmt"
5− "log"
6− "os"
7− "path/filepath"
8−
9 "github.com/charmbracelet/bubbles/key"
4 "github.com/charmbracelet/bubbles/key"
10 tea "github.com/charmbracelet/bubbletea"
5 tea "github.com/charmbracelet/bubbletea"
11 "github.com/charmbracelet/lipgloss"
6 "github.com/charmbracelet/lipgloss"
12 "github.com/kujtimiihoxha/termai/internal/app"
7 "github.com/kujtimiihoxha/termai/internal/app"
13− "github.com/kujtimiihoxha/termai/internal/llm"
14 "github.com/kujtimiihoxha/termai/internal/permission"
8 "github.com/kujtimiihoxha/termai/internal/permission"
15 "github.com/kujtimiihoxha/termai/internal/pubsub"
9 "github.com/kujtimiihoxha/termai/internal/pubsub"
16 "github.com/kujtimiihoxha/termai/internal/tui/components/core"
10 "github.com/kujtimiihoxha/termai/internal/tui/components/core"
17 "github.com/kujtimiihoxha/termai/internal/tui/components/dialog"
11 "github.com/kujtimiihoxha/termai/internal/tui/components/dialog"
12+ "github.com/kujtimiihoxha/termai/internal/tui/components/repl"
18 "github.com/kujtimiihoxha/termai/internal/tui/layout"
13 "github.com/kujtimiihoxha/termai/internal/tui/layout"
19 "github.com/kujtimiihoxha/termai/internal/tui/page"
14 "github.com/kujtimiihoxha/termai/internal/tui/page"
20 "github.com/kujtimiihoxha/termai/internal/tui/util"
15 "github.com/kujtimiihoxha/termai/internal/tui/util"
@@ -52,9 +47,9 @@ var keys = keyMap{
52 ),
47 ),
53}
48}
54
49
55−var editorKeyMap = key.NewBinding(
50+var replKeyMap = key.NewBinding(
56− key.WithKeys("i"),
51+ key.WithKeys("N"),
57− key.WithHelp("i", "insert mode"),
52+ key.WithHelp("N", "new session"),
58)
53)
59
54
60type appModel struct {
55type appModel struct {
@@ -66,6 +61,7 @@ type appModel struct {
66 status tea.Model
61 status tea.Model
67 help core.HelpCmp
62 help core.HelpCmp
68 dialog core.DialogCmp
63 dialog core.DialogCmp
64+ app *app.App
69 dialogVisible bool
65 dialogVisible bool
70 editorMode vimtea.EditorMode
66 editorMode vimtea.EditorMode
71 showHelp bool
67 showHelp bool
@@ -79,19 +75,8 @@ func (a appModel) Init() tea.Cmd {
79
75
80func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
76func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
81 switch msg := msg.(type) {
77 switch msg := msg.(type) {
82− case pubsub.Event[llm.AgentEvent]:
83− log.Println("AgentEvent")
84− log.Println(msg)
85 case pubsub.Event[permission.PermissionRequest]:
78 case pubsub.Event[permission.PermissionRequest]:
86− return a, dialog.NewPermissionDialogCmd(
79+ return a, dialog.NewPermissionDialogCmd(msg.Payload)
87− msg.Payload,
88− fmt.Sprintf(
89− "Tool: %s\nAction: %s\nParams: %v",
90− msg.Payload.ToolName,
91− msg.Payload.Action,
92− msg.Payload.Params,
93− ),
94− )
95 case dialog.PermissionResponseMsg:
80 case dialog.PermissionResponseMsg:
96 switch msg.Action {
81 switch msg.Action {
97 case dialog.PermissionAllow:
82 case dialog.PermissionAllow:
@@ -104,6 +89,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
104 case vimtea.EditorModeMsg:
89 case vimtea.EditorModeMsg:
105 a.editorMode = msg.Mode
90 a.editorMode = msg.Mode
106 case tea.WindowSizeMsg:
91 case tea.WindowSizeMsg:
92+ var cmds []tea.Cmd
107 msg.Height -= 1 // Make space for the status bar
93 msg.Height -= 1 // Make space for the status bar
108 a.width, a.height = msg.Width, msg.Height
94 a.width, a.height = msg.Width, msg.Height
109
95
@@ -113,8 +99,14 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
113 a.help = uh.(core.HelpCmp)
99 a.help = uh.(core.HelpCmp)
114
100
115 p, cmd := a.pages[a.currentPage].Update(msg)
101 p, cmd := a.pages[a.currentPage].Update(msg)
102+ cmds = append(cmds, cmd)
116 a.pages[a.currentPage] = p
103 a.pages[a.currentPage] = p
104+
105+ d, cmd := a.dialog.Update(msg)
106+ cmds = append(cmds, cmd)
107+ a.dialog = d.(core.DialogCmp)
108+
117− return a, cmd
109+ return a, tea.Batch(cmds...)
118 case core.DialogMsg:
110 case core.DialogMsg:
119 d, cmd := a.dialog.Update(msg)
111 d, cmd := a.dialog.Update(msg)
120 a.dialog = d.(core.DialogCmp)
112 a.dialog = d.(core.DialogCmp)
@@ -145,6 +137,22 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
145 a.ToggleHelp()
137 a.ToggleHelp()
146 return a, nil
138 return a, nil
147 }
139 }
140+ case key.Matches(msg, replKeyMap):
141+ if a.currentPage == page.ReplPage {
142+ sessions, err := a.app.Sessions.List()
143+ if err != nil {
144+ return a, util.CmdHandler(util.ErrorMsg(err))
145+ }
146+ lastSession := sessions[0]
147+ if lastSession.MessageCount == 0 {
148+ return a, util.CmdHandler(repl.SelectedSessionMsg{SessionID: lastSession.ID})
149+ }
150+ s, err := a.app.Sessions.Create("New Session")
151+ if err != nil {
152+ return a, util.CmdHandler(util.ErrorMsg(err))
153+ }
154+ return a, util.CmdHandler(repl.SelectedSessionMsg{SessionID: s.ID})
155+ }
148 case key.Matches(msg, keys.Logs):
156 case key.Matches(msg, keys.Logs):
149 return a, a.moveToPage(page.LogsPage)
157 return a, a.moveToPage(page.LogsPage)
150 case key.Matches(msg, keys.Help):
158 case key.Matches(msg, keys.Help):
@@ -205,6 +213,9 @@ func (a appModel) View() string {
205 if a.dialogVisible {
213 if a.dialogVisible {
206 bindings = append(bindings, a.dialog.BindingKeys()...)
214 bindings = append(bindings, a.dialog.BindingKeys()...)
207 }
215 }
216+ if a.currentPage == page.ReplPage {
217+ bindings = append(bindings, replKeyMap)
218+ }
208 a.help.SetBindings(bindings)
219 a.help.SetBindings(bindings)
209 components = append(components, a.help.View())
220 components = append(components, a.help.View())
210 }
221 }
@@ -231,14 +242,13 @@ func (a appModel) View() string {
231}
242}
232
243
233func New(app *app.App) tea.Model {
244func New(app *app.App) tea.Model {
234− // Check if config file exists, if not, start with init page
235− homedir, _ := os.UserHomeDir()
245+ // homedir, _ := os.UserHomeDir()
236− configPath := filepath.Join(homedir, ".termai.yaml")
246+ // configPath := filepath.Join(homedir, ".termai.yaml")
237−
247+ //
238 startPage := page.ReplPage
248 startPage := page.ReplPage
239− if _, err := os.Stat(configPath); os.IsNotExist(err) {
249+ // if _, err := os.Stat(configPath); os.IsNotExist(err) {
240− startPage = page.InitPage
250+ // startPage = page.InitPage
241− }
251+ // }
242
252
243 return &appModel{
253 return &appModel{
244 currentPage: startPage,
254 currentPage: startPage,
@@ -246,6 +256,7 @@ func New(app *app.App) tea.Model {
246 status: core.NewStatusCmp(),
256 status: core.NewStatusCmp(),
247 help: core.NewHelpCmp(),
257 help: core.NewHelpCmp(),
248 dialog: core.NewDialogCmp(),
258 dialog: core.NewDialogCmp(),
259+ app: app,
249 pages: map[page.PageID]tea.Model{
260 pages: map[page.PageID]tea.Model{
250 page.LogsPage: page.NewLogsPage(),
261 page.LogsPage: page.NewLogsPage(),
251 page.InitPage: page.NewInitPage(),
262 page.InitPage: page.NewInitPage(),