on main help sign in
rework llm
commitafd9ad0560d76c2a6d161dad52553b10ff428905 authorKujtim Hoxha <kujtimii.h@gmail.com> dateThu 27 Mar 2025, 22:35 (2 years ago) parent904061c
61 files changed+5864−2056
.gitignore+2 README.md+94−7 cmd/root.go+7−49 go.mod+39−47 go.sum+73−169 internal/app/services.go+3−6 internal/config/config.go+180 internal/config/config_test.go+465 internal/db/connect.go+2−2 internal/db/db.go+10 internal/db/messages.sql.go+77−12 internal/db/migrations/000001_initial.up.sql+7−1 internal/db/models.go+23−13 internal/db/querier.go+1 internal/db/sessions.sql.go+20−10 internal/db/sql/messages.sql+17−2 internal/db/sql/sessions.sql+3 internal/llm/agent/agent-tool.go+102 internal/llm/agent/agent.go+344−7 internal/llm/agent/coder.go+48−163 internal/llm/agent/mcp-tools.go+190 internal/llm/agent/task.go+44 internal/llm/agent/title.go−31 internal/llm/llm.go−229 internal/llm/models/models.go+83−191 internal/llm/prompt/coder.go+206 internal/llm/prompt/task.go+16 internal/llm/prompt/title.go+9 internal/llm/provider/anthropic.go+309 internal/llm/provider/gemini.go+443 internal/llm/provider/openai.go+278 internal/llm/provider/provider.go+48 internal/llm/tools/agent.go−141 internal/llm/tools/bash.go+36−54 internal/llm/tools/bash_test.go+389 internal/llm/tools/edit.go+149−198 internal/llm/tools/glob.go+67−35 internal/llm/tools/grep.go+127−74 internal/llm/tools/ls.go+198−107 internal/llm/tools/ls_test.go+457 internal/llm/tools/shell/shell.go+4−4 internal/llm/tools/tools.go+49 internal/llm/tools/view.go+112−64 internal/llm/tools/write.go+91−95 internal/llm/tools/write_test.go+324 internal/message/message.go+127−31 internal/permission/permission.go+1 internal/session/session.go+18 internal/tui/components/core/dialog.go+37−4 internal/tui/components/core/status.go+9−2 internal/tui/components/dialog/permission.go+135−50 internal/tui/components/dialog/quit.go+9−14 internal/tui/components/messages/message.go−108 internal/tui/components/repl/editor.go+16−8 internal/tui/components/repl/messages.go+296−89 internal/tui/components/repl/sessions.go+18−1 internal/tui/page/init.go+4−4 internal/tui/styles/icons.go+4 internal/tui/styles/markdown.go+3−3 internal/tui/styles/styles.go+1−2 internal/tui/tui.go+40−29
.gitignore+2
@@ -42,3 +42,5 @@ debug.log
4242.env.local
4343
4444.termai
45+
46+.termai.json
README.md+94−7
@@ -1,13 +1,100 @@
11# TermAI
22
3−**⚠️ WORK IN PROGRESS ⚠️**
3+A powerful terminal-based AI assistant for developers, providing intelligent coding assistance directly in your terminal.
44
5−This project is currently under active development.
5+[![TermAI Demo](https://asciinema.org/a/dtc4nJyGSZX79HRUmFLY3gmoy.svg)](https://asciinema.org/a/dtc4nJyGSZX79HRUmFLY3gmoy)
66
7−## Current Progress
7+## Overview
88
9−- Initial CLI setup
10−- Basic functionality implementation
11−- Working on core features
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.
1210
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 (
77
88 tea "github.com/charmbracelet/bubbletea"
99 "github.com/kujtimiihoxha/termai/internal/app"
10+ "github.com/kujtimiihoxha/termai/internal/config"
1011 "github.com/kujtimiihoxha/termai/internal/db"
11− "github.com/kujtimiihoxha/termai/internal/llm/models"
12+ "github.com/kujtimiihoxha/termai/internal/llm/agent"
1213 "github.com/kujtimiihoxha/termai/internal/tui"
1314 zone "github.com/lrstanley/bubblezone"
1415 "github.com/spf13/cobra"
15− "github.com/spf13/viper"
1616)
1717
1818var rootCmd = &cobra.Command{
@@ -25,11 +25,10 @@ var rootCmd = &cobra.Command{
2525 return nil
2626 }
2727 debug, _ := cmd.Flags().GetBool("debug")
28− viper.Set("debug", debug)
29− if debug {
30− viper.Set("log.level", "debug")
28+ err := config.Load(debug)
29+ if err != nil {
30+ return err
3131 }
32−
3332 conn, err := db.Connect()
3433 if err != nil {
3534 return err
@@ -49,6 +48,8 @@ var rootCmd = &cobra.Command{
4948 defer unsub()
5049
5150 go func() {
51+ // Set this up once
52+ agent.GetMcpTools(ctx)
5253 for msg := range ch {
5354 tui.Send(msg)
5455 }
@@ -95,16 +96,6 @@ func setupSubscriptions(app *app.App) (chan tea.Msg, func()) {
9596 wg.Done()
9697 }()
9798 }
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− }
10899 {
109100 sub := app.Permissions.Subscribe(ctx)
110101 wg.Add(1)
@@ -129,40 +120,7 @@ func Execute() {
129120 }
130121}
131122
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−
163123func init() {
164− loadConfig()
165−
166124 rootCmd.Flags().BoolP("help", "h", false, "Help")
167125 rootCmd.Flags().BoolP("debug", "d", false, "Help")
168126}
go.mod+39−47
@@ -3,6 +3,7 @@ module github.com/kujtimiihoxha/termai
33go 1.23.5
44
55require (
6+ github.com/anthropics/anthropic-sdk-go v0.2.0-beta.2
67 github.com/bmatcuk/doublestar/v4 v4.8.1
78 github.com/catppuccin/go v0.3.0
89 github.com/charmbracelet/bubbles v0.20.0
@@ -10,92 +11,70 @@ require (
1011 github.com/charmbracelet/glamour v0.9.1
1112 github.com/charmbracelet/huh v0.6.0
1213 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
1614 github.com/go-logfmt/logfmt v0.6.0
1715 github.com/golang-migrate/migrate/v4 v4.18.2
16+ github.com/google/generative-ai-go v0.19.0
1817 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
2019 github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231
20+ github.com/mark3labs/mcp-go v0.17.0
2121 github.com/mattn/go-runewidth v0.0.16
2222 github.com/mattn/go-sqlite3 v1.14.24
2323 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
2424 github.com/muesli/reflow v0.3.0
2525 github.com/muesli/termenv v0.16.0
26+ github.com/openai/openai-go v0.1.0-beta.2
2627 github.com/sergi/go-diff v1.3.1
2728 github.com/spf13/cobra v1.9.1
2829 github.com/spf13/viper v1.20.0
30+ github.com/stretchr/testify v1.10.0
2931 golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1
32+ google.golang.org/api v0.215.0
3033)
3134
3235require (
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
3342 github.com/alecthomas/chroma/v2 v2.15.0 // indirect
34− github.com/anthropics/anthropic-sdk-go v0.2.0-alpha.8 // indirect
3543 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
5044 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
5145 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
5446 github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
5547 github.com/charmbracelet/x/ansi v0.8.0 // indirect
5648 github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
5749 github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
5850 github.com/charmbracelet/x/term v0.2.1 // indirect
59− github.com/cloudwego/base64x v0.1.4 // 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
51+ github.com/davecgh/go-spew v1.1.1 // indirect
6252 github.com/dlclark/regexp2 v1.11.4 // indirect
6353 github.com/dustin/go-humanize v1.0.1 // indirect
6454 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
55+ github.com/felixge/httpsnoop v1.0.4 // indirect
6556 github.com/fsnotify/fsnotify v1.8.0 // indirect
66− github.com/getkin/kin-openapi v0.118.0 // indirect
67− github.com/go-openapi/jsonpointer v0.19.5 // indirect
68− github.com/go-openapi/swag v0.19.5 // indirect
57+ github.com/go-logr/logr v1.4.2 // indirect
58+ github.com/go-logr/stdr v1.2.2 // indirect
6959 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
7163 github.com/gorilla/css v1.0.1 // indirect
7264 github.com/hashicorp/errwrap v1.1.0 // indirect
7365 github.com/hashicorp/go-multierror v1.1.1 // indirect
7466 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
7967 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
80− github.com/mailru/easyjson v0.7.7 // indirect
8168 github.com/mattn/go-isatty v0.0.20 // indirect
8269 github.com/mattn/go-localereader v0.0.1 // indirect
8370 github.com/microcosm-cc/bluemonday v1.0.27 // indirect
8471 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
8872 github.com/muesli/cancelreader v0.2.2 // indirect
89− github.com/nikolalohinski/gonja v1.5.3 // indirect
9073 github.com/pelletier/go-toml/v2 v2.2.3 // indirect
91− github.com/perimeterx/marshmallow v1.1.4 // indirect
92− github.com/pkg/errors v0.9.1 // indirect
74+ github.com/pmezard/go-difflib v1.0.0 // indirect
9375 github.com/rivo/uniseg v0.4.7 // indirect
9476 github.com/sagikazarmark/locafero v0.7.0 // indirect
9577 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
9978 github.com/sourcegraph/conc v0.3.0 // indirect
10079 github.com/spf13/afero v1.12.0 // indirect
10180 github.com/spf13/cast v1.7.1 // indirect
@@ -105,19 +84,32 @@ require (
10584 github.com/tidwall/match v1.1.1 // indirect
10685 github.com/tidwall/pretty v1.2.1 // indirect
10786 github.com/tidwall/sjson v1.2.5 // indirect
108− github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
10987 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
11189 github.com/yuin/goldmark v1.7.8 // indirect
11290 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
11396 go.uber.org/atomic v1.9.0 // indirect
11497 go.uber.org/multierr v1.9.0 // indirect
115− golang.org/x/arch v0.11.0 // indirect
116− golang.org/x/net v0.33.0 // indirect
98+ golang.design/x/clipboard v0.7.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
117105 golang.org/x/sync v0.12.0 // indirect
118106 golang.org/x/sys v0.31.0 // indirect
119107 golang.org/x/term v0.30.0 // indirect
120108 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
122114 gopkg.in/yaml.v3 v3.0.1 // indirect
123115)
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=
113github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
214github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
3−github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o=
415github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
516github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
617github.com/alecthomas/chroma/v2 v2.15.0 h1:LxXTQHFoYrstG2nnV9y2X5O94sOBzf0CIUpSTbpxvMc=
718github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio=
819github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
920github.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=
11−github.com/anthropics/anthropic-sdk-go v0.2.0-alpha.8/go.mod h1:GJxtdOs9K4neo8Gg65CjJ7jNautmldGli5/OFNabOoo=
21+github.com/anthropics/anthropic-sdk-go v0.2.0-beta.2 h1:h7qxtumNjKPWFv1QM/HJy60MteeW23iKeEtBoY7bYZk=
22+github.com/anthropics/anthropic-sdk-go v0.2.0-beta.2/go.mod h1:AapDW22irxK2PSumZiQXYUFvsdQgkwIWlpESweWZI/c=
1223github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
1324github.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=
4225github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
4326github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
4427github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
4528github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
4629github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
4730github.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=
4931github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
5032github.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=
6133github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
6234github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
63−github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4=
6435github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
6536github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
6637github.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
8354github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
8455github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
8556github.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=
9857github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
9958github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
10059github.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
10564github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
10665github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
10766github.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=
10869github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
10970github.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=
11171github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
11272github.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=
11873github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
11974github.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=
121−github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
122−github.com/go-openapi/swag v0.19.5 h1:lTz6Ys4CmqqCQmZPBlbQENR1/GucA2bzYTE12Pw4tFY=
123−github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
124−github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
125−github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
75+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
76+github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
77+github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
78+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
79+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
12680github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
12781github.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=
12982github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
13083github.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=
13286github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
13387github.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=
13590github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
13691github.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=
138−github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH4LNHic=
139−github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
140−github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
92+github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
93+github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
94+github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
95+github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
14196github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
14297github.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=
14498github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
14599github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
146100github.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
148102github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
149103github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
150104github.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=
152105github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
153106github.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=
167107github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
168108github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
169109github.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=
171111github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
172112github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
173113github.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=
175−github.com/kujtimiihoxha/vimtea v0.0.3-0.20250317175717-9d8ba9c69840/go.mod h1:VyCD1xYnYem+OHp9nzGNx8x7rCwaeB+2VSyOUgX8Zyc=
114+github.com/kujtimiihoxha/vimtea v0.0.3-0.20250329221256-a250e98498f9 h1:xYfCLI8KUwmXDFp1pOpNX+XsQczQw9VbEuju1pQF5/A=
115+github.com/kujtimiihoxha/vimtea v0.0.3-0.20250329221256-a250e98498f9/go.mod h1:Ye+kIkTmPO5xuqCQ+PPHDTGIViRRoSpSIlcYgma8YlA=
176116github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
177117github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
178118github.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
181121github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231/go.mod h1:S5etECMx+sZnW0Gm100Ma9J1PgVCTgNyFaqGu2b08b4=
182122github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
183123github.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=
185−github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
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=
124+github.com/mark3labs/mcp-go v0.17.0 h1:5Ps6T7qXr7De/2QTqs9h6BKeZ/qdeUeGrgM5lPzi930=
125+github.com/mark3labs/mcp-go v0.17.0/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE=
190126github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
191127github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
192128github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
@@ -196,19 +132,10 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T
196132github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
197133github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
198134github.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=
201135github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
202136github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
203137github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
204138github.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=
212139github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
213140github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
214141github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -217,18 +144,10 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
217144github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
218145github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
219146github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
220−github.com/nikolalohinski/gonja v1.5.3 h1:GsA+EEaZDZPGJ8JtpeGN78jidhOlxeJROpqMT9fTj9c=
221−github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4=
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=
147+github.com/openai/openai-go v0.1.0-beta.2 h1:Ra5nCFkbEl9w+UJwAciC4kqnIBUCcJazhmMA0/YN894=
148+github.com/openai/openai-go v0.1.0-beta.2/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
225149github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
226150github.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=
232151github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
233152github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
234153github.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=
237156github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
238157github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
239158github.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=
241159github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
242160github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
243161github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
244162github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
245163github.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=
248164github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
249165github.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=
259166github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
260167github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
261168github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
@@ -269,16 +176,8 @@ github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
269176github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY=
270177github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
271178github.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=
276179github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
277180github.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=
282181github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
283182github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
284183github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
@@ -293,66 +192,71 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
293192github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
294193github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
295194github.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=
304195github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
305196github.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=
307−github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA=
197+github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
198+github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
308199github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
309200github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
310201github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
311202github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
312203github.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=
313214go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
314215go.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=
317216go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
318217go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
319−golang.org/x/arch v0.11.0 h1:KXV8WWKCXm6tRpLirl2szsO5j/oOODwZf4hATmGVNs4=
320−golang.org/x/arch v0.11.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
321−golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
322−golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
323−golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
218+golang.design/x/clipboard v0.7.0 h1:4Je8M/ys9AJumVnl8m+rZnIvstSnYj1fvzqYrU3TXvo=
219+golang.design/x/clipboard v0.7.0/go.mod h1:PQIvqYO9GP29yINEfsEn5zSQKAz3UgXmZKzDA6dnq2E=
220+golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
221+golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
324222golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw=
325223golang.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=
327−golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
328−golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
329−golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
224+golang.org/x/exp/shiny v0.0.0-20250305212735-054e65f0b394 h1:bFYqOIMdeiCEdzPJkLiOoMDzW/v3tjW4AA/RmUZYsL8=
225+golang.org/x/exp/shiny v0.0.0-20250305212735-054e65f0b394/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8=
226+golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
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=
330234golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
331235golang.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=
334236golang.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=
336237golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
337238golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
338239golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
339240golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
340241golang.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=
342242golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
343243golang.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=
344256gopkg.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=
346257gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
347258gopkg.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=
351259gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
352−gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
353260gopkg.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=
356261gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
357262gopkg.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 (
44 "context"
55 "database/sql"
66
7+ "github.com/kujtimiihoxha/termai/internal/config"
78 "github.com/kujtimiihoxha/termai/internal/db"
8− "github.com/kujtimiihoxha/termai/internal/llm"
99 "github.com/kujtimiihoxha/termai/internal/logging"
1010 "github.com/kujtimiihoxha/termai/internal/message"
1111 "github.com/kujtimiihoxha/termai/internal/permission"
1212 "github.com/kujtimiihoxha/termai/internal/session"
13− "github.com/spf13/viper"
1413)
1514
1615type App struct {
@@ -19,7 +18,6 @@ type App struct {
1918 Sessions session.Service
2019 Messages message.Service
2120 Permissions permission.Service
22− LLM llm.Service
2321
2422 Logger logging.Interface
2523}
@@ -27,18 +25,17 @@ type App struct {
2725func New(ctx context.Context, conn *sql.DB) *App {
2826 q := db.New(conn)
2927 log := logging.NewLogger(logging.Options{
30− Level: viper.GetString("log.level"),
28+ Level: config.Get().Log.Level,
3129 })
3230 sessions := session.NewService(ctx, q)
3331 messages := message.NewService(ctx, q)
34− llm := llm.NewService(ctx, log, sessions, messages)
3532
3633 return &App{
3734 Context: ctx,
3835 Sessions: sessions,
3936 Messages: messages,
4037 Permissions: permission.Default,
41− LLM: llm,
4238 Logger: log,
4339 }
4440}
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 (
1212 "github.com/golang-migrate/migrate/v4/database/sqlite3"
1313 _ "github.com/mattn/go-sqlite3"
1414
15+ "github.com/kujtimiihoxha/termai/internal/config"
1516 "github.com/kujtimiihoxha/termai/internal/logging"
16− "github.com/spf13/viper"
1717)
1818
1919var log = logging.Get()
2020
2121func Connect() (*sql.DB, error) {
22− dataDir := viper.GetString("data.dir")
22+ dataDir := config.Get().Data.Directory
2323 if dataDir == "" {
2424 return nil, fmt.Errorf("data.dir is not set")
2525 }
internal/db/db.go+10
@@ -51,6 +51,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
5151 if q.listSessionsStmt, err = db.PrepareContext(ctx, listSessions); err != nil {
5252 return nil, fmt.Errorf("error preparing query ListSessions: %w", err)
5353 }
54+ if q.updateMessageStmt, err = db.PrepareContext(ctx, updateMessage); err != nil {
55+ return nil, fmt.Errorf("error preparing query UpdateMessage: %w", err)
56+ }
5457 if q.updateSessionStmt, err = db.PrepareContext(ctx, updateSession); err != nil {
5558 return nil, fmt.Errorf("error preparing query UpdateSession: %w", err)
5659 }
@@ -104,6 +107,11 @@ func (q *Queries) Close() error {
104107 err = fmt.Errorf("error closing listSessionsStmt: %w", cerr)
105108 }
106109 }
110+ if q.updateMessageStmt != nil {
111+ if cerr := q.updateMessageStmt.Close(); cerr != nil {
112+ err = fmt.Errorf("error closing updateMessageStmt: %w", cerr)
113+ }
114+ }
107115 if q.updateSessionStmt != nil {
108116 if cerr := q.updateSessionStmt.Close(); cerr != nil {
109117 err = fmt.Errorf("error closing updateSessionStmt: %w", cerr)
@@ -157,6 +165,7 @@ type Queries struct {
157165 getSessionByIDStmt *sql.Stmt
158166 listMessagesBySessionStmt *sql.Stmt
159167 listSessionsStmt *sql.Stmt
168+ updateMessageStmt *sql.Stmt
160169 updateSessionStmt *sql.Stmt
161170}
162171
@@ -173,6 +182,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
173182 getSessionByIDStmt: q.getSessionByIDStmt,
174183 listMessagesBySessionStmt: q.listMessagesBySessionStmt,
175184 listSessionsStmt: q.listSessionsStmt,
185+ updateMessageStmt: q.updateMessageStmt,
176186 updateSessionStmt: q.updateSessionStmt,
177187 }
178188}
internal/db/messages.sql.go+77−12
@@ -7,34 +7,56 @@ package db
77
88import (
99 "context"
10+ "database/sql"
1011)
1112
1213const createMessage = `-- name: CreateMessage :one
1314INSERT INTO messages (
1415 id,
1516 session_id,
16− message_data,
17+ role,
18+ finished,
19+ content,
20+ tool_calls,
21+ tool_results,
1722 created_at,
1823 updated_at
1924) VALUES (
20− ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
25+ ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
2126)
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
2328`
2429
2530type CreateMessageParams struct {
26− ID string `json:"id"`
27− SessionID string `json:"session_id"`
28− MessageData string `json:"message_data"`
31+ ID string `json:"id"`
32+ SessionID string `json:"session_id"`
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"`
2938}
3039
3140func (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+ )
3350 var i Message
3451 err := row.Scan(
3552 &i.ID,
3653 &i.SessionID,
37− &i.MessageData,
54+ &i.Role,
55+ &i.Content,
56+ &i.Thinking,
57+ &i.Finished,
58+ &i.ToolCalls,
59+ &i.ToolResults,
3860 &i.CreatedAt,
3961 &i.UpdatedAt,
4062 )
@@ -62,7 +84,7 @@ func (q *Queries) DeleteSessionMessages(ctx context.Context, sessionID string) e
6284}
6385
6486const 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
6688FROM messages
6789WHERE id = ? LIMIT 1
6890`
@@ -73,7 +95,12 @@ func (q *Queries) GetMessage(ctx context.Context, id string) (Message, error) {
7395 err := row.Scan(
7496 &i.ID,
7597 &i.SessionID,
76− &i.MessageData,
98+ &i.Role,
99+ &i.Content,
100+ &i.Thinking,
101+ &i.Finished,
102+ &i.ToolCalls,
103+ &i.ToolResults,
77104 &i.CreatedAt,
78105 &i.UpdatedAt,
79106 )
@@ -81,7 +108,7 @@ func (q *Queries) GetMessage(ctx context.Context, id string) (Message, error) {
81108}
82109
83110const 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
85112FROM messages
86113WHERE session_id = ?
87114ORDER BY created_at ASC
@@ -99,7 +126,12 @@ func (q *Queries) ListMessagesBySession(ctx context.Context, sessionID string) (
99126 if err := rows.Scan(
100127 &i.ID,
101128 &i.SessionID,
102− &i.MessageData,
129+ &i.Role,
130+ &i.Content,
131+ &i.Thinking,
132+ &i.Finished,
133+ &i.ToolCalls,
134+ &i.ToolResults,
103135 &i.CreatedAt,
104136 &i.UpdatedAt,
105137 ); err != nil {
@@ -115,3 +147,36 @@ func (q *Queries) ListMessagesBySession(ctx context.Context, sessionID string) (
115147 }
116148 return items, nil
117149}
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 @@
11-- Sessions
22CREATE TABLE IF NOT EXISTS sessions (
33 id TEXT PRIMARY KEY,
4+ parent_session_id TEXT,
45 title TEXT NOT NULL,
56 message_count INTEGER NOT NULL DEFAULT 0 CHECK (message_count >= 0),
67 prompt_tokens INTEGER NOT NULL DEFAULT 0 CHECK (prompt_tokens >= 0),
@@ -21,7 +22,12 @@ END;
2122CREATE TABLE IF NOT EXISTS messages (
2223 id TEXT PRIMARY KEY,
2324 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,
2531 created_at INTEGER NOT NULL, -- Unix timestamp in milliseconds
2632 updated_at INTEGER NOT NULL, -- Unix timestamp in milliseconds
2733 FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE
internal/db/models.go+23−13
@@ -4,21 +4,31 @@
44
55package db
66
7+import (
8+ "database/sql"
9+)
10+
711type Message struct {
8− ID string `json:"id"`
9− SessionID string `json:"session_id"`
10− MessageData string `json:"message_data"`
11− CreatedAt int64 `json:"created_at"`
12− UpdatedAt int64 `json:"updated_at"`
12+ ID string `json:"id"`
13+ SessionID string `json:"session_id"`
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"`
20+ CreatedAt int64 `json:"created_at"`
21+ UpdatedAt int64 `json:"updated_at"`
1322}
1423
1524type Session struct {
16− ID string `json:"id"`
17− Title string `json:"title"`
18− MessageCount int64 `json:"message_count"`
19− PromptTokens int64 `json:"prompt_tokens"`
20− CompletionTokens int64 `json:"completion_tokens"`
21− Cost float64 `json:"cost"`
22− UpdatedAt int64 `json:"updated_at"`
23− CreatedAt int64 `json:"created_at"`
25+ ID string `json:"id"`
26+ ParentSessionID sql.NullString `json:"parent_session_id"`
27+ Title string `json:"title"`
28+ MessageCount int64 `json:"message_count"`
29+ PromptTokens int64 `json:"prompt_tokens"`
30+ CompletionTokens int64 `json:"completion_tokens"`
31+ Cost float64 `json:"cost"`
32+ UpdatedAt int64 `json:"updated_at"`
33+ CreatedAt int64 `json:"created_at"`
2434}
internal/db/querier.go+1
@@ -18,6 +18,7 @@ type Querier interface {
1818 GetSessionByID(ctx context.Context, id string) (Session, error)
1919 ListMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
2020 ListSessions(ctx context.Context) ([]Session, error)
21+ UpdateMessage(ctx context.Context, arg UpdateMessageParams) error
2122 UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error)
2223}
2324
internal/db/sessions.sql.go+20−10
@@ -7,11 +7,13 @@ package db
77
88import (
99 "context"
10+ "database/sql"
1011)
1112
1213const createSession = `-- name: CreateSession :one
1314INSERT INTO sessions (
1415 id,
16+ parent_session_id,
1517 title,
1618 message_count,
1719 prompt_tokens,
@@ -26,23 +28,26 @@ INSERT INTO sessions (
2628 ?,
2729 ?,
2830 ?,
31+ ?,
2932 strftime('%s', 'now'),
3033 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
3235`
3336
3437type CreateSessionParams struct {
35− ID string `json:"id"`
36− Title string `json:"title"`
37− MessageCount int64 `json:"message_count"`
38− PromptTokens int64 `json:"prompt_tokens"`
39− CompletionTokens int64 `json:"completion_tokens"`
40− Cost float64 `json:"cost"`
38+ ID string `json:"id"`
39+ ParentSessionID sql.NullString `json:"parent_session_id"`
40+ Title string `json:"title"`
41+ MessageCount int64 `json:"message_count"`
42+ PromptTokens int64 `json:"prompt_tokens"`
43+ CompletionTokens int64 `json:"completion_tokens"`
44+ Cost float64 `json:"cost"`
4145}
4246
4347func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) {
4448 row := q.queryRow(ctx, q.createSessionStmt, createSession,
4549 arg.ID,
50+ arg.ParentSessionID,
4651 arg.Title,
4752 arg.MessageCount,
4853 arg.PromptTokens,
@@ -52,6 +57,7 @@ func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (S
5257 var i Session
5358 err := row.Scan(
5459 &i.ID,
60+ &i.ParentSessionID,
5561 &i.Title,
5662 &i.MessageCount,
5763 &i.PromptTokens,
@@ -74,7 +80,7 @@ func (q *Queries) DeleteSession(ctx context.Context, id string) error {
7480}
7581
7682const 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
7884FROM sessions
7985WHERE id = ? LIMIT 1
8086`
@@ -84,6 +90,7 @@ func (q *Queries) GetSessionByID(ctx context.Context, id string) (Session, error
8490 var i Session
8591 err := row.Scan(
8692 &i.ID,
93+ &i.ParentSessionID,
8794 &i.Title,
8895 &i.MessageCount,
8996 &i.PromptTokens,
@@ -96,8 +103,9 @@ func (q *Queries) GetSessionByID(ctx context.Context, id string) (Session, error
96103}
97104
98105const 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
100107FROM sessions
108+WHERE parent_session_id is NULL
101109ORDER BY created_at DESC
102110`
103111
@@ -112,6 +120,7 @@ func (q *Queries) ListSessions(ctx context.Context) ([]Session, error) {
112120 var i Session
113121 if err := rows.Scan(
114122 &i.ID,
123+ &i.ParentSessionID,
115124 &i.Title,
116125 &i.MessageCount,
117126 &i.PromptTokens,
@@ -141,7 +150,7 @@ SET
141150 completion_tokens = ?,
142151 cost = ?
143152WHERE 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
145154`
146155
147156type UpdateSessionParams struct {
@@ -163,6 +172,7 @@ func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) (S
163172 var i Session
164173 err := row.Scan(
165174 &i.ID,
175+ &i.ParentSessionID,
166176 &i.Title,
167177 &i.MessageCount,
168178 &i.PromptTokens,
internal/db/sql/messages.sql+17−2
@@ -13,14 +13,29 @@ ORDER BY created_at ASC;
1313INSERT INTO messages (
1414 id,
1515 session_id,
16− message_data,
16+ role,
17+ finished,
18+ content,
19+ tool_calls,
20+ tool_results,
1721 created_at,
1822 updated_at
1923) VALUES (
20− ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
24+ ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
2125)
2226RETURNING *;
2327
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+
2439-- name: DeleteMessage :exec
2540DELETE FROM messages
2641WHERE id = ?;
internal/db/sql/sessions.sql+3
@@ -1,6 +1,7 @@
11-- name: CreateSession :one
22INSERT INTO sessions (
33 id,
4+ parent_session_id,
45 title,
56 message_count,
67 prompt_tokens,
@@ -15,6 +16,7 @@ INSERT INTO sessions (
1516 ?,
1617 ?,
1718 ?,
19+ ?,
1820 strftime('%s', 'now'),
1921 strftime('%s', 'now')
2022) RETURNING *;
@@ -27,6 +29,7 @@ WHERE id = ? LIMIT 1;
2729-- name: ListSessions :many
2830SELECT *
2931FROM sessions
32+WHERE parent_session_id is NULL
3033ORDER BY created_at DESC;
3134
3235-- 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
22
33import (
44 "context"
5+ "errors"
56 "fmt"
7+ "log"
8+ "sync"
69
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"
817)
918
10−func GetAgent(ctx context.Context, name string) (*react.Agent, string, error) {
11− switch name {
12− case "coder":
13− agent, err := NewCoderAgent(ctx)
14− return agent, CoderSystemPrompt(), err
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 {
80+ switch event.Type {
81+ case provider.EventThinkingDelta:
82+ assistantMsg.Thinking += event.Thinking
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
15167 }
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
17354}
internal/llm/agent/coder.go+48−163
@@ -1,182 +1,67 @@
11package agent
22
33import (
4− "context"
5− "fmt"
6− "os"
7− "path/filepath"
8− "runtime"
9− "time"
4+ "errors"
105
11− "github.com/cloudwego/eino/components/tool"
12− "github.com/cloudwego/eino/compose"
13− "github.com/cloudwego/eino/flow/agent/react"
6+ "github.com/kujtimiihoxha/termai/internal/app"
7+ "github.com/kujtimiihoxha/termai/internal/config"
148 "github.com/kujtimiihoxha/termai/internal/llm/models"
159 "github.com/kujtimiihoxha/termai/internal/llm/tools"
16− "github.com/spf13/viper"
1710)
1811
19−func coderTools() []tool.BaseTool {
20− wd := viper.GetString("wd")
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− }
12+type coderAgent struct {
13+ *agent
3014}
3115
32−func NewCoderAgent(ctx context.Context) (*react.Agent, error) {
33− model, err := models.GetModel(ctx, models.ModelID(viper.GetString("models.big")))
34− if err != nil {
35− return nil, err
16+func (c *coderAgent) setAgentTool(sessionID string) {
17+ inx := -1
18+ for i, tool := range c.tools {
19+ if tool.Info().Name == AgentToolName {
20+ inx = i
21+ break
22+ }
3623 }
37− reactAgent, err := react.NewAgent(ctx, &react.AgentConfig{
38− Model: model,
39− ToolsConfig: compose.ToolsNodeConfig{
40− Tools: coderTools(),
41− },
42− MaxStep: 1000,
43− })
44− if err != nil {
45− return nil, err
24+ if inx == -1 {
25+ c.tools = append(c.tools, NewAgentTool(sessionID, c.App))
26+ } else {
27+ c.tools[inx] = NewAgentTool(sessionID, c.App)
4628 }
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)
15529}
15630
157−func getEnvironmentInfo() string {
158− cwd := viper.GetString("wd")
159− isGit := isGitRepo(cwd)
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)
31+func (c *coderAgent) Generate(sessionID string, content string) error {
32+ c.setAgentTool(sessionID)
33+ return c.generate(sessionID, content)
17034}
17135
172−func isGitRepo(dir string) bool {
173− _, err := os.Stat(filepath.Join(dir, ".git"))
174− return err == nil
175−}
36+func NewCoderAgent(app *app.App) (Agent, error) {
37+ model, ok := models.SupportedModels[config.Get().Model.Coder]
38+ if !ok {
39+ return nil, errors.New("model not supported")
40+ }
17641
177−func boolToYesNo(b bool) string {
178− if b {
179− return "Yes"
42+ agentProvider, titleGenerator, err := getAgentProviders(app.Context, model)
43+ if err != nil {
44+ return nil, err
18045 }
181− return "No"
46+
47+ mcpTools := GetMcpTools(app.Context)
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
18267}
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 @@
11package models
22
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−
143type (
154 ModelID string
165 ModelProvider string
176)
187
198type Model struct {
20− ID ModelID `json:"id"`
21− Name string `json:"name"`
22− Provider ModelProvider `json:"provider"`
23− APIModel string `json:"api_model"`
24− CostPer1MIn float64 `json:"cost_per_1m_in"`
25− CostPer1MOut float64 `json:"cost_per_1m_out"`
9+ ID ModelID `json:"id"`
10+ Name string `json:"name"`
11+ Provider ModelProvider `json:"provider"`
12+ APIModel string `json:"api_model"`
13+ CostPer1MIn float64 `json:"cost_per_1m_in"`
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"`
2617}
2718
28−const (
29− DefaultBigModel = Claude37Sonnet
30− DefaultLittleModel = Claude37Sonnet
31−)
32−
3319// Model IDs
3420const (
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"
4121 // Anthropic
4222 Claude35Sonnet ModelID = "claude-3.5-sonnet"
4323 Claude3Haiku ModelID = "claude-3-haiku"
4424 Claude37Sonnet ModelID = "claude-3.7-sonnet"
45− // Google
46− Gemini20Pro ModelID = "gemini-2.0-pro"
47− Gemini15Flash ModelID = "gemini-1.5-flash"
48− Gemini20Flash ModelID = "gemini-2.0-flash"
49− // xAI
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"
25+ // OpenAI
26+ GPT4o ModelID = "gpt-4o"
27+
28+ // GEMINI
29+ GEMINI25 ModelID = "gemini-2.5"
30+ GRMINI20Flash ModelID = "gemini-2.0-flash"
31+
5832 // GROQ
59− GroqLlama3SpecDec ModelID = "groq-llama-3-spec-dec"
60− GroqQwen32BCoder ModelID = "qwen-2.5-coder-32b"
33+ QWENQwq ModelID = "qwen-qwq"
6134)
6235
6336const (
6437 ProviderOpenAI ModelProvider = "openai"
6538 ProviderAnthropic ModelProvider = "anthropic"
66− ProviderGoogle ModelProvider = "google"
67− ProviderXAI ModelProvider = "xai"
68− ProviderDeepSeek ModelProvider = "deepseek"
69− ProviderMeta ModelProvider = "meta"
70− ProviderGroq ModelProvider = "groq"
39+ ProviderGemini ModelProvider = "gemini"
40+ ProviderGROQ ModelProvider = "groq"
7141)
7242
7343var 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− },
10744 // Anthropic
10845 Claude35Sonnet: {
109− ID: Claude35Sonnet,
110− Name: "Claude 3.5 Sonnet",
111− Provider: ProviderAnthropic,
112− APIModel: "claude-3.5-sonnet",
46+ ID: Claude35Sonnet,
47+ Name: "Claude 3.5 Sonnet",
48+ Provider: ProviderAnthropic,
49+ APIModel: "claude-3-5-sonnet-latest",
50+ CostPer1MIn: 3.0,
51+ CostPer1MInCached: 3.75,
52+ CostPer1MOutCached: 0.30,
53+ CostPer1MOut: 15.0,
11354 },
11455 Claude3Haiku: {
115− ID: Claude3Haiku,
116− Name: "Claude 3 Haiku",
117− Provider: ProviderAnthropic,
118− APIModel: "claude-3-haiku",
56+ ID: Claude3Haiku,
57+ Name: "Claude 3 Haiku",
58+ Provider: ProviderAnthropic,
59+ APIModel: "claude-3-haiku-latest",
60+ CostPer1MIn: 0.80,
61+ CostPer1MInCached: 1,
62+ CostPer1MOutCached: 0.08,
63+ CostPer1MOut: 4,
11964 },
12065 Claude37Sonnet: {
121− ID: Claude37Sonnet,
122− Name: "Claude 3.7 Sonnet",
123− Provider: ProviderAnthropic,
124− APIModel: "claude-3-7-sonnet-20250219",
125− CostPer1MIn: 3.0,
126− CostPer1MOut: 15.0,
66+ ID: Claude37Sonnet,
67+ Name: "Claude 3.7 Sonnet",
68+ Provider: ProviderAnthropic,
69+ APIModel: "claude-3-7-sonnet-latest",
70+ CostPer1MIn: 3.0,
71+ CostPer1MInCached: 3.75,
72+ CostPer1MOutCached: 0.30,
73+ CostPer1MOut: 15.0,
12774 },
128− // Google
129− Gemini20Pro: {
130− ID: Gemini20Pro,
131− Name: "Gemini 2.0 Pro",
132− Provider: ProviderGoogle,
133− APIModel: "gemini-2.0-pro",
134− },
135− Gemini15Flash: {
136− ID: Gemini15Flash,
137− Name: "Gemini 1.5 Flash",
138− Provider: ProviderGoogle,
139− APIModel: "gemini-1.5-flash",
140− },
141− Gemini20Flash: {
142− ID: Gemini20Flash,
143− Name: "Gemini 2.0 Flash",
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",
75+
76+ // OpenAI
77+ GPT4o: {
78+ ID: GPT4o,
79+ Name: "GPT-4o",
80+ Provider: ProviderOpenAI,
81+ APIModel: "gpt-4o",
82+ CostPer1MIn: 2.50,
83+ CostPer1MInCached: 1.25,
84+ CostPer1MOutCached: 0,
85+ CostPer1MOut: 10.00,
17286 },
173− // Meta
174− Llama3: {
175− ID: Llama3,
176− Name: "LLaMA 3",
177− Provider: ProviderMeta,
178− APIModel: "llama-3",
87+
88+ // GEMINI
89+ GEMINI25: {
90+ ID: GEMINI25,
91+ Name: "Gemini 2.5 Pro",
92+ Provider: ProviderGemini,
93+ APIModel: "gemini-2.5-pro-exp-03-25",
94+ CostPer1MIn: 0,
95+ CostPer1MInCached: 0,
96+ CostPer1MOutCached: 0,
97+ CostPer1MOut: 0,
17998 },
180− Llama270B: {
181− ID: Llama270B,
182− Name: "LLaMA 2 70B",
183− Provider: ProviderMeta,
184− APIModel: "llama-2-70b",
99+
100+ GRMINI20Flash: {
101+ ID: GRMINI20Flash,
102+ Name: "Gemini 2.0 Flash",
103+ Provider: ProviderGemini,
104+ APIModel: "gemini-2.0-flash",
105+ CostPer1MIn: 0.1,
106+ CostPer1MInCached: 0,
107+ CostPer1MOutCached: 0.025,
108+ CostPer1MOut: 0.4,
185109 },
186110
187111 // GROQ
188− GroqLlama3SpecDec: {
189− ID: GroqLlama3SpecDec,
190− Name: "GROQ LLaMA 3 SpecDec",
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,
198− APIModel: "qwen-2.5-coder-32b",
112+ QWENQwq: {
113+ ID: QWENQwq,
114+ Name: "Qwen Qwq",
115+ Provider: ProviderGROQ,
116+ APIModel: "qwen-qwq-32b",
117+ CostPer1MIn: 0,
118+ CostPer1MInCached: 0,
119+ CostPer1MOutCached: 0,
120+ CostPer1MOut: 0,
199121 },
200122}
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 (
66 "fmt"
77 "strings"
88
9− "github.com/cloudwego/eino/components/tool"
10− "github.com/cloudwego/eino/schema"
9+ "github.com/kujtimiihoxha/termai/internal/config"
1110 "github.com/kujtimiihoxha/termai/internal/llm/tools/shell"
1211 "github.com/kujtimiihoxha/termai/internal/permission"
1312)
1413
15−type bashTool struct {
16− workingDir string
17−}
14+type bashTool struct{}
1815
1916const (
2017 BashToolName = "bash"
2118
22− DefaultTimeout = 30 * 60 * 1000 // 30 minutes in milliseconds
19+ DefaultTimeout = 1 * 60 * 1000 // 1 minutes in milliseconds
2320 MaxTimeout = 10 * 60 * 1000 // 10 minutes in milliseconds
2421 MaxOutputLength = 30000
2522)
@@ -29,6 +26,11 @@ type BashParams struct {
2926 Timeout int `json:"timeout"`
3027}
3128
29+type BashPermissionsParams struct {
30+ Command string `json:"command"`
31+ Timeout int `json:"timeout"`
32+}
33+
3234var BannedCommands = []string{
3335 "alias", "curl", "curlie", "wget", "axel", "aria2c",
3436 "nc", "telnet", "lynx", "w3m", "links", "httpie", "xh",
@@ -40,29 +42,29 @@ var SafeReadOnlyCommands = []string{
4042 "whatis", //...
4143}
4244
43−func (b *bashTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
44− return &schema.ToolInfo{
45− Name: BashToolName,
46− Desc: bashDescription(),
47− ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
48− "command": {
49− Type: "string",
50− Desc: "The command to execute",
51− Required: true,
45+func (b *bashTool) Info() ToolInfo {
46+ return ToolInfo{
47+ Name: BashToolName,
48+ Description: bashDescription(),
49+ Parameters: map[string]any{
50+ "command": map[string]any{
51+ "type": "string",
52+ "description": "The command to execute",
5253 },
53− "timeout": {
54− Type: "number",
55− Desc: "Optional timeout in milliseconds (max 600000)",
54+ "timeout": map[string]any{
55+ "type": "number",
56+ "desription": "Optional timeout in milliseconds (max 600000)",
5657 },
57− }),
58− }, nil
58+ },
59+ Required: []string{"command"},
60+ }
5961}
6062
6163// 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) {
6365 var params BashParams
64− if err := json.Unmarshal([]byte(args), &params); err != nil {
65− return "", err
66+ if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
67+ return NewTextErrorResponse("invalid parameters"), nil
6668 }
6769
6870 if params.Timeout > MaxTimeout {
@@ -72,13 +74,13 @@ func (b *bashTool) InvokableRun(ctx context.Context, args string, opts ...tool.O
7274 }
7375
7476 if params.Command == "" {
75− return "missing command", nil
77+ return NewTextErrorResponse("missing command"), nil
7678 }
7779
7880 baseCmd := strings.Fields(params.Command)[0]
7981 for _, banned := range BannedCommands {
8082 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
8284 }
8385 }
8486 isSafeReadOnly := false
@@ -91,39 +93,21 @@ func (b *bashTool) InvokableRun(ctx context.Context, args string, opts ...tool.O
9193 if !isSafeReadOnly {
9294 p := permission.Default.Request(
9395 permission.CreatePermissionRequest{
94− Path: b.workingDir,
96+ Path: config.WorkingDirectory(),
9597 ToolName: BashToolName,
9698 Action: "execute",
9799 Description: fmt.Sprintf("Execute command: %s", params.Command),
98− Params: map[string]interface{}{
99− "command": params.Command,
100− "timeout": params.Timeout,
101− },
100+ Params: BashPermissionsParams(params),
102101 },
103102 )
104103 if !p {
105− return "", fmt.Errorf("permission denied for command: %s", params.Command)
104+ return NewTextErrorResponse("permission denied"), nil
106105 }
107106 }
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())
124108 stdout, stderr, exitCode, interrupted, err := shell.Exec(ctx, params.Command, params.Timeout)
125109 if err != nil {
126− return "", err
110+ return NewTextErrorResponse(fmt.Sprintf("error executing command: %s", err)), nil
127111 }
128112
129113 stdout = truncateOutput(stdout)
@@ -153,9 +137,9 @@ func (b *bashTool) InvokableRun(ctx context.Context, args string, opts ...tool.O
153137 }
154138
155139 if stdout == "" {
156− return "no output", nil
140+ return NewTextResponse("no output"), nil
157141 }
158− return stdout, nil
142+ return NewTextResponse(stdout), nil
159143}
160144
161145func truncateOutput(content string) string {
@@ -327,8 +311,6 @@ Important:
327311- Never update git config`, bannedCommandsStr, MaxOutputLength)
328312}
329313
330−func NewBashTool(workingDir string) tool.InvokableTool {
331− return &bashTool{
332− workingDir: workingDir,
333− }
314+func NewBashTool() BaseTool {
315+ return &bashTool{}
334316}
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
33import (
44 "context"
55 "encoding/json"
6− "errors"
76 "fmt"
87 "os"
98 "path/filepath"
109 "strings"
1110 "time"
1211
13− "github.com/cloudwego/eino/components/tool"
14− "github.com/cloudwego/eino/schema"
12+ "github.com/kujtimiihoxha/termai/internal/config"
1513 "github.com/kujtimiihoxha/termai/internal/permission"
1614 "github.com/sergi/go-diff/diffmatchpatch"
1715)
1816
19−type editTool struct {
20− workingDir string
21−}
17+type editTool struct{}
2218
2319const (
2420 EditToolName = "edit"
@@ -30,100 +26,72 @@ type EditParams struct {
3026 NewString string `json:"new_string"`
3127}
3228
33−func (b *editTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
34− return &schema.ToolInfo{
35− Name: EditToolName,
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.
37−
38−Before using this tool:
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 /)
29+type EditPermissionsParams struct {
30+ FilePath string `json:"file_path"`
31+ OldString string `json:"old_string"`
32+ NewString string `json:"new_string"`
33+ Diff string `json:"diff"`
34+}
7735
78−If you want to create a new file, use:
79− - A new file path, including dir name if needed
80− - An empty old_string
81− - The new file's contents as new_string
82−
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": {
86− Type: "string",
87− Desc: "The absolute path to the file to modify",
88− Required: true,
36+func (e *editTool) Info() ToolInfo {
37+ return ToolInfo{
38+ Name: EditToolName,
39+ Description: editDescription(),
40+ Parameters: map[string]any{
41+ "file_path": map[string]any{
42+ "type": "string",
43+ "description": "The absolute path to the file to modify",
8944 },
90− "old_string": {
91− Type: "string",
92− Desc: "The text to replace",
93− Required: true,
45+ "old_string": map[string]any{
46+ "type": "string",
47+ "description": "The text to replace",
9448 },
95− "new_string": {
96− Type: "string",
97− Desc: "The text to replace it with",
98− Required: true,
49+ "new_string": map[string]any{
50+ "type": "string",
51+ "description": "The text to replace it with",
9952 },
100− }),
101− }, nil
53+ },
54+ Required: []string{"file_path", "old_string", "new_string"},
55+ }
10256}
10357
104−func (b *editTool) InvokableRun(ctx context.Context, args string, opts ...tool.Option) (string, error) {
58+// Run implements Tool.
59+func (e *editTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
10560 var params EditParams
106− if err := json.Unmarshal([]byte(args), &params); err != nil {
107− return "", err
61+ if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
62+ return NewTextErrorResponse("invalid parameters"), nil
10863 }
10964
11065 if params.FilePath == "" {
111− return "", errors.New("file_path is required")
66+ return NewTextErrorResponse("file_path is required"), nil
11267 }
11368
11469 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)
11672 }
11773
11874 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
12080 }
12181
12282 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
12488 }
12589
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
12795}
12896
12997func createNewFile(filePath, content string) (string, error) {
@@ -148,9 +116,11 @@ func createNewFile(filePath, content string) (string, error) {
148116 ToolName: EditToolName,
149117 Action: "create",
150118 Description: fmt.Sprintf("Create file %s", filePath),
151− Params: map[string]interface{}{
152− "file_path": filePath,
153− "content": content,
119+ Params: EditPermissionsParams{
120+ FilePath: filePath,
121+ OldString: "",
122+ NewString: content,
123+ Diff: GenerateDiff("", content),
154124 },
155125 },
156126 )
@@ -166,19 +136,6 @@ func createNewFile(filePath, content string) (string, error) {
166136 recordFileWrite(filePath)
167137 recordFileRead(filePath)
168138
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− //
182139 return "File created: " + filePath, nil
183140}
184141
@@ -231,9 +188,11 @@ func deleteContent(filePath, oldString string) (string, error) {
231188 ToolName: EditToolName,
232189 Action: "delete",
233190 Description: fmt.Sprintf("Delete content from file %s", filePath),
234− Params: map[string]interface{}{
235− "file_path": filePath,
236− "content": content,
191+ Params: EditPermissionsParams{
192+ FilePath: filePath,
193+ OldString: oldString,
194+ NewString: "",
195+ Diff: GenerateDiff(oldContent, newContent),
237196 },
238197 },
239198 )
@@ -247,21 +206,7 @@ func deleteContent(filePath, oldString string) (string, error) {
247206 }
248207
249208 recordFileWrite(filePath)
250−
251− // result := FileEditResult{
252− // FilePath: 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− // }
209+ recordFileRead(filePath)
265210
266211 return "Content deleted from file: " + filePath, nil
267212}
@@ -270,44 +215,45 @@ func replaceContent(filePath, oldString, newString string) (string, error) {
270215 fileInfo, err := os.Stat(filePath)
271216 if err != nil {
272217 if os.IsNotExist(err) {
273− return fmt.Sprintf("file not found: %s", filePath), nil
218+ return "", fmt.Errorf("file not found: %s", filePath)
274219 }
275− return fmt.Sprintf("failed to access file: %s", err), nil
220+ return "", fmt.Errorf("failed to access file: %w", err)
276221 }
277222
278223 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)
280225 }
281226
282227 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")
284229 }
285230
286231 modTime := fileInfo.ModTime()
287232 lastRead := getLastReadTime(filePath)
288233 if modTime.After(lastRead) {
289− return fmt.Sprintf("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
234+ return "", fmt.Errorf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
235+ filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))
291236 }
292237
293238 content, err := os.ReadFile(filePath)
294239 if err != nil {
295− return fmt.Sprintf("failed to read file: %s", err), nil
240+ return "", fmt.Errorf("failed to read file: %w", err)
296241 }
297242
298243 oldContent := string(content)
299244
300245 index := strings.Index(oldContent, oldString)
301246 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")
303248 }
304249
305250 lastIndex := strings.LastIndex(oldContent, oldString)
306251 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")
308253 }
309254
310255 newContent := oldContent[:index] + newString + oldContent[index+len(oldString):]
256+ diff := GenerateDiff(oldString, newContent)
311257
312258 p := permission.Default.Request(
313259 permission.CreatePermissionRequest{
@@ -315,10 +261,11 @@ func replaceContent(filePath, oldString, newString string) (string, error) {
315261 ToolName: EditToolName,
316262 Action: "replace",
317263 Description: fmt.Sprintf("Replace content in file %s", filePath),
318− Params: map[string]interface{}{
319− "file_path": filePath,
320− "old_string": oldString,
321− "new_string": newString,
264+ Params: EditPermissionsParams{
265+ FilePath: filePath,
266+ OldString: oldString,
267+ NewString: newString,
268+ Diff: diff,
322269 },
323270 },
324271 )
@@ -328,93 +275,97 @@ func replaceContent(filePath, oldString, newString string) (string, error) {
328275
329276 err = os.WriteFile(filePath, []byte(newContent), 0o644)
330277 if err != nil {
331− return fmt.Sprintf("failed to write file: %s", err), nil
278+ return "", fmt.Errorf("failed to write file: %w", err)
332279 }
333280
334281 recordFileWrite(filePath)
335−
336− // result := FileEditResult{
337− // FilePath: 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− // }
282+ recordFileRead(filePath)
350283
351284 return "Content replaced in file: " + filePath, nil
352285}
353286
354−func getContextSnippet(content string, position, length int) string {
355− contextLines := 3
356−
357− lines := strings.Split(content, "\n")
358− lineIndex := 0
359− currentPos := 0
360−
361− for i, line := range lines {
362− if currentPos <= position && position < currentPos+len(line)+1 {
363− lineIndex = i
364− break
287+func GenerateDiff(oldContent, newContent string) string {
288+ dmp := diffmatchpatch.New()
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:
299+ for _, line := range strings.Split(text, "\n") {
300+ _, _ = buff.WriteString("+ " + line + "\n")
301+ }
302+ case diffmatchpatch.DiffDelete:
303+ for _, line := range strings.Split(text, "\n") {
304+ _, _ = buff.WriteString("- " + line + "\n")
305+ }
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+ }
365314 }
366− currentPos += len(line) + 1 // +1 for the newline
367315 }
316+ return buff.String()
317+}
368318
369− startLine := max(0, lineIndex-contextLines)
370− endLine := min(len(lines), lineIndex+contextLines+1)
319+func editDescription() string {
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.
371321
372− var snippetBuilder strings.Builder
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− }
322+Before using this tool:
380323
381− return snippetBuilder.String()
382−}
324+1. Use the FileRead tool to understand the file's contents and context
383325
384−func generateDiff(oldContent, newContent string) string {
385− dmp := diffmatchpatch.New()
326+2. Verify the directory path is correct (only applicable when creating new files):
327+ - Use the LS tool to verify the parent directory exists and is the correct location
386328
387− diffs := dmp.DiffMain(oldContent, newContent, false)
388−
389− patches := dmp.PatchMake(oldContent, diffs)
390− patchText := dmp.PatchToText(patches)
391−
392− if patchText == "" && (oldContent != newContent) {
393− var result strings.Builder
394−
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− }
329+To make a file edit, provide the following:
330+1. file_path: The absolute path to the file to modify (must be absolute, not relative)
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)
332+3. new_string: The edited text to replace the old_string
333+
334+Special cases:
335+- To create a new file: provide file_path and new_string, leave old_string empty
336+- To delete content: provide file_path and old_string, leave new_string empty
412337
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.`
414367}
415368
416−func NewEditTool(workingDir string) tool.InvokableTool {
417− return &editTool{
418− workingDir: workingDir,
419− }
369+func NewEditTool() BaseTool {
370+ return &editTool{}
420371}
internal/llm/tools/glob.go+67−35
@@ -11,15 +11,11 @@ import (
1111 "strings"
1212 "time"
1313
14− "github.com/cloudwego/eino/components/tool"
15− "github.com/cloudwego/eino/schema"
16−
1714 "github.com/bmatcuk/doublestar/v4"
15+ "github.com/kujtimiihoxha/termai/internal/config"
1816)
1917
20−type globTool struct {
21− workingDir string
22−}
18+type globTool struct{}
2319
2420const (
2521 GlobToolName = "glob"
@@ -35,43 +31,44 @@ type GlobParams struct {
3531 Path string `json:"path"`
3632}
3733
38−func (b *globTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
39− return &schema.ToolInfo{
40− Name: GlobToolName,
41− Desc: `- Fast file pattern matching tool that works with any codebase size
42−- Supports glob patterns like "**/*.js" or "src/**/*.ts"
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": {
48− Type: "string",
49− Desc: "The glob pattern to match files against",
50− Required: true,
34+func (g *globTool) Info() ToolInfo {
35+ return ToolInfo{
36+ Name: GlobToolName,
37+ Description: globDescription(),
38+ Parameters: map[string]any{
39+ "pattern": map[string]any{
40+ "type": "string",
41+ "description": "The glob pattern to match files against",
5142 },
52− "path": {
53− Type: "string",
54− Desc: "The directory to search in. Defaults to the current working directory.",
43+ "path": map[string]any{
44+ "type": "string",
45+ "description": "The directory to search in. Defaults to the current working directory.",
5546 },
56− }),
57− }, nil
47+ },
48+ Required: []string{"pattern"},
49+ }
5850}
5951
60−func (b *globTool) InvokableRun(ctx context.Context, args string, opts ...tool.Option) (string, error) {
52+// Run implements Tool.
53+func (g *globTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
6154 var params GlobParams
62− if err := json.Unmarshal([]byte(args), &params); err != nil {
63− return fmt.Sprintf("error parsing parameters: %s", err), nil
55+ if err := json.Unmarshal([]byte(call.Input), &params); 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
6461 }
6562
6663 // If path is empty, use current working directory
6764 searchPath := params.Path
6865 if searchPath == "" {
69− searchPath = b.workingDir
66+ searchPath = config.WorkingDirectory()
7067 }
7168
7269 files, truncated, err := globFiles(params.Pattern, searchPath, 100)
7370 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
7572 }
7673
7774 // Format the output for the assistant
@@ -81,11 +78,11 @@ func (b *globTool) InvokableRun(ctx context.Context, args string, opts ...tool.O
8178 } else {
8279 output = strings.Join(files, "\n")
8380 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.)"
8582 }
8683 }
8784
88− return output, nil
85+ return NewTextResponse(output), nil
8986}
9087
9188func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) {
@@ -167,8 +164,43 @@ func skipHidden(path string) bool {
167164 return base != "." && strings.HasPrefix(base, ".")
168165}
169166
170−func NewGlobTool(workingDir string) tool.InvokableTool {
171− return &globTool{
172− workingDir,
173− }
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 {
205+ return &globTool{}
174206}
internal/llm/tools/grep.go+127−74
@@ -13,18 +13,13 @@ import (
1313 "strings"
1414 "time"
1515
16− "github.com/cloudwego/eino/components/tool"
17− "github.com/cloudwego/eino/schema"
16+ "github.com/kujtimiihoxha/termai/internal/config"
1817)
1918
20−type grepTool struct {
21− workingDir string
22−}
19+type grepTool struct{}
2320
2421const (
2522 GrepToolName = "grep"
26−
27− MaxGrepResults = 100
2823)
2924
3025type GrepParams struct {
@@ -38,83 +33,66 @@ type grepMatch struct {
3833 modTime time.Time
3934}
4035
41−func (b *grepTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
42− return &schema.ToolInfo{
43− Name: GrepToolName,
44− Desc: `- Fast content search tool that works with any codebase size
45−- Searches file contents using regular expressions
46−- Supports full regex syntax (eg. "log.*Error", "function\\s+\\w+", etc.)
47−- Filter files by pattern with the include parameter (eg. "*.js", "*.{ts,tsx}")
48−- Returns matching file paths sorted by modification time
49−- Use this tool when you need to find files containing specific patterns
50−- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead`,
51− ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
52− "command": {
53− Type: "string",
54− Desc: "The command to execute",
55− Required: true,
36+func (g *grepTool) Info() ToolInfo {
37+ return ToolInfo{
38+ Name: GrepToolName,
39+ Description: grepDescription(),
40+ Parameters: map[string]any{
41+ "pattern": map[string]any{
42+ "type": "string",
43+ "description": "The regex pattern to search for in file contents",
44+ },
45+ "path": map[string]any{
46+ "type": "string",
47+ "description": "The directory to search in. Defaults to the current working directory.",
5648 },
57− "timeout": {
58− Type: "number",
59− Desc: "Optional timeout in milliseconds (max 600000)",
49+ "include": map[string]any{
50+ "type": "string",
51+ "description": "File pattern to include in the search (e.g. \"*.js\", \"*.{ts,tsx}\")",
6052 },
61− }),
62− }, nil
53+ },
54+ Required: []string{"pattern"},
55+ }
6356}
6457
65−func (b *grepTool) InvokableRun(ctx context.Context, args string, opts ...tool.Option) (string, error) {
58+// Run implements Tool.
59+func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
6660 var params GrepParams
67− if err := json.Unmarshal([]byte(args), &params); err != nil {
68− return "", err
61+ if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
62+ return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
63+ }
64+
65+ if params.Pattern == "" {
66+ return NewTextErrorResponse("pattern is required"), nil
6967 }
7068
69+ // If path is empty, use current working directory
7170 searchPath := params.Path
7271 if searchPath == "" {
73− var err error
74− searchPath, err = os.Getwd()
75− if err != nil {
76− return fmt.Sprintf("unable to get current working directory: %s", err), nil
77− }
72+ searchPath = config.WorkingDirectory()
7873 }
7974
80− matches, err := searchWithRipgrep(params.Pattern, searchPath, params.Include)
75+ matches, truncated, err := searchFiles(params.Pattern, searchPath, params.Include, 100)
8176 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
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
77+ return NewTextErrorResponse(fmt.Sprintf("error searching files: %s", err)), nil
10178 }
10279
80+ // Format the output for the assistant
10381 var output string
104− if len(filenames) == 0 {
82+ if len(matches) == 0 {
10583 output = "No files found"
10684 } else {
10785 output = fmt.Sprintf("Found %d file%s\n%s",
108− len(filenames),
109− pluralize(len(filenames)),
110− strings.Join(filenames, "\n"))
86+ len(matches),
87+ pluralize(len(matches)),
88+ strings.Join(matches, "\n"))
11189
11290 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.)"
11492 }
11593 }
11694
117− return output, nil
95+ return NewTextResponse(output), nil
11896}
11997
12098func pluralize(count int) string {
@@ -124,6 +102,37 @@ func pluralize(count int) string {
124102 return "s"
125103}
126104
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+
127136func searchWithRipgrep(pattern, path, include string) ([]grepMatch, error) {
128137 _, err := exec.LookPath("rg")
129138 if err != nil {
@@ -140,6 +149,7 @@ func searchWithRipgrep(pattern, path, include string) ([]grepMatch, error) {
140149 output, err := cmd.Output()
141150 if err != nil {
142151 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
143153 return []grepMatch{}, nil
144154 }
145155 return nil, err
@@ -155,7 +165,7 @@ func searchWithRipgrep(pattern, path, include string) ([]grepMatch, error) {
155165
156166 fileInfo, err := os.Stat(line)
157167 if err != nil {
158− continue
168+ continue // Skip files we can't access
159169 }
160170
161171 matches = append(matches, grepMatch{
@@ -186,20 +196,27 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
186196
187197 err = filepath.Walk(rootPath, func(path string, info os.FileInfo, err error) error {
188198 if err != nil {
189− return nil
199+ return nil // Skip errors
190200 }
191201
192202 if info.IsDir() {
203+ return nil // Skip directories
204+ }
205+
206+ // Skip hidden files
207+ if skipHidden(path) {
193208 return nil
194209 }
195210
211+ // Check include pattern if provided
196212 if includePattern != nil && !includePattern.MatchString(path) {
197213 return nil
198214 }
199215
216+ // Check file contents for the pattern
200217 match, err := fileContainsPattern(path, regex)
201218 if err != nil {
202− return nil
219+ return nil // Skip files we can't read
203220 }
204221
205222 if match {
@@ -207,6 +224,11 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
207224 path: path,
208225 modTime: info.ModTime(),
209226 })
227+
228+ // Check if we've hit the limit (collect double for sorting)
229+ if len(matches) >= 200 {
230+ return filepath.SkipAll
231+ }
210232 }
211233
212234 return nil
@@ -232,11 +254,7 @@ func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, error)
232254 }
233255 }
234256
235− if err := scanner.Err(); err != nil {
236− return false, err
237− }
238−
239− return false, nil
257+ return false, scanner.Err()
240258}
241259
242260func globToRegex(glob string) string {
@@ -250,11 +268,46 @@ func globToRegex(glob string) string {
250268 return "(" + strings.ReplaceAll(inner, ",", "|") + ")"
251269 })
252270
253− return "^" + regexPattern + "$"
271+ return regexPattern
254272}
255273
256−func NewGrepTool(workingDir string) tool.InvokableTool {
257− return &grepTool{
258− workingDir,
259− }
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 {
312+ return &grepTool{}
260313}
internal/llm/tools/ls.go+198−107
@@ -8,19 +8,14 @@ import (
88 "path/filepath"
99 "strings"
1010
11− "github.com/cloudwego/eino/components/tool"
12− "github.com/cloudwego/eino/schema"
11+ "github.com/kujtimiihoxha/termai/internal/config"
1312)
1413
15−type lsTool struct {
16− workingDir string
17−}
14+type lsTool struct{}
1815
1916const (
2017 LSToolName = "ls"
21−
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"
18+ MaxLSFiles = 1000
2419)
2520
2621type LSParams struct {
@@ -28,61 +23,82 @@ type LSParams struct {
2823 Ignore []string `json:"ignore"`
2924}
3025
31−func (b *lsTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
32− return &schema.ToolInfo{
33− 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.",
35− ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
36− "path": {
37− Type: "string",
38− Desc: "The absolute path to the directory to list (must be absolute, not relative)",
39− Required: true,
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+
33+func (l *lsTool) Info() ToolInfo {
34+ return ToolInfo{
35+ Name: LSToolName,
36+ Description: lsDescription(),
37+ Parameters: map[string]any{
38+ "path": map[string]any{
39+ "type": "string",
40+ "description": "The path to the directory to list (defaults to current working directory)",
4041 },
41− "ignore": {
42− Type: "array",
43− ElemInfo: &schema.ParameterInfo{
44− Type: schema.String,
45− Desc: "List of glob patterns to ignore",
42+ "ignore": map[string]any{
43+ "type": "array",
44+ "description": "List of glob patterns to ignore",
45+ "items": map[string]any{
46+ "type": "string",
4647 },
4748 },
48− }),
49− }, nil
49+ },
50+ Required: []string{"path"},
51+ }
5052}
5153
52−func (b *lsTool) InvokableRun(ctx context.Context, args string, opts ...tool.Option) (string, error) {
54+// Run implements Tool.
55+func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
5356 var params LSParams
54− if err := json.Unmarshal([]byte(args), &params); err != nil {
55− return "", err
57+ if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
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)
5670 }
5771
58− if !filepath.IsAbs(params.Path) {
59− return fmt.Sprintf("path must be absolute, got: %s", params.Path), nil
72+ // Check if the path exists
73+ if _, err := os.Stat(searchPath); os.IsNotExist(err) {
74+ return NewTextErrorResponse(fmt.Sprintf("path does not exist: %s", searchPath)), nil
6075 }
6176
62− files, err := b.listDirectory(params.Path)
77+ files, truncated, err := listDirectory(searchPath, params.Ignore, MaxLSFiles)
6378 if err != nil {
64− return fmt.Sprintf("error listing directory: %s", err), nil
79+ return NewTextErrorResponse(fmt.Sprintf("error listing directory: %s", err)), nil
6580 }
6681
6782 tree := createFileTree(files)
68− output := printTree(tree, params.Path)
83+ output := printTree(tree, searchPath)
6984
70− if len(files) >= MaxFiles {
71− output = TruncatedMessage + output
85+ if truncated {
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)
7287 }
7388
74− return output, nil
89+ return NewTextResponse(output), nil
7590}
7691
77−func (b *lsTool) listDirectory(initialPath string) ([]string, error) {
92+func listDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) {
7893 var results []string
94+ truncated := false
7995
8096 err := filepath.Walk(initialPath, func(path string, info os.FileInfo, err error) error {
8197 if err != nil {
8298 return nil // Skip files we don't have permission to access
8399 }
84100
85− if shouldSkip(path) {
101+ if shouldSkip(path, ignorePatterns) {
86102 if info.IsDir() {
87103 return filepath.SkipDir
88104 }
@@ -93,137 +109,212 @@ func (b *lsTool) listDirectory(initialPath string) ([]string, error) {
93109 if info.IsDir() {
94110 path = path + string(filepath.Separator)
95111 }
96−
97− relPath, err := filepath.Rel(b.workingDir, path)
98− if err == nil {
99− results = append(results, relPath)
100− } else {
101− results = append(results, path)
102− }
112+ results = append(results, path)
103113 }
104114
105− if len(results) >= MaxFiles {
106− return fmt.Errorf("max files reached")
115+ if len(results) >= limit {
116+ truncated = true
117+ return filepath.SkipAll
107118 }
108119
109120 return nil
110121 })
111−
112− if err != nil && err.Error() != "max files reached" {
113− return nil, err
122+ if err != nil {
123+ return nil, truncated, err
114124 }
115125
116− return results, nil
126+ return results, truncated, nil
117127}
118128
119−func shouldSkip(path string) bool {
129+func shouldSkip(path string, ignorePatterns []string) bool {
120130 base := filepath.Base(path)
121131
132+ // Skip hidden files and directories
122133 if base != "." && strings.HasPrefix(base, ".") {
123134 return true
124135 }
125136
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
126160 if strings.Contains(path, filepath.Join("__pycache__", "")) {
127161 return true
128162 }
129163
130− return false
131−}
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 {
179+ return true
180+ }
181+ }
182+ }
132183
133−type TreeNode struct {
134− Name string `json:"name"`
135− Path string `json:"path"`
136− Type string `json:"type"` // "file" or "directory"
137− Children []TreeNode `json:"children,omitempty"`
184+ // Check against ignore patterns
185+ for _, pattern := range ignorePatterns {
186+ matched, err := filepath.Match(pattern, base)
187+ if err == nil && matched {
188+ return true
189+ }
190+ }
191+
192+ return false
138193}
139194
140−func createFileTree(sortedPaths []string) []TreeNode {
141− root := []TreeNode{}
195+func createFileTree(sortedPaths []string) []*TreeNode {
196+ root := []*TreeNode{}
197+ pathMap := make(map[string]*TreeNode)
142198
143199 for _, path := range sortedPaths {
144200 parts := strings.Split(path, string(filepath.Separator))
145− currentLevel := &root
146201 currentPath := ""
202+ var parentPath string
147203
148− for i, part := range parts {
149− if part == "" {
150− continue
204+ var cleanParts []string
205+ for _, part := range parts {
206+ if part != "" {
207+ cleanParts = append(cleanParts, part)
151208 }
209+ }
210+ parts = cleanParts
152211
212+ if len(parts) == 0 {
213+ continue
214+ }
215+
216+ for i, part := range parts {
153217 if currentPath == "" {
154218 currentPath = part
155219 } else {
156220 currentPath = filepath.Join(currentPath, part)
157221 }
158222
223+ if _, exists := pathMap[currentPath]; exists {
224+ parentPath = currentPath
225+ continue
226+ }
227+
159228 isLastPart := i == len(parts)-1
160229 isDir := !isLastPart || strings.HasSuffix(path, string(filepath.Separator))
161−
162− found := false
163− for i := range *currentLevel {
164− if (*currentLevel)[i].Name == part {
165− found = true
166− if (*currentLevel)[i].Children != nil {
167− currentLevel = &(*currentLevel)[i].Children
168− }
169− break
170− }
230+ nodeType := "file"
231+ if isDir {
232+ nodeType = "directory"
233+ }
234+ newNode := &TreeNode{
235+ Name: part,
236+ Path: currentPath,
237+ Type: nodeType,
238+ Children: []*TreeNode{},
171239 }
172240
173− if !found {
174− nodeType := "file"
175− if isDir {
176− nodeType = "directory"
177− }
178−
179− newNode := TreeNode{
180− Name: part,
181− Path: currentPath,
182− Type: nodeType,
183− }
241+ pathMap[currentPath] = newNode
184242
185− if isDir {
186− newNode.Children = []TreeNode{}
187− *currentLevel = append(*currentLevel, newNode)
188− currentLevel = &(*currentLevel)[len(*currentLevel)-1].Children
189− } else {
190− *currentLevel = append(*currentLevel, newNode)
243+ if i > 0 && parentPath != "" {
244+ if parent, ok := pathMap[parentPath]; ok {
245+ parent.Children = append(parent.Children, newNode)
191246 }
247+ } else {
248+ root = append(root, newNode)
192249 }
250+
251+ parentPath = currentPath
193252 }
194253 }
195254
196255 return root
197256}
198257
199−func printTree(tree []TreeNode, rootPath string) string {
258+func printTree(tree []*TreeNode, rootPath string) string {
200259 var result strings.Builder
201260
202261 result.WriteString(fmt.Sprintf("- %s%s\n", rootPath, string(filepath.Separator)))
203262
204− printTreeRecursive(&result, tree, 0, " ")
263+ for _, node := range tree {
264+ printNode(&result, node, 1)
265+ }
205266
206267 return result.String()
207268}
208269
209−func printTreeRecursive(builder *strings.Builder, tree []TreeNode, level int, prefix string) {
210− for _, node := range tree {
211− linePrefix := prefix + "- "
270+func printNode(builder *strings.Builder, node *TreeNode, level int) {
271+ indent := strings.Repeat(" ", level)
212272
213− nodeName := node.Name
214− if node.Type == "directory" {
215− nodeName += string(filepath.Separator)
216− }
217− fmt.Fprintf(builder, "%s%s\n", linePrefix, nodeName)
273+ nodeName := node.Name
274+ if node.Type == "directory" {
275+ nodeName += string(filepath.Separator)
276+ }
277+
278+ fmt.Fprintf(builder, "%s- %s\n", indent, nodeName)
218279
219− if node.Type == "directory" && len(node.Children) > 0 {
220− printTreeRecursive(builder, node.Children, level+1, prefix+" ")
280+ if node.Type == "directory" && len(node.Children) > 0 {
281+ for _, child := range node.Children {
282+ printNode(builder, child, level+1)
221283 }
222284 }
223285}
224286
225−func NewLsTool(workingDir string) tool.InvokableTool {
226− return &lsTool{
227− workingDir,
228− }
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 {
319+ return &lsTool{}
229320}
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
116116 }
117117
118118 tempDir := os.TempDir()
119− stdoutFile := filepath.Join(tempDir, fmt.Sprintf("orbitowl-stdout-%d", time.Now().UnixNano()))
120− stderrFile := filepath.Join(tempDir, fmt.Sprintf("orbitowl-stderr-%d", time.Now().UnixNano()))
121− statusFile := filepath.Join(tempDir, fmt.Sprintf("orbitowl-status-%d", time.Now().UnixNano()))
122− cwdFile := filepath.Join(tempDir, fmt.Sprintf("orbitowl-cwd-%d", time.Now().UnixNano()))
119+ stdoutFile := filepath.Join(tempDir, fmt.Sprintf("termai-stdout-%d", time.Now().UnixNano()))
120+ stderrFile := filepath.Join(tempDir, fmt.Sprintf("termai-stderr-%d", time.Now().UnixNano()))
121+ statusFile := filepath.Join(tempDir, fmt.Sprintf("termai-status-%d", time.Now().UnixNano()))
122+ cwdFile := filepath.Join(tempDir, fmt.Sprintf("termai-cwd-%d", time.Now().UnixNano()))
123123
124124 defer func() {
125125 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 (
1010 "path/filepath"
1111 "strings"
1212
13− "github.com/cloudwego/eino/components/tool"
14− "github.com/cloudwego/eino/schema"
13+ "github.com/kujtimiihoxha/termai/internal/config"
1514)
1615
17−type viewTool struct {
18− workingDir string
19−}
16+type viewTool struct{}
2017
2118const (
22− ViewToolName = "view"
23−
24− MaxReadSize = 250 * 1024
25−
19+ ViewToolName = "view"
20+ MaxReadSize = 250 * 1024
2621 DefaultReadLimit = 2000
27−
28− MaxLineLength = 2000
22+ MaxLineLength = 2000
2923)
3024
31−type ViewPatams struct {
25+type ViewParams struct {
3226 FilePath string `json:"file_path"`
3327 Offset int `json:"offset"`
3428 Limit int `json:"limit"`
3529}
3630
37−func (b *viewTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
38− return &schema.ToolInfo{
39− 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.`,
41− ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
42− "file_path": {
43− Type: "string",
44− Desc: "The absolute path to the file to read",
45− Required: true,
31+func (v *viewTool) Info() ToolInfo {
32+ return ToolInfo{
33+ Name: ViewToolName,
34+ Description: viewDescription(),
35+ Parameters: map[string]any{
36+ "file_path": map[string]any{
37+ "type": "string",
38+ "description": "The path to the file to read",
4639 },
47− "offset": {
48− Type: "int",
49− Desc: "The line number to start reading from. Only provide if the file is too large to read at once",
40+ "offset": map[string]any{
41+ "type": "integer",
42+ "description": "The line number to start reading from (0-based)",
5043 },
51− "limit": {
52− Type: "int",
53− Desc: "The number of lines to read. Only provide if the file is too large to read at once.",
44+ "limit": map[string]any{
45+ "type": "integer",
46+ "description": "The number of lines to read (defaults to 2000)",
5447 },
55− }),
56− }, nil
48+ },
49+ Required: []string{"file_path"},
50+ }
5751}
5852
59−func (b *viewTool) InvokableRun(ctx context.Context, args string, opts ...tool.Option) (string, error) {
60− var params ViewPatams
61− if err := json.Unmarshal([]byte(args), &params); err != nil {
62− return fmt.Sprintf("failed to parse parameters: %s", err), nil
53+// Run implements Tool.
54+func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
55+ var params ViewParams
56+ if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
57+ return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
6358 }
6459
6560 if params.FilePath == "" {
66− return "file_path is required", nil
61+ return NewTextErrorResponse("file_path is required"), nil
6762 }
6863
69− if !filepath.IsAbs(params.FilePath) {
70− return fmt.Sprintf("file path must be absolute, got: %s", params.FilePath), nil
64+ // Handle relative paths
65+ filePath := params.FilePath
66+ if !filepath.IsAbs(filePath) {
67+ filePath = filepath.Join(config.WorkingDirectory(), filePath)
7168 }
7269
73− fileInfo, err := os.Stat(params.FilePath)
70+ // Check if file exists
71+ fileInfo, err := os.Stat(filePath)
7472 if err != nil {
7573 if os.IsNotExist(err) {
76− dir := filepath.Dir(params.FilePath)
77− base := filepath.Base(params.FilePath)
74+ // Try to offer suggestions for similarly named files
75+ dir := filepath.Dir(filePath)
76+ base := filepath.Base(filePath)
7877
7978 dirEntries, dirErr := os.ReadDir(dir)
8079 if dirErr == nil {
8180 var suggestions []string
8281 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())) {
8484 suggestions = append(suggestions, filepath.Join(dir, entry.Name()))
8585 if len(suggestions) >= 3 {
8686 break
@@ -89,43 +89,55 @@ func (b *viewTool) InvokableRun(ctx context.Context, args string, opts ...tool.O
8989 }
9090
9191 if len(suggestions) > 0 {
92− return fmt.Sprintf("file not found: %s. Did you mean one of these?\n%s",
93− params.FilePath, strings.Join(suggestions, "\n")), nil
92+ return NewTextErrorResponse(fmt.Sprintf("File not found: %s\n\nDid you mean one of these?\n%s",
93+ filePath, strings.Join(suggestions, "\n"))), nil
9494 }
9595 }
9696
97− return fmt.Sprintf("file not found: %s", params.FilePath), nil
97+ return NewTextErrorResponse(fmt.Sprintf("File not found: %s", filePath)), nil
9898 }
99− return fmt.Sprintf("failed to access file: %s", err), nil
99+ return NewTextErrorResponse(fmt.Sprintf("Failed to access file: %s", err)), nil
100100 }
101101
102+ // Check if it's a directory
102103 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
104105 }
105106
107+ // Check file size
106108 if fileInfo.Size() > MaxReadSize {
107− return fmt.Sprintf("file is too large (%d bytes). Maximum size is %d bytes",
108− fileInfo.Size(), MaxReadSize), nil
109+ return NewTextErrorResponse(fmt.Sprintf("File is too large (%d bytes). Maximum size is %d bytes",
110+ fileInfo.Size(), MaxReadSize)), nil
109111 }
110112
113+ // Set default limit if not provided
111114 if params.Limit <= 0 {
112115 params.Limit = DefaultReadLimit
113116 }
114117
115− isImage, _ := isImageFile(params.FilePath)
118+ // Check if it's an image file
119+ isImage, imageType := isImageFile(filePath)
116120 if isImage {
117− // TODO: Implement image reading
118− return "reading images is not supported", nil
121+ return NewTextErrorResponse(fmt.Sprintf("This is an image file of type: %s\nUse a different tool to process images", imageType)), nil
119122 }
120123
121− content, _, err := readTextFile(params.FilePath, params.Offset, params.Limit)
124+ // Read the file content
125+ content, lineCount, err := readTextFile(filePath, params.Offset, params.Limit)
122126 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
124128 }
125129
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+ }
127138
128− return addLineNumbers(content, params.Offset+1), nil
139+ recordFileRead(filePath)
140+ return NewTextResponse(output), nil
129141}
130142
131143func addLineNumbers(content string, startLine int) string {
@@ -191,6 +203,11 @@ func readTextFile(filePath string, offset, limit int) (string, int, error) {
191203 lines = append(lines, lineText)
192204 }
193205
206+ // Continue scanning to get total line count
207+ for scanner.Scan() {
208+ lineCount++
209+ }
210+
194211 if err := scanner.Err(); err != nil {
195212 return "", 0, err
196213 }
@@ -202,17 +219,17 @@ func isImageFile(filePath string) (bool, string) {
202219 ext := strings.ToLower(filepath.Ext(filePath))
203220 switch ext {
204221 case ".jpg", ".jpeg":
205− return true, "jpeg"
222+ return true, "JPEG"
206223 case ".png":
207− return true, "png"
224+ return true, "PNG"
208225 case ".gif":
209− return true, "gif"
226+ return true, "GIF"
210227 case ".bmp":
211− return true, "bmp"
228+ return true, "BMP"
212229 case ".svg":
213− return true, "svg"
230+ return true, "SVG"
214231 case ".webp":
215− return true, "webp"
232+ return true, "WebP"
216233 default:
217234 return false, ""
218235 }
@@ -240,8 +257,39 @@ func (s *LineScanner) Err() error {
240257 return s.scanner.Err()
241258}
242259
243−func NewViewTool(workingDir string) tool.InvokableTool {
244− return &viewTool{
245− workingDir,
246− }
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 {
294+ return &viewTool{}
247295}
internal/llm/tools/write.go+91−95
@@ -6,17 +6,13 @@ import (
66 "fmt"
77 "os"
88 "path/filepath"
9− "strings"
109 "time"
1110
12− "github.com/cloudwego/eino/components/tool"
13− "github.com/cloudwego/eino/schema"
11+ "github.com/kujtimiihoxha/termai/internal/config"
1412 "github.com/kujtimiihoxha/termai/internal/permission"
1513)
1614
17−type writeTool struct {
18− workingDir string
19−}
15+type writeTool struct{}
2016
2117const (
2218 WriteToolName = "write"
@@ -27,139 +23,139 @@ type WriteParams struct {
2723 Content string `json:"content"`
2824}
2925
30−func (b *writeTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
31− return &schema.ToolInfo{
32− 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− ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
35− "file_path": {
36− Type: "string",
37− Desc: "The absolute path to the file to write (must be absolute, not relative)",
38− Required: true,
26+type WritePermissionsParams struct {
27+ FilePath string `json:"file_path"`
28+ Content string `json:"content"`
29+}
30+
31+func (w *writeTool) Info() ToolInfo {
32+ return ToolInfo{
33+ Name: WriteToolName,
34+ Description: writeDescription(),
35+ Parameters: map[string]any{
36+ "file_path": map[string]any{
37+ "type": "string",
38+ "description": "The path to the file to write",
3939 },
40− "content": {
41− Type: "string",
42− Desc: "The content to write to the file",
43− Required: true,
40+ "content": map[string]any{
41+ "type": "string",
42+ "description": "The content to write to the file",
4443 },
45− }),
46− }, nil
44+ },
45+ Required: []string{"file_path", "content"},
46+ }
4747}
4848
49−func (b *writeTool) InvokableRun(ctx context.Context, args string, opts ...tool.Option) (string, error) {
49+// Run implements Tool.
50+func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
5051 var params WriteParams
51− if err := json.Unmarshal([]byte(args), &params); err != nil {
52− return "", fmt.Errorf("failed to parse parameters: %w", err)
52+ if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
53+ return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
5354 }
5455
5556 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
5762 }
5863
59− if !filepath.IsAbs(params.FilePath) {
60− return fmt.Sprintf("file path must be absolute, got: %s", params.FilePath), nil
64+ // Handle relative paths
65+ filePath := params.FilePath
66+ if !filepath.IsAbs(filePath) {
67+ filePath = filepath.Join(config.WorkingDirectory(), filePath)
6168 }
6269
63− // fileExists := false
64− // oldContent := ""
65− fileInfo, err := os.Stat(params.FilePath)
70+ // Check if file exists and is a directory
71+ fileInfo, err := os.Stat(filePath)
6672 if err == nil {
6773 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
6975 }
7076
77+ // Check if file was modified since last read
7178 modTime := fileInfo.ModTime()
72− lastRead := getLastReadTime(params.FilePath)
79+ lastRead := getLastReadTime(filePath)
7380 if modTime.After(lastRead) {
74− return fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
75− params.FilePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339)), nil
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.",
82+ filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))), nil
7683 }
7784
78− // oldContentBytes, readErr := os.ReadFile(params.FilePath)
79− // if readErr != nil {
80− // oldContent = string(oldContentBytes)
81− // }
85+ // Optional: Get old content for diff
86+ oldContent, readErr := os.ReadFile(filePath)
87+ if readErr == nil && string(oldContent) == params.Content {
88+ return NewTextErrorResponse(fmt.Sprintf("File %s already contains the exact content. No changes made.", filePath)), nil
89+ }
8290 } 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
8492 }
8593
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+ }
8699 p := permission.Default.Request(
87100 permission.CreatePermissionRequest{
88− Path: b.workingDir,
101+ Path: filePath,
89102 ToolName: WriteToolName,
90− Action: "write",
91− Description: fmt.Sprintf("Write to file %s", params.FilePath),
92− Params: map[string]interface{}{
93− "file_path": params.FilePath,
94− "contnet": params.Content,
103+ Action: "create",
104+ Description: fmt.Sprintf("Create file %s", filePath),
105+ Params: WritePermissionsParams{
106+ FilePath: filePath,
107+ Content: GenerateDiff("", params.Content),
95108 },
96109 },
97110 )
98111 if !p {
99− return "", fmt.Errorf("permission denied")
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
112+ return NewTextErrorResponse(fmt.Sprintf("Permission denied to create file: %s", filePath)), nil
104113 }
105114
106− err = os.WriteFile(params.FilePath, []byte(params.Content), 0o644)
115+ // Write the file
116+ err = os.WriteFile(filePath, []byte(params.Content), 0o644)
107117 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
109119 }
110120
111− recordFileWrite(params.FilePath)
112−
113− output := "File written: " + params.FilePath
121+ // Record the file write
122+ recordFileWrite(filePath)
123+ recordFileRead(filePath)
114124
115− // if fileExists && oldContent != params.Content {
116− // output = generateSimpleDiff(oldContent, params.Content)
117− // }
118−
119− return output, nil
125+ return NewTextResponse(fmt.Sprintf("File successfully written: %s", filePath)), nil
120126}
121127
122−func generateSimpleDiff(oldContent, newContent string) string {
123− if oldContent == newContent {
124− return "[No changes]"
125− }
128+func writeDescription() string {
129+ return `File writing tool that creates or updates files in the filesystem, allowing you to save or modify text content.
126130
127− oldLines := strings.Split(oldContent, "\n")
128− newLines := strings.Split(newContent, "\n")
131+WHEN TO USE THIS TOOL:
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
129135
130− var diffBuilder strings.Builder
131− diffBuilder.WriteString(fmt.Sprintf("@@ -%d,+%d @@\n", len(oldLines), len(newLines)))
136+HOW TO USE:
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
132140
133− maxLines := max(len(oldLines), len(newLines))
134− for i := range maxLines {
135− oldLine := ""
136− newLine := ""
141+FEATURES:
142+- Can create new files or overwrite existing ones
143+- Creates parent directories automatically if they don't exist
144+- Checks if the file has been modified since last read for safety
145+- Avoids unnecessary writes when content hasn't changed
137146
138− if i < len(oldLines) {
139− oldLine = oldLines[i]
140− }
147+LIMITATIONS:
148+- You should read a file before writing to it to avoid conflicts
149+- Cannot append to files (rewrites the entire file)
141150
142− if i < len(newLines) {
143− newLine = newLines[i]
144− }
145151
146− if oldLine != newLine {
147− if i < len(oldLines) {
148− diffBuilder.WriteString(fmt.Sprintf("- %s\n", oldLine))
149− }
150− if i < len(newLines) {
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()
152+TIPS:
153+- Use the View tool first to examine existing files before modifying them
154+- Use the LS tool to verify the correct location when creating new files
155+- Combine with Glob and Grep tools to find and modify multiple files
156+- Always include descriptive comments when making changes to existing code`
159157}
160158
161−func NewWriteTool(workingDir string) tool.InvokableTool {
162− return &writeTool{
163− workingDir: workingDir,
164− }
159+func NewWriteTool() BaseTool {
160+ return &writeTool{}
165161}
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
22
33import (
44 "context"
5+ "database/sql"
56 "encoding/json"
67
7− "github.com/cloudwego/eino/schema"
88 "github.com/google/uuid"
99 "github.com/kujtimiihoxha/termai/internal/db"
1010 "github.com/kujtimiihoxha/termai/internal/pubsub"
1111)
1212
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+
1336type Message struct {
14− ID string
15− SessionID string
16− MessageData schema.Message
37+ ID string
38+ SessionID string
39+
40+ // NEW
41+ Role MessageRole
42+ Content string
43+ Thinking string
1744
18− CreatedAt int64
19− UpdatedAt int64
45+ Finished bool
46+
47+ ToolResults []ToolResult
48+ ToolCalls []ToolCall
49+ CreatedAt int64
50+ UpdatedAt int64
51+}
52+
53+type CreateMessageParams struct {
54+ Role MessageRole
55+ Content string
56+ ToolCalls []ToolCall
57+ ToolResults []ToolResult
2058}
2159
2260type Service interface {
2361 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
2564 Get(id string) (Message, error)
2665 List(sessionID string) ([]Message, error)
2766 Delete(id string) error
@@ -34,35 +73,46 @@ type service struct {
3473 ctx context.Context
3574}
3675
37−func (s *service) Create(sessionID string, messageData schema.Message) (Message, error) {
38− messageDataJSON, err := json.Marshal(messageData)
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+
89+func (s *service) Create(sessionID string, params CreateMessageParams) (Message, error) {
90+ toolCallsStr, err := json.Marshal(params.ToolCalls)
91+ if err != nil {
92+ return Message{}, err
93+ }
94+ toolResultsStr, err := json.Marshal(params.ToolResults)
3995 if err != nil {
4096 return Message{}, err
4197 }
4298 dbMessage, err := s.q.CreateMessage(s.ctx, db.CreateMessageParams{
4399 ID: uuid.New().String(),
44100 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},
46106 })
47107 if err != nil {
48108 return Message{}, err
49109 }
50− message := 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)
110+ message, err := s.fromDBItem(dbMessage)
61111 if err != nil {
62− return err
112+ return Message{}, err
63113 }
64− s.Publish(pubsub.DeletedEvent, message)
65− return nil
114+ s.Publish(pubsub.CreatedEvent, message)
115+ return message, nil
66116}
67117
68118func (s *service) DeleteSessionMessages(sessionID string) error {
@@ -81,12 +131,36 @@ func (s *service) DeleteSessionMessages(sessionID string) error {
81131 return nil
82132}
83133
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+
84158func (s *service) Get(id string) (Message, error) {
85159 dbMessage, err := s.q.GetMessage(s.ctx, id)
86160 if err != nil {
87161 return Message{}, err
88162 }
89− return s.fromDBItem(dbMessage), nil
163+ return s.fromDBItem(dbMessage)
90164}
91165
92166func (s *service) List(sessionID string) ([]Message, error) {
@@ -96,21 +170,43 @@ func (s *service) List(sessionID string) ([]Message, error) {
96170 }
97171 messages := make([]Message, len(dbMessages))
98172 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+ }
100177 }
101178 return messages, nil
102179}
103180
104−func (s *service) fromDBItem(item db.Message) Message {
105− var messageData schema.Message
106− json.Unmarshal([]byte(item.MessageData), &messageData)
181+func (s *service) fromDBItem(item db.Message) (Message, error) {
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 {
192+ err := json.Unmarshal([]byte(item.ToolResults.String), &toolResults)
193+ if err != nil {
194+ return Message{}, err
195+ }
196+ }
197+
107198 return Message{
108199 ID: item.ID,
109200 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,
111207 CreatedAt: item.CreatedAt,
112208 UpdatedAt: item.UpdatedAt,
113− }
209+ }, nil
114210}
115211
116212func NewService(ctx context.Context, q db.Querier) Service {
internal/permission/permission.go+1
@@ -65,6 +65,7 @@ func (s *permissionService) Deny(permission PermissionRequest) {
6565func (s *permissionService) Request(opts CreatePermissionRequest) bool {
6666 permission := PermissionRequest{
6767 ID: uuid.New().String(),
68+ Path: opts.Path,
6869 ToolName: opts.ToolName,
6970 Description: opts.Description,
7071 Action: opts.Action,
internal/session/session.go+18
@@ -2,6 +2,7 @@ package session
22
33import (
44 "context"
5+ "database/sql"
56
67 "github.com/google/uuid"
78 "github.com/kujtimiihoxha/termai/internal/db"
@@ -10,6 +11,7 @@ import (
1011
1112type Session struct {
1213 ID string
14+ ParentSessionID string
1315 Title string
1416 MessageCount int64
1517 PromptTokens int64
@@ -22,6 +24,7 @@ type Session struct {
2224type Service interface {
2325 pubsub.Suscriber[Session]
2426 Create(title string) (Session, error)
27+ CreateTaskSession(toolCallID, parentSessionID, title string) (Session, error)
2528 Get(id string) (Session, error)
2629 List() ([]Session, error)
2730 Save(session Session) (Session, error)
@@ -47,6 +50,20 @@ func (s *service) Create(title string) (Session, error) {
4750 return session, nil
4851}
4952
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+
5067func (s *service) Delete(id string) error {
5168 session, err := s.Get(id)
5269 if err != nil {
@@ -99,6 +116,7 @@ func (s *service) List() ([]Session, error) {
99116func (s service) fromDBItem(item db.Session) Session {
100117 return Session{
101118 ID: item.ID,
119+ ParentSessionID: item.ParentSessionID.String,
102120 Title: item.Title,
103121 MessageCount: item.MessageCount,
104122 PromptTokens: item.PromptTokens,
internal/tui/components/core/dialog.go+37−4
@@ -14,7 +14,12 @@ type SizeableModel interface {
1414}
1515
1616type DialogMsg struct {
17− Content SizeableModel
17+ Content SizeableModel
18+ WidthRatio float64
19+ HeightRatio float64
20+
21+ MinWidth int
22+ MinHeight int
1823}
1924
2025type DialogCloseMsg struct{}
@@ -36,7 +41,18 @@ type DialogCmp interface {
3641}
3742
3843type 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
4056}
4157
4258func (d *dialogCmp) Init() tea.Cmd {
@@ -45,8 +61,26 @@ func (d *dialogCmp) Init() tea.Cmd {
4561
4662func (d *dialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
4763 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
4873 case DialogMsg:
4974 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+ }
5084 case DialogCloseMsg:
5185 d.content = nil
5286 return d, nil
@@ -75,8 +109,7 @@ func (d *dialogCmp) BindingKeys() []key.Binding {
75109}
76110
77111func (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())
80113}
81114
82115func NewDialogCmp() DialogCmp {
internal/tui/components/core/status.go+9−2
@@ -3,6 +3,8 @@ package core
33import (
44 tea "github.com/charmbracelet/bubbletea"
55 "github.com/charmbracelet/lipgloss"
6+ "github.com/kujtimiihoxha/termai/internal/config"
7+ "github.com/kujtimiihoxha/termai/internal/llm/models"
68 "github.com/kujtimiihoxha/termai/internal/tui/styles"
79 "github.com/kujtimiihoxha/termai/internal/tui/util"
810 "github.com/kujtimiihoxha/termai/internal/version"
@@ -57,14 +59,19 @@ func (m statusCmp) View() string {
5759 Width(m.availableFooterMsgWidth()).
5860 Render(m.info)
5961 }
60−
62+ status += m.model()
6163 status += versionWidget
6264 return status
6365}
6466
6567func (m statusCmp) availableFooterMsgWidth() int {
6668 // -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)
6875}
6976
7077func NewStatusCmp() tea.Model {
internal/tui/components/dialog/permission.go+135−50
@@ -1,9 +1,14 @@
11package dialog
22
33import (
4+ "fmt"
5+
46 "github.com/charmbracelet/bubbles/key"
7+ "github.com/charmbracelet/bubbles/viewport"
58 tea "github.com/charmbracelet/bubbletea"
9+ "github.com/charmbracelet/glamour"
610 "github.com/charmbracelet/lipgloss"
11+ "github.com/kujtimiihoxha/termai/internal/llm/tools"
712 "github.com/kujtimiihoxha/termai/internal/permission"
813 "github.com/kujtimiihoxha/termai/internal/tui/components/core"
914 "github.com/kujtimiihoxha/termai/internal/tui/layout"
@@ -28,12 +33,6 @@ type PermissionResponseMsg struct {
2833 Action PermissionAction
2934}
3035
31−// Width and height constants for the dialog
32−var (
33− permissionWidth = 60
34− permissionHeight = 10
35−)
36−
3736// PermissionDialog interface for permission dialog component
3837type PermissionDialog interface {
3938 tea.Model
@@ -41,13 +40,28 @@ type PermissionDialog interface {
4140 layout.Bindings
4241}
4342
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+
4454// permissionDialogCmp is the implementation of PermissionDialog
4555type permissionDialogCmp struct {
46− form *huh.Form
47− content string
48− width int
49− height int
50− permission permission.PermissionRequest
56+ form *huh.Form
57+ width int
58+ height int
59+ permission permission.PermissionRequest
60+ windowSize tea.WindowSizeMsg
61+ r *glamour.TermRenderer
62+ contentViewPort viewport.Model
63+ isViewportFocus bool
64+ selectOption *huh.Select[string]
5165}
5266
5367func (p *permissionDialogCmp) Init() tea.Cmd {
@@ -57,41 +71,101 @@ func (p *permissionDialogCmp) Init() tea.Cmd {
5771func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
5872 var cmds []tea.Cmd
5973
60− // Process the form
61− form, cmd := p.form.Update(msg)
62− if f, ok := form.(*huh.Form); ok {
63− p.form = f
64− cmds = append(cmds, cmd)
74+ switch msg := msg.(type) {
75+ case tea.WindowSizeMsg:
76+ p.windowSize = msg
77+ case tea.KeyMsg:
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+ }
6587 }
6688
67− if p.form.State == huh.StateCompleted {
68− // Get the selected action
69− action := p.form.GetString("action")
70−
71− // Close the dialog and return the response
72− return p, tea.Batch(
73− util.CmdHandler(core.DialogCloseMsg{}),
74− util.CmdHandler(PermissionResponseMsg{Action: PermissionAction(action), Permission: p.permission}),
75− )
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+
100+ if p.form.State == huh.StateCompleted {
101+ // Get the selected action
102+ action := p.form.GetString("action")
103+
104+ // Close the dialog and return the response
105+ return p, tea.Batch(
106+ util.CmdHandler(core.DialogCloseMsg{}),
107+ util.CmdHandler(PermissionResponseMsg{Action: PermissionAction(action), Permission: p.permission}),
108+ )
109+ }
76110 }
77−
78111 return p, tea.Batch(cmds...)
79112}
80113
81−func (p *permissionDialogCmp) View() string {
82− contentStyle := lipgloss.NewStyle().
83− Width(p.width).
84− Padding(1, 0).
85− Foreground(styles.Text).
86− Align(lipgloss.Center)
114+func (p *permissionDialogCmp) render() string {
115+ form := p.form.View()
116+ keyStyle := lipgloss.NewStyle().Bold(true).Foreground(styles.Rosewater)
117+ valueStyle := lipgloss.NewStyle().Foreground(styles.Peach)
118+
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)
87156
88157 return lipgloss.JoinVertical(
89− lipgloss.Center,
90− contentStyle.Render(p.content),
91− p.form.View(),
158+ lipgloss.Top,
159+ headerContent,
160+ cotentStyle.Render(p.contentViewPort.View()),
161+ form,
92162 )
93163}
94164
165+func (p *permissionDialogCmp) View() string {
166+ return p.render()
167+}
168+
95169func (p *permissionDialogCmp) GetSize() (int, int) {
96170 return p.width, p.height
97171}
@@ -99,13 +173,14 @@ func (p *permissionDialogCmp) GetSize() (int, int) {
99173func (p *permissionDialogCmp) SetSize(width int, height int) {
100174 p.width = width
101175 p.height = height
176+ p.form = p.form.WithWidth(width)
102177}
103178
104179func (p *permissionDialogCmp) BindingKeys() []key.Binding {
105180 return p.form.KeyBinds()
106181}
107182
108−func newPermissionDialogCmp(permission permission.PermissionRequest, content string) PermissionDialog {
183+func newPermissionDialogCmp(permission permission.PermissionRequest) PermissionDialog {
109184 // Create a note field for displaying the content
110185
111186 // Create select field for the permission options
@@ -116,14 +191,13 @@ func newPermissionDialogCmp(permission permission.PermissionRequest, content str
116191 huh.NewOption("Allow for this session", string(PermissionAllowForSession)),
117192 huh.NewOption("Deny", string(PermissionDeny)),
118193 ).
119− Title("Permission Request")
194+ Title("Select an action")
120195
121196 // Apply theme
122197 theme := styles.HuhTheme()
123198
124199 // Setup form width and height
125200 form := huh.NewForm(huh.NewGroup(selectOption)).
126− WithWidth(permissionWidth - 2).
127201 WithShowHelp(false).
128202 WithTheme(theme).
129203 WithShowErrors(false)
@@ -132,25 +206,22 @@ func newPermissionDialogCmp(permission permission.PermissionRequest, content str
132206 selectOption.Focus()
133207
134208 return &permissionDialogCmp{
135− permission: permission,
136− form: form,
137− content: content,
138− width: permissionWidth,
139− height: permissionHeight,
209+ permission: permission,
210+ form: form,
211+ selectOption: selectOption,
140212 }
141213}
142214
143215// NewPermissionDialogCmd creates a new permission dialog command
144−func NewPermissionDialogCmd(permission permission.PermissionRequest, content string) tea.Cmd {
145− permDialog := newPermissionDialogCmp(permission, content)
216+func NewPermissionDialogCmd(permission permission.PermissionRequest) tea.Cmd {
217+ permDialog := newPermissionDialogCmp(permission)
146218
147219 // Create the dialog layout
148220 dialogPane := layout.NewSinglePane(
149221 permDialog.(*permissionDialogCmp),
150− layout.WithSignlePaneSize(permissionWidth+2, permissionHeight+2),
151222 layout.WithSinglePaneBordered(true),
152223 layout.WithSinglePaneFocusable(true),
153− layout.WithSinglePaneActiveColor(styles.Blue),
224+ layout.WithSinglePaneActiveColor(styles.Warning),
154225 layout.WithSignlePaneBorderText(map[layout.BorderPosition]string{
155226 layout.TopMiddleBorder: " Permission Required ",
156227 }),
@@ -158,10 +229,24 @@ func NewPermissionDialogCmd(permission permission.PermissionRequest, content str
158229
159230 // Focus the dialog
160231 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+ }
162244 // Return the dialog command
163245 return util.CmdHandler(core.DialogMsg{
164− Content: dialogPane,
246+ Content: dialogPane,
247+ WidthRatio: widthRatio,
248+ HeightRatio: heightRatio,
249+ MinWidth: minWidth,
250+ MinHeight: minHeight,
165251 })
166252}
167−
internal/tui/components/dialog/quit.go+9−14
@@ -3,7 +3,6 @@ package dialog
33import (
44 "github.com/charmbracelet/bubbles/key"
55 tea "github.com/charmbracelet/bubbletea"
6− "github.com/charmbracelet/lipgloss"
76 "github.com/kujtimiihoxha/termai/internal/tui/components/core"
87 "github.com/kujtimiihoxha/termai/internal/tui/layout"
98 "github.com/kujtimiihoxha/termai/internal/tui/styles"
@@ -14,11 +13,6 @@ import (
1413
1514const question = "Are you sure you want to quit?"
1615
17−var (
18− width = lipgloss.Width(question) + 6
19− height = 3
20−)
21−
2216type QuitDialog interface {
2317 tea.Model
2418 layout.Sizeable
@@ -37,8 +31,6 @@ func (q *quitDialogCmp) Init() tea.Cmd {
3731
3832func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
3933 var cmds []tea.Cmd
40−
41− // Process the form
4234 form, cmd := q.form.Update(msg)
4335 if f, ok := form.(*huh.Form); ok {
4436 q.form = f
@@ -67,6 +59,7 @@ func (q *quitDialogCmp) GetSize() (int, int) {
6759func (q *quitDialogCmp) SetSize(width int, height int) {
6860 q.width = width
6961 q.height = height
62+ q.form = q.form.WithWidth(width).WithHeight(height)
7063}
7164
7265func (q *quitDialogCmp) BindingKeys() []key.Binding {
@@ -84,28 +77,30 @@ func newQuitDialogCmp() QuitDialog {
8477 theme.Focused.FocusedButton = theme.Focused.FocusedButton.Background(styles.Warning)
8578 theme.Blurred.FocusedButton = theme.Blurred.FocusedButton.Background(styles.Warning)
8679 form := huh.NewForm(huh.NewGroup(confirm)).
87− WithWidth(width).
88− WithHeight(height).
8980 WithShowHelp(false).
81+ WithWidth(0).
82+ WithHeight(0).
9083 WithTheme(theme).
9184 WithShowErrors(false)
9285 confirm.Focus()
9386 return &quitDialogCmp{
94− form: form,
95− width: width,
87+ form: form,
9688 }
9789}
9890
9991func NewQuitDialogCmd() tea.Cmd {
10092 content := layout.NewSinglePane(
10193 newQuitDialogCmp().(*quitDialogCmp),
102− layout.WithSignlePaneSize(width+2, height+2),
10394 layout.WithSinglePaneBordered(true),
10495 layout.WithSinglePaneFocusable(true),
10596 layout.WithSinglePaneActiveColor(styles.Warning),
10697 )
10798 content.Focus()
10899 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,
110105 })
111106}
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 (
66 "github.com/charmbracelet/bubbles/key"
77 tea "github.com/charmbracelet/bubbletea"
88 "github.com/charmbracelet/lipgloss"
9− "github.com/cloudwego/eino/schema"
109 "github.com/kujtimiihoxha/termai/internal/app"
10+ "github.com/kujtimiihoxha/termai/internal/llm/agent"
1111 "github.com/kujtimiihoxha/termai/internal/tui/layout"
1212 "github.com/kujtimiihoxha/termai/internal/tui/styles"
13+ "github.com/kujtimiihoxha/termai/internal/tui/util"
1314 "github.com/kujtimiihoxha/vimtea"
1415)
1516
@@ -112,7 +113,7 @@ func (m *editorCmp) BorderText() map[layout.BorderPosition]string {
112113 title = lipgloss.NewStyle().Foreground(styles.Primary).Render(title)
113114 }
114115 return map[layout.BorderPosition]string{
115− layout.TopLeftBorder: title,
116+ layout.BottomLeftBorder: title,
116117 }
117118}
118119
@@ -137,9 +138,15 @@ func (m *editorCmp) SetSize(width int, height int) {
137138
138139func (m *editorCmp) Send() tea.Cmd {
139140 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+
140147 content := strings.Join(m.editor.GetBuffer().Lines(), "\n")
141− m.app.Messages.Create(m.sessionID, *schema.UserMessage(content))
142− m.app.LLM.SendRequest(m.sessionID, content)
148+ go a.Generate(m.sessionID, content)
149+
143150 return m.editor.Reset()
144151 }
145152}
@@ -153,10 +160,11 @@ func (m *editorCmp) BindingKeys() []key.Binding {
153160}
154161
155162func NewEditorCmp(app *app.App) EditorCmp {
163+ editor := vimtea.NewEditor(
164+ vimtea.WithFileName("message.md"),
165+ )
156166 return &editorCmp{
157− app: app,
158− editor: vimtea.NewEditor(
159− vimtea.WithFileName("message.md"),
160− ),
167+ app: app,
168+ editor: editor,
161169 }
162170}
internal/tui/components/repl/messages.go+296−89
@@ -1,8 +1,9 @@
11package repl
22
33import (
4+ "encoding/json"
45 "fmt"
5− "slices"
6+ "sort"
67 "strings"
78
89 "github.com/charmbracelet/bubbles/key"
@@ -10,8 +11,8 @@ import (
1011 tea "github.com/charmbracelet/bubbletea"
1112 "github.com/charmbracelet/glamour"
1213 "github.com/charmbracelet/lipgloss"
13− "github.com/cloudwego/eino/schema"
1414 "github.com/kujtimiihoxha/termai/internal/app"
15+ "github.com/kujtimiihoxha/termai/internal/llm/agent"
1516 "github.com/kujtimiihoxha/termai/internal/message"
1617 "github.com/kujtimiihoxha/termai/internal/pubsub"
1718 "github.com/kujtimiihoxha/termai/internal/session"
@@ -28,30 +29,50 @@ type MessagesCmp interface {
2829}
2930
3031type messagesCmp struct {
31− app *app.App
32− messages []message.Message
33− session session.Session
34− viewport viewport.Model
35− mdRenderer *glamour.TermRenderer
36− width int
37− height int
38− focused bool
39− cachedView string
32+ app *app.App
33+ messages []message.Message
34+ selectedMsgIdx int // Index of the selected message
35+ session session.Session
36+ viewport viewport.Model
37+ mdRenderer *glamour.TermRenderer
38+ width int
39+ height int
40+ focused bool
41+ cachedView string
4042}
4143
4244func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
4345 switch msg := msg.(type) {
4446 case pubsub.Event[message.Message]:
4547 if msg.Type == pubsub.CreatedEvent {
46− m.messages = append(m.messages, msg.Payload)
47− m.renderView()
48− m.viewport.GotoBottom()
48+ if msg.Payload.SessionID == m.session.ID {
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 {
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 {
67+ m.viewport.GotoBottom()
68+ }
69+ break
70+ }
71+ }
4972 }
5073 case pubsub.Event[session.Session]:
51− if msg.Type == pubsub.UpdatedEvent {
52− if m.session.ID == msg.Payload.ID {
53− m.session = msg.Payload
54− }
74+ if msg.Type == pubsub.UpdatedEvent && m.session.ID == msg.Payload.ID {
75+ m.session = msg.Payload
5576 }
5677 case SelectedSessionMsg:
5778 m.session, _ = m.app.Sessions.Get(msg.SessionID)
@@ -67,26 +88,24 @@ func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
6788 return m, nil
6889}
6990
70−func borderColor(role schema.RoleType) lipgloss.TerminalColor {
91+func borderColor(role message.MessageRole) lipgloss.TerminalColor {
7192 switch role {
72− case schema.Assistant:
93+ case message.Assistant:
7394 return styles.Mauve
74− case schema.User:
95+ case message.User:
7596 return styles.Rosewater
76− case schema.Tool:
77− return styles.Peach
7897 }
7998 return styles.Blue
8099}
81100
82−func borderText(msgRole schema.RoleType, currentMessage int) map[layout.BorderPosition]string {
101+func borderText(msgRole message.MessageRole, currentMessage int) map[layout.BorderPosition]string {
83102 role := ""
84103 icon := ""
85104 switch msgRole {
86− case schema.Assistant:
105+ case message.Assistant:
87106 role = "Assistant"
88107 icon = styles.BotIcon
89− case schema.User:
108+ case message.User:
90109 role = "User"
91110 icon = styles.UserIcon
92111 }
@@ -106,81 +125,259 @@ func borderText(msgRole schema.RoleType, currentMessage int) map[layout.BorderPo
106125 }
107126}
108127
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+
109329func (m *messagesCmp) renderView() {
110330 stringMessages := make([]string, 0)
111331 r, _ := glamour.NewTermRenderer(
112332 glamour.WithStyles(styles.CatppuccinMarkdownStyle()),
113− glamour.WithWordWrap(m.width-10),
333+ glamour.WithWordWrap(m.width-20),
114334 glamour.WithEmoji(),
115335 )
116336 textStyle := lipgloss.NewStyle().Width(m.width - 4)
117337 currentMessage := 1
118− for _, msg := range m.messages {
119− if msg.MessageData.Role == schema.Tool {
120− continue
121− }
122− content := msg.MessageData.Content
123− if content != "" {
124− content, _ = r.Render(msg.MessageData.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..."
144− } else {
145− result := m.messages[resultInx].MessageData.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− }
338+ displayedMsgCount := 0 // Track the actual displayed messages count
339+
340+ prevMessageWasUser := false
341+ for inx, msg := range m.messages {
342+ content := msg.Content
343+ if content != "" || prevMessageWasUser {
344+ if msg.Thinking != "" && content == "" {
345+ content = msg.Thinking
346+ } else if content == "" {
347+ content = "..."
156348 }
157349 content, _ = r.Render(content)
158− stringMessages = append(stringMessages, layout.Borderize(
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+
360+ content = layout.Borderize(
159361 textStyle.Render(content),
160362 layout.BorderOptions{
161− InactiveBorder: lipgloss.DoubleBorder(),
162− ActiveBorder: lipgloss.DoubleBorder(),
163− ActiveColor: borderColor(schema.Tool),
164− InactiveColor: borderColor(schema.Tool),
165− EmbeddedText: map[layout.BorderPosition]string{
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− },
363+ InactiveBorder: border,
364+ ActiveBorder: border,
365+ ActiveColor: activeColor,
366+ InactiveColor: borderColor(msg.Role),
367+ EmbeddedText: borderText(msg.Role, currentMessage),
181368 },
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)
183374 currentMessage++
375+ displayedMsgCount++
376+ }
377+ if msg.Role == message.User && msg.Content != "" {
378+ prevMessageWasUser = true
379+ } else {
380+ prevMessageWasUser = false
184381 }
185382 }
186383 m.viewport.SetContent(lipgloss.JoinVertical(lipgloss.Top, stringMessages...))
@@ -191,7 +388,9 @@ func (m *messagesCmp) View() string {
191388}
192389
193390func (m *messagesCmp) BindingKeys() []key.Binding {
194− return layout.KeyMapToSlice(m.viewport.KeyMap)
391+ keys := layout.KeyMapToSlice(m.viewport.KeyMap)
392+
393+ return keys
195394}
196395
197396func (m *messagesCmp) Blur() tea.Cmd {
@@ -208,10 +407,17 @@ func (m *messagesCmp) BorderText() map[layout.BorderPosition]string {
208407 if m.focused {
209408 title = lipgloss.NewStyle().Foreground(styles.Primary).Render(title)
210409 }
211− return map[layout.BorderPosition]string{
410+ borderTest := map[layout.BorderPosition]string{
212411 layout.TopLeftBorder: title,
213412 layout.BottomRightBorder: formatTokensAndCost(m.session.CompletionTokens+m.session.PromptTokens, m.session.Cost),
214413 }
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
215421}
216422
217423func (m *messagesCmp) Focus() tea.Cmd {
@@ -232,6 +438,7 @@ func (m *messagesCmp) SetSize(width int, height int) {
232438 m.height = height
233439 m.viewport.Width = width - 2 // padding
234440 m.viewport.Height = height - 2 // padding
441+ m.renderView()
235442}
236443
237444func (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) {
8989 }
9090 return i, i.list.SetItems(items)
9191 case pubsub.Event[session.Session]:
92− if msg.Type == pubsub.UpdatedEvent {
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)
108+ } else if msg.Type == pubsub.UpdatedEvent {
93109 // update the session in the list
94110 items := i.list.Items()
95111 for idx, item := range items {
@@ -229,3 +245,4 @@ func NewSessionsCmp(app *app.App) SessionsCmp {
229245 focused: false,
230246 }
231247}
248+
internal/tui/page/init.go+4−4
@@ -78,12 +78,12 @@ func (i *initPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
7878 // Save configuration to file
7979 configPath := filepath.Join(os.Getenv("HOME"), ".termai.yaml")
8080 maxTokens, _ := strconv.Atoi(i.maxTokens)
81− config := map[string]interface{}{
81+ config := map[string]any{
8282 "models": map[string]string{
8383 "big": i.bigModel,
8484 "small": i.smallModel,
8585 },
86− "providers": map[string]interface{}{
86+ "providers": map[string]any{
8787 "openai": map[string]string{
8888 "key": i.openAIKey,
8989 },
@@ -192,8 +192,8 @@ func NewInitPage() tea.Model {
192192 // Init page with form
193193 initModel := &initPage{
194194 modelOpts: modelOpts,
195− bigModel: string(models.DefaultBigModel),
196− smallModel: string(models.DefaultLittleModel),
195+ bigModel: string(models.Claude37Sonnet),
196+ smallModel: string(models.Claude37Sonnet),
197197 maxTokens: "4000",
198198 dataDir: ".termai",
199199 agent: "coder",
internal/tui/styles/icons.go+4
@@ -8,5 +8,9 @@ const (
88 ToolIcon string = ""
99 UserIcon string = ""
1010
11+ CheckIcon string = "✓"
12+ ErrorIcon string = "✗"
13+ SpinnerIcon string = "..."
14+
1115 SleepIcon string = "󰒲"
1216)
internal/tui/styles/markdown.go+3−3
@@ -5,7 +5,7 @@ import (
55 "github.com/charmbracelet/lipgloss"
66)
77
8−const defaultMargin = 2
8+const defaultMargin = 1
99
1010// Helper functions for style pointers
1111func boolPtr(b bool) *bool { return &b }
@@ -25,7 +25,7 @@ var catppuccinDark = ansi.StyleConfig{
2525 Document: ansi.StyleBlock{
2626 StylePrimitive: ansi.StylePrimitive{
2727 BlockPrefix: "\n",
28− BlockSuffix: "\n",
28+ BlockSuffix: "",
2929 Color: stringPtr(dark.Text().Hex),
3030 },
3131 Margin: uintPtr(defaultMargin),
@@ -153,7 +153,7 @@ var catppuccinDark = ansi.StyleConfig{
153153 CodeBlock: ansi.StyleCodeBlock{
154154 StyleBlock: ansi.StyleBlock{
155155 StylePrimitive: ansi.StylePrimitive{
156− Prefix: " ",
156+ Prefix: " ",
157157 Color: stringPtr(dark.Text().Hex),
158158 },
159159
internal/tui/styles/styles.go+1−2
@@ -20,8 +20,7 @@ var (
2020 DoubleBorder = Regular.Border(lipgloss.DoubleBorder())
2121
2222 // Colors
23− White = lipgloss.Color("#ffffff")
24−
23+ White = lipgloss.Color("#ffffff")
2524 Surface0 = lipgloss.AdaptiveColor{
2625 Dark: dark.Surface0().Hex,
2726 Light: light.Surface0().Hex,
internal/tui/tui.go+40−29
@@ -1,20 +1,15 @@
11package tui
22
33import (
4− "fmt"
5− "log"
6− "os"
7− "path/filepath"
8−
94 "github.com/charmbracelet/bubbles/key"
105 tea "github.com/charmbracelet/bubbletea"
116 "github.com/charmbracelet/lipgloss"
127 "github.com/kujtimiihoxha/termai/internal/app"
13− "github.com/kujtimiihoxha/termai/internal/llm"
148 "github.com/kujtimiihoxha/termai/internal/permission"
159 "github.com/kujtimiihoxha/termai/internal/pubsub"
1610 "github.com/kujtimiihoxha/termai/internal/tui/components/core"
1711 "github.com/kujtimiihoxha/termai/internal/tui/components/dialog"
12+ "github.com/kujtimiihoxha/termai/internal/tui/components/repl"
1813 "github.com/kujtimiihoxha/termai/internal/tui/layout"
1914 "github.com/kujtimiihoxha/termai/internal/tui/page"
2015 "github.com/kujtimiihoxha/termai/internal/tui/util"
@@ -52,9 +47,9 @@ var keys = keyMap{
5247 ),
5348}
5449
55−var editorKeyMap = key.NewBinding(
56− key.WithKeys("i"),
57− key.WithHelp("i", "insert mode"),
50+var replKeyMap = key.NewBinding(
51+ key.WithKeys("N"),
52+ key.WithHelp("N", "new session"),
5853)
5954
6055type appModel struct {
@@ -66,6 +61,7 @@ type appModel struct {
6661 status tea.Model
6762 help core.HelpCmp
6863 dialog core.DialogCmp
64+ app *app.App
6965 dialogVisible bool
7066 editorMode vimtea.EditorMode
7167 showHelp bool
@@ -79,19 +75,8 @@ func (a appModel) Init() tea.Cmd {
7975
8076func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
8177 switch msg := msg.(type) {
82− case pubsub.Event[llm.AgentEvent]:
83− log.Println("AgentEvent")
84− log.Println(msg)
8578 case pubsub.Event[permission.PermissionRequest]:
86− return a, dialog.NewPermissionDialogCmd(
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− )
79+ return a, dialog.NewPermissionDialogCmd(msg.Payload)
9580 case dialog.PermissionResponseMsg:
9681 switch msg.Action {
9782 case dialog.PermissionAllow:
@@ -104,6 +89,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
10489 case vimtea.EditorModeMsg:
10590 a.editorMode = msg.Mode
10691 case tea.WindowSizeMsg:
92+ var cmds []tea.Cmd
10793 msg.Height -= 1 // Make space for the status bar
10894 a.width, a.height = msg.Width, msg.Height
10995
@@ -113,8 +99,14 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
11399 a.help = uh.(core.HelpCmp)
114100
115101 p, cmd := a.pages[a.currentPage].Update(msg)
102+ cmds = append(cmds, cmd)
116103 a.pages[a.currentPage] = p
117− return a, cmd
104+
105+ d, cmd := a.dialog.Update(msg)
106+ cmds = append(cmds, cmd)
107+ a.dialog = d.(core.DialogCmp)
108+
109+ return a, tea.Batch(cmds...)
118110 case core.DialogMsg:
119111 d, cmd := a.dialog.Update(msg)
120112 a.dialog = d.(core.DialogCmp)
@@ -145,6 +137,22 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
145137 a.ToggleHelp()
146138 return a, nil
147139 }
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+ }
148156 case key.Matches(msg, keys.Logs):
149157 return a, a.moveToPage(page.LogsPage)
150158 case key.Matches(msg, keys.Help):
@@ -205,6 +213,9 @@ func (a appModel) View() string {
205213 if a.dialogVisible {
206214 bindings = append(bindings, a.dialog.BindingKeys()...)
207215 }
216+ if a.currentPage == page.ReplPage {
217+ bindings = append(bindings, replKeyMap)
218+ }
208219 a.help.SetBindings(bindings)
209220 components = append(components, a.help.View())
210221 }
@@ -231,14 +242,13 @@ func (a appModel) View() string {
231242}
232243
233244func New(app *app.App) tea.Model {
234− // Check if config file exists, if not, start with init page
235− homedir, _ := os.UserHomeDir()
236− configPath := filepath.Join(homedir, ".termai.yaml")
237−
245+ // homedir, _ := os.UserHomeDir()
246+ // configPath := filepath.Join(homedir, ".termai.yaml")
247+ //
238248 startPage := page.ReplPage
239− if _, err := os.Stat(configPath); os.IsNotExist(err) {
240− startPage = page.InitPage
241− }
249+ // if _, err := os.Stat(configPath); os.IsNotExist(err) {
250+ // startPage = page.InitPage
251+ // }
242252
243253 return &appModel{
244254 currentPage: startPage,
@@ -246,6 +256,7 @@ func New(app *app.App) tea.Model {
246256 status: core.NewStatusCmp(),
247257 help: core.NewHelpCmp(),
248258 dialog: core.NewDialogCmp(),
259+ app: app,
249260 pages: map[page.PageID]tea.Model{
250261 page.LogsPage: page.NewLogsPage(),
251262 page.InitPage: page.NewInitPage(),