on main help sign in
additional tools
904061cKujtim Hoxha
README.mdnew file+13
@@ -0,0 +1,13 @@
1+# TermAI
2+
3+**⚠️ WORK IN PROGRESS ⚠️**
4+
5+This project is currently under active development.
6+
7+## Current Progress
8+
9+- Initial CLI setup
10+- Basic functionality implementation
11+- Working on core features
12+
13+More details coming soon.
cmd/root.go+14
@@ -10,6 +10,7 @@ import (
10 "github.com/kujtimiihoxha/termai/internal/db"
10 "github.com/kujtimiihoxha/termai/internal/db"
11 "github.com/kujtimiihoxha/termai/internal/llm/models"
11 "github.com/kujtimiihoxha/termai/internal/llm/models"
12 "github.com/kujtimiihoxha/termai/internal/tui"
12 "github.com/kujtimiihoxha/termai/internal/tui"
13+ zone "github.com/lrstanley/bubblezone"
13 "github.com/spf13/cobra"
14 "github.com/spf13/cobra"
14 "github.com/spf13/viper"
15 "github.com/spf13/viper"
15)
16)
@@ -37,9 +38,11 @@ var rootCmd = &cobra.Command{
37
38
38 app := app.New(ctx, conn)
39 app := app.New(ctx, conn)
39 app.Logger.Info("Starting termai...")
40 app.Logger.Info("Starting termai...")
41+ zone.NewGlobal()
40 tui := tea.NewProgram(
42 tui := tea.NewProgram(
41 tui.New(app),
43 tui.New(app),
42 tea.WithAltScreen(),
44 tea.WithAltScreen(),
45+ tea.WithMouseCellMotion(),
43 )
46 )
44 app.Logger.Info("Setting up subscriptions...")
47 app.Logger.Info("Setting up subscriptions...")
45 ch, unsub := setupSubscriptions(app)
48 ch, unsub := setupSubscriptions(app)
@@ -102,6 +105,16 @@ func setupSubscriptions(app *app.App) (chan tea.Msg, func()) {
102 wg.Done()
105 wg.Done()
103 }()
106 }()
104 }
107 }
108+ {
109+ sub := app.Permissions.Subscribe(ctx)
110+ wg.Add(1)
111+ go func() {
112+ for ev := range sub {
113+ ch <- ev
114+ }
115+ wg.Done()
116+ }()
117+ }
105 return ch, func() {
118 return ch, func() {
106 cancel()
119 cancel()
107 wg.Wait()
120 wg.Wait()
@@ -130,6 +143,7 @@ func loadConfig() {
130 // LLM
143 // LLM
131 viper.SetDefault("models.big", string(models.DefaultBigModel))
144 viper.SetDefault("models.big", string(models.DefaultBigModel))
132 viper.SetDefault("models.small", string(models.DefaultLittleModel))
145 viper.SetDefault("models.small", string(models.DefaultLittleModel))
146+
133 viper.SetDefault("providers.openai.key", os.Getenv("OPENAI_API_KEY"))
147 viper.SetDefault("providers.openai.key", os.Getenv("OPENAI_API_KEY"))
134 viper.SetDefault("providers.anthropic.key", os.Getenv("ANTHROPIC_API_KEY"))
148 viper.SetDefault("providers.anthropic.key", os.Getenv("ANTHROPIC_API_KEY"))
135 viper.SetDefault("providers.groq.key", os.Getenv("GROQ_API_KEY"))
149 viper.SetDefault("providers.groq.key", os.Getenv("GROQ_API_KEY"))
go.mod+5−1
@@ -3,6 +3,7 @@ module github.com/kujtimiihoxha/termai
3go 1.23.5
3go 1.23.5
4
4
5require (
5require (
6+ github.com/bmatcuk/doublestar/v4 v4.8.1
6 github.com/catppuccin/go v0.3.0
7 github.com/catppuccin/go v0.3.0
7 github.com/charmbracelet/bubbles v0.20.0
8 github.com/charmbracelet/bubbles v0.20.0
8 github.com/charmbracelet/bubbletea v1.3.4
9 github.com/charmbracelet/bubbletea v1.3.4
@@ -16,11 +17,13 @@ require (
16 github.com/golang-migrate/migrate/v4 v4.18.2
17 github.com/golang-migrate/migrate/v4 v4.18.2
17 github.com/google/uuid v1.6.0
18 github.com/google/uuid v1.6.0
18 github.com/kujtimiihoxha/vimtea v0.0.3-0.20250317175717-9d8ba9c69840
19 github.com/kujtimiihoxha/vimtea v0.0.3-0.20250317175717-9d8ba9c69840
20+ github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231
19 github.com/mattn/go-runewidth v0.0.16
21 github.com/mattn/go-runewidth v0.0.16
20 github.com/mattn/go-sqlite3 v1.14.24
22 github.com/mattn/go-sqlite3 v1.14.24
21 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
23 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
22 github.com/muesli/reflow v0.3.0
24 github.com/muesli/reflow v0.3.0
23 github.com/muesli/termenv v0.16.0
25 github.com/muesli/termenv v0.16.0
26+ github.com/sergi/go-diff v1.3.1
24 github.com/spf13/cobra v1.9.1
27 github.com/spf13/cobra v1.9.1
25 github.com/spf13/viper v1.20.0
28 github.com/spf13/viper v1.20.0
26 golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1
29 golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1
@@ -112,7 +115,8 @@ require (
112 golang.org/x/arch v0.11.0 // indirect
115 golang.org/x/arch v0.11.0 // indirect
113 golang.org/x/net v0.33.0 // indirect
116 golang.org/x/net v0.33.0 // indirect
114 golang.org/x/sync v0.12.0 // indirect
117 golang.org/x/sync v0.12.0 // indirect
115− golang.org/x/sys v0.30.0 // indirect
118+ golang.org/x/sys v0.31.0 // indirect
119+ golang.org/x/term v0.30.0 // indirect
116 golang.org/x/text v0.23.0 // indirect
120 golang.org/x/text v0.23.0 // indirect
117 gopkg.in/yaml.v2 v2.4.0 // indirect
121 gopkg.in/yaml.v2 v2.4.0 // indirect
118 gopkg.in/yaml.v3 v3.0.1 // indirect
122 gopkg.in/yaml.v3 v3.0.1 // indirect
go.sum+9−2
@@ -46,6 +46,8 @@ github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/
46github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
46github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
47github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
47github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
48github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
48github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
49+github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
50+github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
49github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
51github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
50github.com/bugsnag/bugsnag-go v1.4.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8=
52github.com/bugsnag/bugsnag-go v1.4.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8=
51github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE=
53github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE=
@@ -175,6 +177,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
175github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
177github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
176github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
178github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
177github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
179github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
180+github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231 h1:9rjt7AfnrXKNSZhp36A3/4QAZAwGGCGD/p8Bse26zms=
181+github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231/go.mod h1:S5etECMx+sZnW0Gm100Ma9J1PgVCTgNyFaqGu2b08b4=
178github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
182github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
179github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
183github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
180github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
184github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
@@ -241,6 +245,8 @@ github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
241github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
245github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
242github.com/sashabaranov/go-openai v1.32.5 h1:/eNVa8KzlE7mJdKPZDj6886MUzZQjoVHyn0sLvIt5qA=
246github.com/sashabaranov/go-openai v1.32.5 h1:/eNVa8KzlE7mJdKPZDj6886MUzZQjoVHyn0sLvIt5qA=
243github.com/sashabaranov/go-openai v1.32.5/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
247github.com/sashabaranov/go-openai v1.32.5/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
248+github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
249+github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
244github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
250github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
245github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
251github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
246github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
252github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
@@ -268,6 +274,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
268github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
274github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
269github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
275github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
270github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
276github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
277+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
271github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
278github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
272github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
279github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
273github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
280github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
@@ -327,8 +334,8 @@ golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5h
327golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
334golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
328golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
335golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
329golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
336golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
330−golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
337+golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
331−golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
338+golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
332golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
339golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
333golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
340golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
334golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
341golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
internal/app/services.go+11−8
@@ -8,6 +8,7 @@ import (
8 "github.com/kujtimiihoxha/termai/internal/llm"
8 "github.com/kujtimiihoxha/termai/internal/llm"
9 "github.com/kujtimiihoxha/termai/internal/logging"
9 "github.com/kujtimiihoxha/termai/internal/logging"
10 "github.com/kujtimiihoxha/termai/internal/message"
10 "github.com/kujtimiihoxha/termai/internal/message"
11+ "github.com/kujtimiihoxha/termai/internal/permission"
11 "github.com/kujtimiihoxha/termai/internal/session"
12 "github.com/kujtimiihoxha/termai/internal/session"
12 "github.com/spf13/viper"
13 "github.com/spf13/viper"
13)
14)
@@ -15,9 +16,10 @@ import (
15type App struct {
16type App struct {
16 Context context.Context
17 Context context.Context
17
18
18− Sessions session.Service
19+ Sessions session.Service
19− Messages message.Service
20+ Messages message.Service
21+ Permissions permission.Service
20− LLM llm.Service
22+ LLM llm.Service
21
23
22 Logger logging.Interface
24 Logger logging.Interface
23}
25}
@@ -32,10 +34,11 @@ func New(ctx context.Context, conn *sql.DB) *App {
32 llm := llm.NewService(ctx, log, sessions, messages)
34 llm := llm.NewService(ctx, log, sessions, messages)
33
35
34 return &App{
36 return &App{
35− Context: ctx,
37+ Context: ctx,
36− Sessions: sessions,
38+ Sessions: sessions,
37− Messages: messages,
39+ Messages: messages,
40+ Permissions: permission.Default,
38− LLM: llm,
41+ LLM: llm,
39− Logger: log,
42+ Logger: log,
40 }
43 }
41}
44}
internal/llm/agent/coder.go+8−1
@@ -17,8 +17,15 @@ import (
17)
17)
18
18
19func coderTools() []tool.BaseTool {
19func coderTools() []tool.BaseTool {
20+ wd := viper.GetString("wd")
20 return []tool.BaseTool{
21 return []tool.BaseTool{
22+ tools.NewAgentTool(wd),
21− tools.NewBashTool(viper.GetString("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),
22 }
29 }
23}
30}
24
31
internal/llm/llm.go−2
@@ -88,7 +88,6 @@ func (s *service) handleRequest(id string, sessionID string, content string) {
88 return
88 return
89 }
89 }
90
90
91− log.Printf("Request: %s", content)
92 currentAgent, systemMessage, err := agent.GetAgent(s.ctx, viper.GetString("agents.default"))
91 currentAgent, systemMessage, err := agent.GetAgent(s.ctx, viper.GetString("agents.default"))
93 if err != nil {
92 if err != nil {
94 s.Publish(AgentErrorEvent, AgentEvent{
93 s.Publish(AgentErrorEvent, AgentEvent{
@@ -172,7 +171,6 @@ func (s *service) handleRequest(id string, sessionID string, content string) {
172 }
171 }
173 session.PromptTokens += int64(usage.PromptTokens)
172 session.PromptTokens += int64(usage.PromptTokens)
174 session.CompletionTokens += int64(usage.CompletionTokens)
173 session.CompletionTokens += int64(usage.CompletionTokens)
175− // TODO: calculate cost
176 model := models.SupportedModels[models.ModelID(viper.GetString("models.big"))]
174 model := models.SupportedModels[models.ModelID(viper.GetString("models.big"))]
177 session.Cost += float64(usage.PromptTokens)*(model.CostPer1MIn/1_000_000) +
175 session.Cost += float64(usage.PromptTokens)*(model.CostPer1MIn/1_000_000) +
178 float64(usage.CompletionTokens)*(model.CostPer1MOut/1_000_000)
176 float64(usage.CompletionTokens)*(model.CostPer1MOut/1_000_000)
internal/llm/models/models.go+8−6
@@ -26,8 +26,8 @@ type Model struct {
26}
26}
27
27
28const (
28const (
29− DefaultBigModel = GPT4oMini
29+ DefaultBigModel = Claude37Sonnet
30− DefaultLittleModel = GPT4oMini
30+ DefaultLittleModel = Claude37Sonnet
31)
31)
32
32
33// Model IDs
33// Model IDs
@@ -118,10 +118,12 @@ var SupportedModels = map[ModelID]Model{
118 APIModel: "claude-3-haiku",
118 APIModel: "claude-3-haiku",
119 },
119 },
120 Claude37Sonnet: {
120 Claude37Sonnet: {
121− ID: Claude37Sonnet,
121+ ID: Claude37Sonnet,
122− Name: "Claude 3.7 Sonnet",
122+ Name: "Claude 3.7 Sonnet",
123− Provider: ProviderAnthropic,
123+ Provider: ProviderAnthropic,
124− APIModel: "claude-3-7-sonnet-20250219",
124+ APIModel: "claude-3-7-sonnet-20250219",
125+ CostPer1MIn: 3.0,
126+ CostPer1MOut: 15.0,
125 },
127 },
126 // Google
128 // Google
127 Gemini20Pro: {
129 Gemini20Pro: {
internal/llm/tools/agent.gonew file+141
@@ -0,0 +1,141 @@
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−156
@@ -3,14 +3,13 @@ package tools
3import (
3import (
4 "context"
4 "context"
5 "encoding/json"
5 "encoding/json"
6− "errors"
7 "fmt"
6 "fmt"
8− "log"
9 "strings"
7 "strings"
10
8
11 "github.com/cloudwego/eino/components/tool"
9 "github.com/cloudwego/eino/components/tool"
12 "github.com/cloudwego/eino/schema"
10 "github.com/cloudwego/eino/schema"
13 "github.com/kujtimiihoxha/termai/internal/llm/tools/shell"
11 "github.com/kujtimiihoxha/termai/internal/llm/tools/shell"
12+ "github.com/kujtimiihoxha/termai/internal/permission"
14)
13)
15
14
16type bashTool struct {
15type bashTool struct {
@@ -36,6 +35,11 @@ var BannedCommands = []string{
36 "http-prompt", "chrome", "firefox", "safari",
35 "http-prompt", "chrome", "firefox", "safari",
37}
36}
38
37
38+var SafeReadOnlyCommands = []string{
39+ "ls", "echo", "pwd", "date", "cal", "uptime", "whoami", "id", "groups", "env", "printenv", "set", "unset", "which", "type", "whereis",
40+ "whatis", //...
41+}
42+
39func (b *bashTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
43func (b *bashTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
40 return &schema.ToolInfo{
44 return &schema.ToolInfo{
41 Name: BashToolName,
45 Name: BashToolName,
@@ -56,7 +60,6 @@ func (b *bashTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
56
60
57// Handle implements Tool.
61// Handle implements Tool.
58func (b *bashTool) InvokableRun(ctx context.Context, args string, opts ...tool.Option) (string, error) {
62func (b *bashTool) InvokableRun(ctx context.Context, args string, opts ...tool.Option) (string, error) {
59− log.Printf("BashTool InvokableRun: %s", args)
60 var params BashParams
63 var params BashParams
61 if err := json.Unmarshal([]byte(args), &params); err != nil {
64 if err := json.Unmarshal([]byte(args), &params); err != nil {
62 return "", err
65 return "", err
@@ -69,13 +72,37 @@ func (b *bashTool) InvokableRun(ctx context.Context, args string, opts ...tool.O
69 }
72 }
70
73
71 if params.Command == "" {
74 if params.Command == "" {
72− return "", errors.New("missing command")
75+ return "missing command", nil
73 }
76 }
74
77
75 baseCmd := strings.Fields(params.Command)[0]
78 baseCmd := strings.Fields(params.Command)[0]
76 for _, banned := range BannedCommands {
79 for _, banned := range BannedCommands {
77 if strings.EqualFold(baseCmd, banned) {
80 if strings.EqualFold(baseCmd, banned) {
78− return "", fmt.Errorf("command '%s' is not allowed", baseCmd)
81+ return fmt.Sprintf("command '%s' is not allowed", baseCmd), nil
82+ }
83+ }
84+ isSafeReadOnly := false
85+ for _, safe := range SafeReadOnlyCommands {
86+ if strings.EqualFold(baseCmd, safe) {
87+ isSafeReadOnly = true
88+ break
89+ }
90+ }
91+ if !isSafeReadOnly {
92+ p := permission.Default.Request(
93+ permission.CreatePermissionRequest{
94+ Path: b.workingDir,
95+ ToolName: BashToolName,
96+ Action: "execute",
97+ Description: fmt.Sprintf("Execute command: %s", params.Command),
98+ Params: map[string]interface{}{
99+ "command": params.Command,
100+ "timeout": params.Timeout,
101+ },
102+ },
103+ )
104+ if !p {
105+ return "", fmt.Errorf("permission denied for command: %s", params.Command)
79 }
106 }
80 }
107 }
81
108
@@ -125,8 +152,9 @@ func (b *bashTool) InvokableRun(ctx context.Context, args string, opts ...tool.O
125 stdout += "\n" + errorMessage
152 stdout += "\n" + errorMessage
126 }
153 }
127
154
128− log.Printf("BashTool InvokableRun: stdout: %s, stderr: %s, exitCode: %d, interrupted: %t", stdout, stderr, exitCode, interrupted)
155+ if stdout == "" {
129−
156+ return "no output", nil
157+ }
130 return stdout, nil
158 return stdout, nil
131}
159}
132
160
@@ -150,7 +178,7 @@ func countLines(s string) int {
150 return len(strings.Split(s, "\n"))
178 return len(strings.Split(s, "\n"))
151}
179}
152
180
153−func bashDescriptionBCP() string {
181+func bashDescription() string {
154 bannedCommandsStr := strings.Join(BannedCommands, ", ")
182 bannedCommandsStr := strings.Join(BannedCommands, ", ")
155 return fmt.Sprintf(`Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
183 return fmt.Sprintf(`Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
156
184
@@ -299,154 +327,6 @@ Important:
299- Never update git config`, bannedCommandsStr, MaxOutputLength)
327- Never update git config`, bannedCommandsStr, MaxOutputLength)
300}
328}
301
329
302−func bashDescription() string {
303− bannedCommandsStr := strings.Join(BannedCommands, ", ")
304− return fmt.Sprintf(`Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
305−
306−Before executing the command, please follow these steps:
307−
308−1. Directory Verification:
309− - If the command will create new directories or files, first use the ls command to verify the parent directory exists and is the correct location
310− - For example, before running "mkdir foo/bar", first use ls command to check that "foo" exists and is the intended parent directory
311−
312−2. Security Check:
313− - For security and to limit the threat of a prompt injection attack, some commands are limited or banned. If you use a disallowed command, you will receive an error message explaining the restriction. Explain the error to the User.
314− - Verify that the command is not one of the banned commands: %s.
315−
316−3. Command Execution:
317− - After ensuring proper quoting, execute the command.
318− - Capture the output of the command.
319−
320−4. Output Processing:
321− - If the output exceeds %d characters, output will be truncated before being returned to you.
322− - Prepare the output for display to the user.
323−
324−5. Return Result:
325− - Provide the processed output of the command.
326− - If any errors occurred during execution, include those in the output.
327−
328−Usage notes:
329−- The command argument is required.
330−- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 30 minutes.
331−- When issuing multiple commands, use the ';' or '&&' operator to separate them. DO NOT use newlines (newlines are ok in quoted strings).
332−- IMPORTANT: All commands share the same shell session. Shell state (environment variables, virtual environments, current directory, etc.) persist between commands. For example, if you set an environment variable as part of a command, the environment variable will persist for subsequent commands.
333−- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of 'cd'. You may use 'cd' if the User explicitly requests it.
334−<good-example>
335−pytest /foo/bar/tests
336−</good-example>
337−<bad-example>
338−cd /foo/bar && pytest tests
339−</bad-example>
340−
341−# Committing changes with git
342−
343−When the user asks you to create a new git commit, follow these steps carefully:
344−
345−1. Start with a single message that contains exactly three tool_use blocks that do the following (it is VERY IMPORTANT that you send these tool_use blocks in a single message, otherwise it will feel slow to the user!):
346− - Run a git status command to see all untracked files.
347− - Run a git diff command to see both staged and unstaged changes that will be committed.
348− - Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.
349−
350−2. Use the git context at the start of this conversation to determine which files are relevant to your commit. Add relevant untracked files to the staging area. Do not commit files that were already modified at the start of this conversation, if they are not relevant to your commit.
351−
352−3. Analyze all staged changes (both previously staged and newly added) and draft a commit message. Wrap your analysis process in <commit_analysis> tags:
353−
354−<commit_analysis>
355−- List the files that have been changed or added
356−- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.)
357−- Brainstorm the purpose or motivation behind these changes
358−- Do not use tools to explore code, beyond what is available in the git context
359−- Assess the impact of these changes on the overall project
360−- Check for any sensitive information that shouldn't be committed
361−- Draft a concise (1-2 sentences) commit message that focuses on the "why" rather than the "what"
362−- Ensure your language is clear, concise, and to the point
363−- Ensure the message accurately reflects the changes and their purpose (i.e. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.)
364−- Ensure the message is not generic (avoid words like "Update" or "Fix" without context)
365−- Review the draft message to ensure it accurately reflects the changes and their purpose
366−</commit_analysis>
367−
368−4. Create the commit with a message ending with:
369−🤖 Generated with termai
370−Co-Authored-By: termai <noreply@termai.io>
371−
372−- In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example:
373−<example>
374−git commit -m "$(cat <<'EOF'
375− Commit message here.
376−
377− 🤖 Generated with termai
378− Co-Authored-By: termai <noreply@termai.io>
379− EOF
380− )"
381−</example>
382−
383−5. If the commit fails due to pre-commit hook changes, retry the commit ONCE to include these automated changes. If it fails again, it usually means a pre-commit hook is preventing the commit. If the commit succeeds but you notice that files were modified by the pre-commit hook, you MUST amend your commit to include them.
384−
385−6. Finally, run git status to make sure the commit succeeded.
386−
387−Important notes:
388−- When possible, combine the "git add" and "git commit" commands into a single "git commit -am" command, to speed things up
389−- However, be careful not to stage files (e.g. with 'git add .') for commits that aren't part of the change, they may have untracked files they want to keep around, but not commit.
390−- NEVER update the git config
391−- DO NOT push to the remote repository
392−- IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported.
393−- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit
394−- Ensure your commit message is meaningful and concise. It should explain the purpose of the changes, not just describe them.
395−- Return an empty response - the user will see the git output directly
396−
397−# Creating pull requests
398−Use the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a Github URL use the gh command to get the information needed.
399−
400−IMPORTANT: When the user asks you to create a pull request, follow these steps carefully:
401−
402−1. Understand the current state of the branch. Remember to send a single message that contains multiple tool_use blocks (it is VERY IMPORTANT that you do this in a single message, otherwise it will feel slow to the user!):
403− - Run a git status command to see all untracked files.
404− - Run a git diff command to see both staged and unstaged changes that will be committed.
405− - Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote
406− - Run a git log command and 'git diff main...HEAD' to understand the full commit history for the current branch (from the time it diverged from the 'main' branch.)
407−
408−2. Create new branch if needed
409−
410−3. Commit changes if needed
411−
412−4. Push to remote with -u flag if needed
413−
414−5. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (not just the latest commit, but all commits that will be included in the pull request!), and draft a pull request summary. Wrap your analysis process in <pr_analysis> tags:
415−
416−<pr_analysis>
417−- List the commits since diverging from the main branch
418−- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.)
419−- Brainstorm the purpose or motivation behind these changes
420−- Assess the impact of these changes on the overall project
421−- Do not use tools to explore code, beyond what is available in the git context
422−- Check for any sensitive information that shouldn't be committed
423−- Draft a concise (1-2 bullet points) pull request summary that focuses on the "why" rather than the "what"
424−- Ensure the summary accurately reflects all changes since diverging from the main branch
425−- Ensure your language is clear, concise, and to the point
426−- Ensure the summary accurately reflects the changes and their purpose (ie. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.)
427−- Ensure the summary is not generic (avoid words like "Update" or "Fix" without context)
428−- Review the draft summary to ensure it accurately reflects the changes and their purpose
429−</pr_analysis>
430−
431−6. Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.
432−<example>
433−gh pr create --title "the pr title" --body "$(cat <<'EOF'
434−## Summary
435−<1-3 bullet points>
436−
437−## Test plan
438−[Checklist of TODOs for testing the pull request...]
439−
440−🤖 Generated with termai
441−EOF
442−)"
443−</example>
444−
445−Important:
446−- Return an empty response - the user will see the gh output directly
447−- Never update git config`, bannedCommandsStr, MaxOutputLength)
448−}
449−
450func NewBashTool(workingDir string) tool.InvokableTool {
330func NewBashTool(workingDir string) tool.InvokableTool {
451 return &bashTool{
331 return &bashTool{
452 workingDir: workingDir,
332 workingDir: workingDir,
internal/llm/tools/edit.gonew file+420
@@ -0,0 +1,420 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+ "time"
12+
13+ "github.com/cloudwego/eino/components/tool"
14+ "github.com/cloudwego/eino/schema"
15+ "github.com/kujtimiihoxha/termai/internal/permission"
16+ "github.com/sergi/go-diff/diffmatchpatch"
17+)
18+
19+type editTool struct {
20+ workingDir string
21+}
22+
23+const (
24+ EditToolName = "edit"
25+)
26+
27+type EditParams struct {
28+ FilePath string `json:"file_path"`
29+ OldString string `json:"old_string"`
30+ NewString string `json:"new_string"`
31+}
32+
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 /)
77+
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,
89+ },
90+ "old_string": {
91+ Type: "string",
92+ Desc: "The text to replace",
93+ Required: true,
94+ },
95+ "new_string": {
96+ Type: "string",
97+ Desc: "The text to replace it with",
98+ Required: true,
99+ },
100+ }),
101+ }, nil
102+}
103+
104+func (b *editTool) InvokableRun(ctx context.Context, args string, opts ...tool.Option) (string, error) {
105+ var params EditParams
106+ if err := json.Unmarshal([]byte(args), &params); err != nil {
107+ return "", err
108+ }
109+
110+ if params.FilePath == "" {
111+ return "", errors.New("file_path is required")
112+ }
113+
114+ if !filepath.IsAbs(params.FilePath) {
115+ return "", fmt.Errorf("file path must be absolute, got: %s", params.FilePath)
116+ }
117+
118+ if params.OldString == "" {
119+ return createNewFile(params.FilePath, params.NewString)
120+ }
121+
122+ if params.NewString == "" {
123+ return deleteContent(params.FilePath, params.OldString)
124+ }
125+
126+ return replaceContent(params.FilePath, params.OldString, params.NewString)
127+}
128+
129+func createNewFile(filePath, content string) (string, error) {
130+ fileInfo, err := os.Stat(filePath)
131+ if err == nil {
132+ if fileInfo.IsDir() {
133+ return "", fmt.Errorf("path is a directory, not a file: %s", filePath)
134+ }
135+ return "", fmt.Errorf("file already exists: %s. Use the Replace tool to overwrite an existing file", filePath)
136+ } else if !os.IsNotExist(err) {
137+ return "", fmt.Errorf("failed to access file: %w", err)
138+ }
139+
140+ dir := filepath.Dir(filePath)
141+ if err = os.MkdirAll(dir, 0o755); err != nil {
142+ return "", fmt.Errorf("failed to create parent directories: %w", err)
143+ }
144+
145+ p := permission.Default.Request(
146+ permission.CreatePermissionRequest{
147+ Path: filepath.Dir(filePath),
148+ ToolName: EditToolName,
149+ Action: "create",
150+ Description: fmt.Sprintf("Create file %s", filePath),
151+ Params: map[string]interface{}{
152+ "file_path": filePath,
153+ "content": content,
154+ },
155+ },
156+ )
157+ if !p {
158+ return "", fmt.Errorf("permission denied")
159+ }
160+
161+ err = os.WriteFile(filePath, []byte(content), 0o644)
162+ if err != nil {
163+ return "", fmt.Errorf("failed to write file: %w", err)
164+ }
165+
166+ recordFileWrite(filePath)
167+ recordFileRead(filePath)
168+
169+ // result := FileEditResult{
170+ // FilePath: filePath,
171+ // Created: true,
172+ // Updated: false,
173+ // Deleted: false,
174+ // Diff: generateDiff("", content),
175+ // }
176+ //
177+ // resultJSON, err := json.Marshal(result)
178+ // if err != nil {
179+ // return "", fmt.Errorf("failed to serialize result: %w", err)
180+ // }
181+ //
182+ return "File created: " + filePath, nil
183+}
184+
185+func deleteContent(filePath, oldString string) (string, error) {
186+ fileInfo, err := os.Stat(filePath)
187+ if err != nil {
188+ if os.IsNotExist(err) {
189+ return "", fmt.Errorf("file not found: %s", filePath)
190+ }
191+ return "", fmt.Errorf("failed to access file: %w", err)
192+ }
193+
194+ if fileInfo.IsDir() {
195+ return "", fmt.Errorf("path is a directory, not a file: %s", filePath)
196+ }
197+
198+ if getLastReadTime(filePath).IsZero() {
199+ return "", fmt.Errorf("you must read the file before editing it. Use the View tool first")
200+ }
201+
202+ modTime := fileInfo.ModTime()
203+ lastRead := getLastReadTime(filePath)
204+ if modTime.After(lastRead) {
205+ return "", fmt.Errorf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
206+ filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))
207+ }
208+
209+ content, err := os.ReadFile(filePath)
210+ if err != nil {
211+ return "", fmt.Errorf("failed to read file: %w", err)
212+ }
213+
214+ oldContent := string(content)
215+
216+ index := strings.Index(oldContent, oldString)
217+ if index == -1 {
218+ return "", fmt.Errorf("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks")
219+ }
220+
221+ lastIndex := strings.LastIndex(oldContent, oldString)
222+ if index != lastIndex {
223+ return "", fmt.Errorf("old_string appears multiple times in the file. Please provide more context to ensure a unique match")
224+ }
225+
226+ newContent := oldContent[:index] + oldContent[index+len(oldString):]
227+
228+ p := permission.Default.Request(
229+ permission.CreatePermissionRequest{
230+ Path: filepath.Dir(filePath),
231+ ToolName: EditToolName,
232+ Action: "delete",
233+ Description: fmt.Sprintf("Delete content from file %s", filePath),
234+ Params: map[string]interface{}{
235+ "file_path": filePath,
236+ "content": content,
237+ },
238+ },
239+ )
240+ if !p {
241+ return "", fmt.Errorf("permission denied")
242+ }
243+
244+ err = os.WriteFile(filePath, []byte(newContent), 0o644)
245+ if err != nil {
246+ return "", fmt.Errorf("failed to write file: %w", err)
247+ }
248+
249+ 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+ // }
265+
266+ return "Content deleted from file: " + filePath, nil
267+}
268+
269+func replaceContent(filePath, oldString, newString string) (string, error) {
270+ fileInfo, err := os.Stat(filePath)
271+ if err != nil {
272+ if os.IsNotExist(err) {
273+ return fmt.Sprintf("file not found: %s", filePath), nil
274+ }
275+ return fmt.Sprintf("failed to access file: %s", err), nil
276+ }
277+
278+ if fileInfo.IsDir() {
279+ return fmt.Sprintf("path is a directory, not a file: %s", filePath), nil
280+ }
281+
282+ if getLastReadTime(filePath).IsZero() {
283+ return "you must read the file before editing it. Use the View tool first", nil
284+ }
285+
286+ modTime := fileInfo.ModTime()
287+ lastRead := getLastReadTime(filePath)
288+ 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
291+ }
292+
293+ content, err := os.ReadFile(filePath)
294+ if err != nil {
295+ return fmt.Sprintf("failed to read file: %s", err), nil
296+ }
297+
298+ oldContent := string(content)
299+
300+ index := strings.Index(oldContent, oldString)
301+ if index == -1 {
302+ return "old_string not found in file. Make sure it matches exactly, including whitespace and line breaks", nil
303+ }
304+
305+ lastIndex := strings.LastIndex(oldContent, oldString)
306+ if index != lastIndex {
307+ return "old_string appears multiple times in the file. Please provide more context to ensure a unique match", nil
308+ }
309+
310+ newContent := oldContent[:index] + newString + oldContent[index+len(oldString):]
311+
312+ p := permission.Default.Request(
313+ permission.CreatePermissionRequest{
314+ Path: filepath.Dir(filePath),
315+ ToolName: EditToolName,
316+ Action: "replace",
317+ 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,
322+ },
323+ },
324+ )
325+ if !p {
326+ return "", fmt.Errorf("permission denied")
327+ }
328+
329+ err = os.WriteFile(filePath, []byte(newContent), 0o644)
330+ if err != nil {
331+ return fmt.Sprintf("failed to write file: %s", err), nil
332+ }
333+
334+ 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+ // }
350+
351+ return "Content replaced in file: " + filePath, nil
352+}
353+
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
365+ }
366+ currentPos += len(line) + 1 // +1 for the newline
367+ }
368+
369+ startLine := max(0, lineIndex-contextLines)
370+ endLine := min(len(lines), lineIndex+contextLines+1)
371+
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+ }
380+
381+ return snippetBuilder.String()
382+}
383+
384+func generateDiff(oldContent, newContent string) string {
385+ dmp := diffmatchpatch.New()
386+
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+ }
412+
413+ return patchText
414+}
415+
416+func NewEditTool(workingDir string) tool.InvokableTool {
417+ return &editTool{
418+ workingDir: workingDir,
419+ }
420+}
internal/llm/tools/file.gonew file+53
@@ -0,0 +1,53 @@
1+package tools
2+
3+import (
4+ "sync"
5+ "time"
6+)
7+
8+// File record to track when files were read/written
9+type fileRecord struct {
10+ path string
11+ readTime time.Time
12+ writeTime time.Time
13+}
14+
15+var (
16+ fileRecords = make(map[string]fileRecord)
17+ fileRecordMutex sync.RWMutex
18+)
19+
20+func recordFileRead(path string) {
21+ fileRecordMutex.Lock()
22+ defer fileRecordMutex.Unlock()
23+
24+ record, exists := fileRecords[path]
25+ if !exists {
26+ record = fileRecord{path: path}
27+ }
28+ record.readTime = time.Now()
29+ fileRecords[path] = record
30+}
31+
32+func getLastReadTime(path string) time.Time {
33+ fileRecordMutex.RLock()
34+ defer fileRecordMutex.RUnlock()
35+
36+ record, exists := fileRecords[path]
37+ if !exists {
38+ return time.Time{}
39+ }
40+ return record.readTime
41+}
42+
43+func recordFileWrite(path string) {
44+ fileRecordMutex.Lock()
45+ defer fileRecordMutex.Unlock()
46+
47+ record, exists := fileRecords[path]
48+ if !exists {
49+ record = fileRecord{path: path}
50+ }
51+ record.writeTime = time.Now()
52+ fileRecords[path] = record
53+}
internal/llm/tools/glob.gonew file+174
@@ -0,0 +1,174 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "io/fs"
8+ "os"
9+ "path/filepath"
10+ "sort"
11+ "strings"
12+ "time"
13+
14+ "github.com/cloudwego/eino/components/tool"
15+ "github.com/cloudwego/eino/schema"
16+
17+ "github.com/bmatcuk/doublestar/v4"
18+)
19+
20+type globTool struct {
21+ workingDir string
22+}
23+
24+const (
25+ GlobToolName = "glob"
26+)
27+
28+type fileInfo struct {
29+ path string
30+ modTime time.Time
31+}
32+
33+type GlobParams struct {
34+ Pattern string `json:"pattern"`
35+ Path string `json:"path"`
36+}
37+
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,
51+ },
52+ "path": {
53+ Type: "string",
54+ Desc: "The directory to search in. Defaults to the current working directory.",
55+ },
56+ }),
57+ }, nil
58+}
59+
60+func (b *globTool) InvokableRun(ctx context.Context, args string, opts ...tool.Option) (string, error) {
61+ var params GlobParams
62+ if err := json.Unmarshal([]byte(args), &params); err != nil {
63+ return fmt.Sprintf("error parsing parameters: %s", err), nil
64+ }
65+
66+ // If path is empty, use current working directory
67+ searchPath := params.Path
68+ if searchPath == "" {
69+ searchPath = b.workingDir
70+ }
71+
72+ files, truncated, err := globFiles(params.Pattern, searchPath, 100)
73+ if err != nil {
74+ return fmt.Sprintf("error performing glob search: %s", err), nil
75+ }
76+
77+ // Format the output for the assistant
78+ var output string
79+ if len(files) == 0 {
80+ output = "No files found"
81+ } else {
82+ output = strings.Join(files, "\n")
83+ if truncated {
84+ output += "\n(Results are truncated. Consider using a more specific path or pattern.)"
85+ }
86+ }
87+
88+ return output, nil
89+}
90+
91+func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) {
92+ // Make sure pattern starts with the search path if not absolute
93+ if !strings.HasPrefix(pattern, "/") && !strings.HasPrefix(pattern, searchPath) {
94+ // If searchPath doesn't end with a slash, add one before appending the pattern
95+ if !strings.HasSuffix(searchPath, "/") {
96+ searchPath += "/"
97+ }
98+ pattern = searchPath + pattern
99+ }
100+
101+ // Open the filesystem for walking
102+ fsys := os.DirFS("/")
103+
104+ // Convert the absolute pattern to a relative one for the DirFS
105+ // DirFS uses the root directory ("/") so we should strip leading "/"
106+ relPattern := strings.TrimPrefix(pattern, "/")
107+
108+ // Collect matching files
109+ var matches []fileInfo
110+
111+ // Use doublestar to walk the filesystem and find matches
112+ err := doublestar.GlobWalk(fsys, relPattern, func(path string, d fs.DirEntry) error {
113+ // Skip directories from results
114+ if d.IsDir() {
115+ return nil
116+ }
117+ if skipHidden(path) {
118+ return nil
119+ }
120+
121+ // Get file info for modification time
122+ info, err := d.Info()
123+ if err != nil {
124+ return nil // Skip files we can't access
125+ }
126+
127+ // Add to matches
128+ absPath := "/" + path // Restore absolute path
129+ matches = append(matches, fileInfo{
130+ path: absPath,
131+ modTime: info.ModTime(),
132+ })
133+
134+ // Check limit
135+ if len(matches) >= limit*2 { // Collect more than needed for sorting
136+ return fs.SkipAll
137+ }
138+
139+ return nil
140+ })
141+ if err != nil {
142+ return nil, false, fmt.Errorf("glob walk error: %w", err)
143+ }
144+
145+ // Sort files by modification time (newest first)
146+ sort.Slice(matches, func(i, j int) bool {
147+ return matches[i].modTime.After(matches[j].modTime)
148+ })
149+
150+ // Check if we need to truncate the results
151+ truncated := len(matches) > limit
152+ if truncated {
153+ matches = matches[:limit]
154+ }
155+
156+ // Extract just the paths
157+ results := make([]string, len(matches))
158+ for i, m := range matches {
159+ results[i] = m.path
160+ }
161+
162+ return results, truncated, nil
163+}
164+
165+func skipHidden(path string) bool {
166+ base := filepath.Base(path)
167+ return base != "." && strings.HasPrefix(base, ".")
168+}
169+
170+func NewGlobTool(workingDir string) tool.InvokableTool {
171+ return &globTool{
172+ workingDir,
173+ }
174+}
internal/llm/tools/grep.gonew file+260
@@ -0,0 +1,260 @@
1+package tools
2+
3+import (
4+ "bufio"
5+ "context"
6+ "encoding/json"
7+ "fmt"
8+ "os"
9+ "os/exec"
10+ "path/filepath"
11+ "regexp"
12+ "sort"
13+ "strings"
14+ "time"
15+
16+ "github.com/cloudwego/eino/components/tool"
17+ "github.com/cloudwego/eino/schema"
18+)
19+
20+type grepTool struct {
21+ workingDir string
22+}
23+
24+const (
25+ GrepToolName = "grep"
26+
27+ MaxGrepResults = 100
28+)
29+
30+type GrepParams struct {
31+ Pattern string `json:"pattern"`
32+ Path string `json:"path"`
33+ Include string `json:"include"`
34+}
35+
36+type grepMatch struct {
37+ path string
38+ modTime time.Time
39+}
40+
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,
56+ },
57+ "timeout": {
58+ Type: "number",
59+ Desc: "Optional timeout in milliseconds (max 600000)",
60+ },
61+ }),
62+ }, nil
63+}
64+
65+func (b *grepTool) InvokableRun(ctx context.Context, args string, opts ...tool.Option) (string, error) {
66+ var params GrepParams
67+ if err := json.Unmarshal([]byte(args), &params); err != nil {
68+ return "", err
69+ }
70+
71+ searchPath := params.Path
72+ 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+ }
78+ }
79+
80+ matches, err := searchWithRipgrep(params.Pattern, searchPath, params.Include)
81+ 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
101+ }
102+
103+ var output string
104+ if len(filenames) == 0 {
105+ output = "No files found"
106+ } else {
107+ output = fmt.Sprintf("Found %d file%s\n%s",
108+ len(filenames),
109+ pluralize(len(filenames)),
110+ strings.Join(filenames, "\n"))
111+
112+ if truncated {
113+ output += "\n(Results are truncated. Consider using a more specific path or pattern.)"
114+ }
115+ }
116+
117+ return output, nil
118+}
119+
120+func pluralize(count int) string {
121+ if count == 1 {
122+ return ""
123+ }
124+ return "s"
125+}
126+
127+func searchWithRipgrep(pattern, path, include string) ([]grepMatch, error) {
128+ _, err := exec.LookPath("rg")
129+ if err != nil {
130+ return nil, fmt.Errorf("ripgrep not found: %w", err)
131+ }
132+
133+ args := []string{"-l", pattern}
134+ if include != "" {
135+ args = append(args, "--glob", include)
136+ }
137+ args = append(args, path)
138+
139+ cmd := exec.Command("rg", args...)
140+ output, err := cmd.Output()
141+ if err != nil {
142+ if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
143+ return []grepMatch{}, nil
144+ }
145+ return nil, err
146+ }
147+
148+ lines := strings.Split(strings.TrimSpace(string(output)), "\n")
149+ matches := make([]grepMatch, 0, len(lines))
150+
151+ for _, line := range lines {
152+ if line == "" {
153+ continue
154+ }
155+
156+ fileInfo, err := os.Stat(line)
157+ if err != nil {
158+ continue
159+ }
160+
161+ matches = append(matches, grepMatch{
162+ path: line,
163+ modTime: fileInfo.ModTime(),
164+ })
165+ }
166+
167+ return matches, nil
168+}
169+
170+func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error) {
171+ matches := []grepMatch{}
172+
173+ regex, err := regexp.Compile(pattern)
174+ if err != nil {
175+ return nil, fmt.Errorf("invalid regex pattern: %w", err)
176+ }
177+
178+ var includePattern *regexp.Regexp
179+ if include != "" {
180+ regexPattern := globToRegex(include)
181+ includePattern, err = regexp.Compile(regexPattern)
182+ if err != nil {
183+ return nil, fmt.Errorf("invalid include pattern: %w", err)
184+ }
185+ }
186+
187+ err = filepath.Walk(rootPath, func(path string, info os.FileInfo, err error) error {
188+ if err != nil {
189+ return nil
190+ }
191+
192+ if info.IsDir() {
193+ return nil
194+ }
195+
196+ if includePattern != nil && !includePattern.MatchString(path) {
197+ return nil
198+ }
199+
200+ match, err := fileContainsPattern(path, regex)
201+ if err != nil {
202+ return nil
203+ }
204+
205+ if match {
206+ matches = append(matches, grepMatch{
207+ path: path,
208+ modTime: info.ModTime(),
209+ })
210+ }
211+
212+ return nil
213+ })
214+ if err != nil {
215+ return nil, err
216+ }
217+
218+ return matches, nil
219+}
220+
221+func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, error) {
222+ file, err := os.Open(filePath)
223+ if err != nil {
224+ return false, err
225+ }
226+ defer file.Close()
227+
228+ scanner := bufio.NewScanner(file)
229+ for scanner.Scan() {
230+ if pattern.MatchString(scanner.Text()) {
231+ return true, nil
232+ }
233+ }
234+
235+ if err := scanner.Err(); err != nil {
236+ return false, err
237+ }
238+
239+ return false, nil
240+}
241+
242+func globToRegex(glob string) string {
243+ regexPattern := strings.ReplaceAll(glob, ".", "\\.")
244+ regexPattern = strings.ReplaceAll(regexPattern, "*", ".*")
245+ regexPattern = strings.ReplaceAll(regexPattern, "?", ".")
246+
247+ re := regexp.MustCompile(`\{([^}]+)\}`)
248+ regexPattern = re.ReplaceAllStringFunc(regexPattern, func(match string) string {
249+ inner := match[1 : len(match)-1]
250+ return "(" + strings.ReplaceAll(inner, ",", "|") + ")"
251+ })
252+
253+ return "^" + regexPattern + "$"
254+}
255+
256+func NewGrepTool(workingDir string) tool.InvokableTool {
257+ return &grepTool{
258+ workingDir,
259+ }
260+}
internal/llm/tools/ls.gonew file+229
@@ -0,0 +1,229 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+
11+ "github.com/cloudwego/eino/components/tool"
12+ "github.com/cloudwego/eino/schema"
13+)
14+
15+type lsTool struct {
16+ workingDir string
17+}
18+
19+const (
20+ 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"
24+)
25+
26+type LSParams struct {
27+ Path string `json:"path"`
28+ Ignore []string `json:"ignore"`
29+}
30+
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,
40+ },
41+ "ignore": {
42+ Type: "array",
43+ ElemInfo: &schema.ParameterInfo{
44+ Type: schema.String,
45+ Desc: "List of glob patterns to ignore",
46+ },
47+ },
48+ }),
49+ }, nil
50+}
51+
52+func (b *lsTool) InvokableRun(ctx context.Context, args string, opts ...tool.Option) (string, error) {
53+ var params LSParams
54+ if err := json.Unmarshal([]byte(args), &params); err != nil {
55+ return "", err
56+ }
57+
58+ if !filepath.IsAbs(params.Path) {
59+ return fmt.Sprintf("path must be absolute, got: %s", params.Path), nil
60+ }
61+
62+ files, err := b.listDirectory(params.Path)
63+ if err != nil {
64+ return fmt.Sprintf("error listing directory: %s", err), nil
65+ }
66+
67+ tree := createFileTree(files)
68+ output := printTree(tree, params.Path)
69+
70+ if len(files) >= MaxFiles {
71+ output = TruncatedMessage + output
72+ }
73+
74+ return output, nil
75+}
76+
77+func (b *lsTool) listDirectory(initialPath string) ([]string, error) {
78+ var results []string
79+
80+ err := filepath.Walk(initialPath, func(path string, info os.FileInfo, err error) error {
81+ if err != nil {
82+ return nil // Skip files we don't have permission to access
83+ }
84+
85+ if shouldSkip(path) {
86+ if info.IsDir() {
87+ return filepath.SkipDir
88+ }
89+ return nil
90+ }
91+
92+ if path != initialPath {
93+ if info.IsDir() {
94+ path = path + string(filepath.Separator)
95+ }
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+ }
103+ }
104+
105+ if len(results) >= MaxFiles {
106+ return fmt.Errorf("max files reached")
107+ }
108+
109+ return nil
110+ })
111+
112+ if err != nil && err.Error() != "max files reached" {
113+ return nil, err
114+ }
115+
116+ return results, nil
117+}
118+
119+func shouldSkip(path string) bool {
120+ base := filepath.Base(path)
121+
122+ if base != "." && strings.HasPrefix(base, ".") {
123+ return true
124+ }
125+
126+ if strings.Contains(path, filepath.Join("__pycache__", "")) {
127+ return true
128+ }
129+
130+ return false
131+}
132+
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"`
138+}
139+
140+func createFileTree(sortedPaths []string) []TreeNode {
141+ root := []TreeNode{}
142+
143+ for _, path := range sortedPaths {
144+ parts := strings.Split(path, string(filepath.Separator))
145+ currentLevel := &root
146+ currentPath := ""
147+
148+ for i, part := range parts {
149+ if part == "" {
150+ continue
151+ }
152+
153+ if currentPath == "" {
154+ currentPath = part
155+ } else {
156+ currentPath = filepath.Join(currentPath, part)
157+ }
158+
159+ isLastPart := i == len(parts)-1
160+ 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+ }
171+ }
172+
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+ }
184+
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)
191+ }
192+ }
193+ }
194+ }
195+
196+ return root
197+}
198+
199+func printTree(tree []TreeNode, rootPath string) string {
200+ var result strings.Builder
201+
202+ result.WriteString(fmt.Sprintf("- %s%s\n", rootPath, string(filepath.Separator)))
203+
204+ printTreeRecursive(&result, tree, 0, " ")
205+
206+ return result.String()
207+}
208+
209+func printTreeRecursive(builder *strings.Builder, tree []TreeNode, level int, prefix string) {
210+ for _, node := range tree {
211+ linePrefix := prefix + "- "
212+
213+ nodeName := node.Name
214+ if node.Type == "directory" {
215+ nodeName += string(filepath.Separator)
216+ }
217+ fmt.Fprintf(builder, "%s%s\n", linePrefix, nodeName)
218+
219+ if node.Type == "directory" && len(node.Children) > 0 {
220+ printTreeRecursive(builder, node.Children, level+1, prefix+" ")
221+ }
222+ }
223+}
224+
225+func NewLsTool(workingDir string) tool.InvokableTool {
226+ return &lsTool{
227+ workingDir,
228+ }
229+}
internal/llm/tools/view.gonew file+247
@@ -0,0 +1,247 @@
1+package tools
2+
3+import (
4+ "bufio"
5+ "context"
6+ "encoding/json"
7+ "fmt"
8+ "io"
9+ "os"
10+ "path/filepath"
11+ "strings"
12+
13+ "github.com/cloudwego/eino/components/tool"
14+ "github.com/cloudwego/eino/schema"
15+)
16+
17+type viewTool struct {
18+ workingDir string
19+}
20+
21+const (
22+ ViewToolName = "view"
23+
24+ MaxReadSize = 250 * 1024
25+
26+ DefaultReadLimit = 2000
27+
28+ MaxLineLength = 2000
29+)
30+
31+type ViewPatams struct {
32+ FilePath string `json:"file_path"`
33+ Offset int `json:"offset"`
34+ Limit int `json:"limit"`
35+}
36+
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,
46+ },
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",
50+ },
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.",
54+ },
55+ }),
56+ }, nil
57+}
58+
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
63+ }
64+
65+ if params.FilePath == "" {
66+ return "file_path is required", nil
67+ }
68+
69+ if !filepath.IsAbs(params.FilePath) {
70+ return fmt.Sprintf("file path must be absolute, got: %s", params.FilePath), nil
71+ }
72+
73+ fileInfo, err := os.Stat(params.FilePath)
74+ if err != nil {
75+ if os.IsNotExist(err) {
76+ dir := filepath.Dir(params.FilePath)
77+ base := filepath.Base(params.FilePath)
78+
79+ dirEntries, dirErr := os.ReadDir(dir)
80+ if dirErr == nil {
81+ var suggestions []string
82+ for _, entry := range dirEntries {
83+ if strings.Contains(entry.Name(), base) || strings.Contains(base, entry.Name()) {
84+ suggestions = append(suggestions, filepath.Join(dir, entry.Name()))
85+ if len(suggestions) >= 3 {
86+ break
87+ }
88+ }
89+ }
90+
91+ 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
94+ }
95+ }
96+
97+ return fmt.Sprintf("file not found: %s", params.FilePath), nil
98+ }
99+ return fmt.Sprintf("failed to access file: %s", err), nil
100+ }
101+
102+ if fileInfo.IsDir() {
103+ return fmt.Sprintf("path is a directory, not a file: %s", params.FilePath), nil
104+ }
105+
106+ 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+ }
110+
111+ if params.Limit <= 0 {
112+ params.Limit = DefaultReadLimit
113+ }
114+
115+ isImage, _ := isImageFile(params.FilePath)
116+ if isImage {
117+ // TODO: Implement image reading
118+ return "reading images is not supported", nil
119+ }
120+
121+ content, _, err := readTextFile(params.FilePath, params.Offset, params.Limit)
122+ if err != nil {
123+ return fmt.Sprintf("failed to read file: %s", err), nil
124+ }
125+
126+ recordFileRead(params.FilePath)
127+
128+ return addLineNumbers(content, params.Offset+1), nil
129+}
130+
131+func addLineNumbers(content string, startLine int) string {
132+ if content == "" {
133+ return ""
134+ }
135+
136+ lines := strings.Split(content, "\n")
137+
138+ var result []string
139+ for i, line := range lines {
140+ line = strings.TrimSuffix(line, "\r")
141+
142+ lineNum := i + startLine
143+ numStr := fmt.Sprintf("%d", lineNum)
144+
145+ if len(numStr) >= 6 {
146+ result = append(result, fmt.Sprintf("%s\t%s", numStr, line))
147+ } else {
148+ paddedNum := fmt.Sprintf("%6s", numStr)
149+ result = append(result, fmt.Sprintf("%s\t|%s", paddedNum, line))
150+ }
151+ }
152+
153+ return strings.Join(result, "\n")
154+}
155+
156+func readTextFile(filePath string, offset, limit int) (string, int, error) {
157+ file, err := os.Open(filePath)
158+ if err != nil {
159+ return "", 0, err
160+ }
161+ defer file.Close()
162+
163+ lineCount := 0
164+ if offset > 0 {
165+ scanner := NewLineScanner(file)
166+ for lineCount < offset && scanner.Scan() {
167+ lineCount++
168+ }
169+ if err = scanner.Err(); err != nil {
170+ return "", 0, err
171+ }
172+ }
173+
174+ if offset == 0 {
175+ _, err = file.Seek(0, io.SeekStart)
176+ if err != nil {
177+ return "", 0, err
178+ }
179+ }
180+
181+ var lines []string
182+ lineCount = offset
183+ scanner := NewLineScanner(file)
184+
185+ for scanner.Scan() && len(lines) < limit {
186+ lineCount++
187+ lineText := scanner.Text()
188+ if len(lineText) > MaxLineLength {
189+ lineText = lineText[:MaxLineLength] + "..."
190+ }
191+ lines = append(lines, lineText)
192+ }
193+
194+ if err := scanner.Err(); err != nil {
195+ return "", 0, err
196+ }
197+
198+ return strings.Join(lines, "\n"), lineCount, nil
199+}
200+
201+func isImageFile(filePath string) (bool, string) {
202+ ext := strings.ToLower(filepath.Ext(filePath))
203+ switch ext {
204+ case ".jpg", ".jpeg":
205+ return true, "jpeg"
206+ case ".png":
207+ return true, "png"
208+ case ".gif":
209+ return true, "gif"
210+ case ".bmp":
211+ return true, "bmp"
212+ case ".svg":
213+ return true, "svg"
214+ case ".webp":
215+ return true, "webp"
216+ default:
217+ return false, ""
218+ }
219+}
220+
221+type LineScanner struct {
222+ scanner *bufio.Scanner
223+}
224+
225+func NewLineScanner(r io.Reader) *LineScanner {
226+ return &LineScanner{
227+ scanner: bufio.NewScanner(r),
228+ }
229+}
230+
231+func (s *LineScanner) Scan() bool {
232+ return s.scanner.Scan()
233+}
234+
235+func (s *LineScanner) Text() string {
236+ return s.scanner.Text()
237+}
238+
239+func (s *LineScanner) Err() error {
240+ return s.scanner.Err()
241+}
242+
243+func NewViewTool(workingDir string) tool.InvokableTool {
244+ return &viewTool{
245+ workingDir,
246+ }
247+}
internal/llm/tools/write.gonew file+165
@@ -0,0 +1,165 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "time"
11+
12+ "github.com/cloudwego/eino/components/tool"
13+ "github.com/cloudwego/eino/schema"
14+ "github.com/kujtimiihoxha/termai/internal/permission"
15+)
16+
17+type writeTool struct {
18+ workingDir string
19+}
20+
21+const (
22+ WriteToolName = "write"
23+)
24+
25+type WriteParams struct {
26+ FilePath string `json:"file_path"`
27+ Content string `json:"content"`
28+}
29+
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,
39+ },
40+ "content": {
41+ Type: "string",
42+ Desc: "The content to write to the file",
43+ Required: true,
44+ },
45+ }),
46+ }, nil
47+}
48+
49+func (b *writeTool) InvokableRun(ctx context.Context, args string, opts ...tool.Option) (string, error) {
50+ var params WriteParams
51+ if err := json.Unmarshal([]byte(args), &params); err != nil {
52+ return "", fmt.Errorf("failed to parse parameters: %w", err)
53+ }
54+
55+ if params.FilePath == "" {
56+ return "file_path is required", nil
57+ }
58+
59+ if !filepath.IsAbs(params.FilePath) {
60+ return fmt.Sprintf("file path must be absolute, got: %s", params.FilePath), nil
61+ }
62+
63+ // fileExists := false
64+ // oldContent := ""
65+ fileInfo, err := os.Stat(params.FilePath)
66+ if err == nil {
67+ if fileInfo.IsDir() {
68+ return fmt.Sprintf("path is a directory, not a file: %s", params.FilePath), nil
69+ }
70+
71+ modTime := fileInfo.ModTime()
72+ lastRead := getLastReadTime(params.FilePath)
73+ 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
76+ }
77+
78+ // oldContentBytes, readErr := os.ReadFile(params.FilePath)
79+ // if readErr != nil {
80+ // oldContent = string(oldContentBytes)
81+ // }
82+ } else if !os.IsNotExist(err) {
83+ return fmt.Sprintf("failed to access file: %s", err), nil
84+ }
85+
86+ p := permission.Default.Request(
87+ permission.CreatePermissionRequest{
88+ Path: b.workingDir,
89+ 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,
95+ },
96+ },
97+ )
98+ 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
104+ }
105+
106+ err = os.WriteFile(params.FilePath, []byte(params.Content), 0o644)
107+ if err != nil {
108+ return fmt.Sprintf("failed to write file: %s", err), nil
109+ }
110+
111+ recordFileWrite(params.FilePath)
112+
113+ output := "File written: " + params.FilePath
114+
115+ // if fileExists && oldContent != params.Content {
116+ // output = generateSimpleDiff(oldContent, params.Content)
117+ // }
118+
119+ return output, nil
120+}
121+
122+func generateSimpleDiff(oldContent, newContent string) string {
123+ if oldContent == newContent {
124+ return "[No changes]"
125+ }
126+
127+ oldLines := strings.Split(oldContent, "\n")
128+ newLines := strings.Split(newContent, "\n")
129+
130+ var diffBuilder strings.Builder
131+ diffBuilder.WriteString(fmt.Sprintf("@@ -%d,+%d @@\n", len(oldLines), len(newLines)))
132+
133+ maxLines := max(len(oldLines), len(newLines))
134+ for i := range maxLines {
135+ oldLine := ""
136+ newLine := ""
137+
138+ if i < len(oldLines) {
139+ oldLine = oldLines[i]
140+ }
141+
142+ if i < len(newLines) {
143+ newLine = newLines[i]
144+ }
145+
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()
159+}
160+
161+func NewWriteTool(workingDir string) tool.InvokableTool {
162+ return &writeTool{
163+ workingDir: workingDir,
164+ }
165+}
internal/permission/permission.gonew file+103
@@ -0,0 +1,103 @@
1+package permission
2+
3+import (
4+ "sync"
5+ "time"
6+
7+ "github.com/google/uuid"
8+ "github.com/kujtimiihoxha/termai/internal/pubsub"
9+)
10+
11+type CreatePermissionRequest struct {
12+ ToolName string `json:"tool_name"`
13+ Description string `json:"description"`
14+ Action string `json:"action"`
15+ Params any `json:"params"`
16+ Path string `json:"path"`
17+}
18+type PermissionRequest struct {
19+ ID string `json:"id"`
20+ SessionID string `json:"session_id"`
21+ ToolName string `json:"tool_name"`
22+ Description string `json:"description"`
23+ Action string `json:"action"`
24+ Params any `json:"params"`
25+ Path string `json:"path"`
26+}
27+
28+type Service interface {
29+ pubsub.Suscriber[PermissionRequest]
30+ GrantPersistant(permission PermissionRequest)
31+ Grant(permission PermissionRequest)
32+ Deny(permission PermissionRequest)
33+ Request(opts CreatePermissionRequest) bool
34+}
35+
36+type permissionService struct {
37+ *pubsub.Broker[PermissionRequest]
38+
39+ sessionPermissions []PermissionRequest
40+ pendingRequests sync.Map
41+}
42+
43+func (s *permissionService) GrantPersistant(permission PermissionRequest) {
44+ respCh, ok := s.pendingRequests.Load(permission.ID)
45+ if ok {
46+ respCh.(chan bool) <- true
47+ }
48+ s.sessionPermissions = append(s.sessionPermissions, permission)
49+}
50+
51+func (s *permissionService) Grant(permission PermissionRequest) {
52+ respCh, ok := s.pendingRequests.Load(permission.ID)
53+ if ok {
54+ respCh.(chan bool) <- true
55+ }
56+}
57+
58+func (s *permissionService) Deny(permission PermissionRequest) {
59+ respCh, ok := s.pendingRequests.Load(permission.ID)
60+ if ok {
61+ respCh.(chan bool) <- false
62+ }
63+}
64+
65+func (s *permissionService) Request(opts CreatePermissionRequest) bool {
66+ permission := PermissionRequest{
67+ ID: uuid.New().String(),
68+ ToolName: opts.ToolName,
69+ Description: opts.Description,
70+ Action: opts.Action,
71+ Params: opts.Params,
72+ }
73+
74+ for _, p := range s.sessionPermissions {
75+ if p.ToolName == permission.ToolName && p.Action == permission.Action {
76+ return true
77+ }
78+ }
79+
80+ respCh := make(chan bool, 1)
81+
82+ s.pendingRequests.Store(permission.ID, respCh)
83+ defer s.pendingRequests.Delete(permission.ID)
84+
85+ s.Publish(pubsub.CreatedEvent, permission)
86+
87+ // Wait for the response with a timeout
88+ select {
89+ case resp := <-respCh:
90+ return resp
91+ case <-time.After(10 * time.Minute):
92+ return false
93+ }
94+}
95+
96+func NewPermissionService() Service {
97+ return &permissionService{
98+ Broker: pubsub.NewBroker[PermissionRequest](),
99+ sessionPermissions: make([]PermissionRequest, 0),
100+ }
101+}
102+
103+var Default Service = NewPermissionService()
internal/pubsub/broker.go+1−1
@@ -5,7 +5,7 @@ import (
5 "sync"
5 "sync"
6)
6)
7
7
8−const bufferSize = 1024
8+const bufferSize = 1024 * 1024
9
9
10type Logger interface {
10type Logger interface {
11 Debug(msg string, args ...any)
11 Debug(msg string, args ...any)
internal/tui/components/core/button.gonew file+287
@@ -0,0 +1,287 @@
1+package core
2+
3+import (
4+ "github.com/charmbracelet/bubbles/key"
5+ tea "github.com/charmbracelet/bubbletea"
6+ "github.com/charmbracelet/lipgloss"
7+ "github.com/kujtimiihoxha/termai/internal/tui/styles"
8+)
9+
10+// ButtonKeyMap defines key bindings for the button component
11+type ButtonKeyMap struct {
12+ Enter key.Binding
13+}
14+
15+// DefaultButtonKeyMap returns default key bindings for the button
16+func DefaultButtonKeyMap() ButtonKeyMap {
17+ return ButtonKeyMap{
18+ Enter: key.NewBinding(
19+ key.WithKeys("enter"),
20+ key.WithHelp("enter", "select"),
21+ ),
22+ }
23+}
24+
25+// ShortHelp returns keybinding help
26+func (k ButtonKeyMap) ShortHelp() []key.Binding {
27+ return []key.Binding{k.Enter}
28+}
29+
30+// FullHelp returns full help info for keybindings
31+func (k ButtonKeyMap) FullHelp() [][]key.Binding {
32+ return [][]key.Binding{
33+ {k.Enter},
34+ }
35+}
36+
37+// ButtonState represents the state of a button
38+type ButtonState int
39+
40+const (
41+ // ButtonNormal is the default state
42+ ButtonNormal ButtonState = iota
43+ // ButtonHovered is when the button is focused/hovered
44+ ButtonHovered
45+ // ButtonPressed is when the button is being pressed
46+ ButtonPressed
47+ // ButtonDisabled is when the button is disabled
48+ ButtonDisabled
49+)
50+
51+// ButtonVariant defines the visual style variant of a button
52+type ButtonVariant int
53+
54+const (
55+ // ButtonPrimary uses primary color styling
56+ ButtonPrimary ButtonVariant = iota
57+ // ButtonSecondary uses secondary color styling
58+ ButtonSecondary
59+ // ButtonDanger uses danger/error color styling
60+ ButtonDanger
61+ // ButtonWarning uses warning color styling
62+ ButtonWarning
63+ // ButtonNeutral uses neutral color styling
64+ ButtonNeutral
65+)
66+
67+// ButtonMsg is sent when a button is clicked
68+type ButtonMsg struct {
69+ ID string
70+ Payload interface{}
71+}
72+
73+// ButtonCmp represents a clickable button component
74+type ButtonCmp struct {
75+ id string
76+ label string
77+ width int
78+ height int
79+ state ButtonState
80+ variant ButtonVariant
81+ keyMap ButtonKeyMap
82+ payload interface{}
83+ style lipgloss.Style
84+ hoverStyle lipgloss.Style
85+}
86+
87+// NewButtonCmp creates a new button component
88+func NewButtonCmp(id, label string) *ButtonCmp {
89+ b := &ButtonCmp{
90+ id: id,
91+ label: label,
92+ state: ButtonNormal,
93+ variant: ButtonPrimary,
94+ keyMap: DefaultButtonKeyMap(),
95+ width: len(label) + 4, // add some padding
96+ height: 1,
97+ }
98+ b.updateStyles()
99+ return b
100+}
101+
102+// WithVariant sets the button variant
103+func (b *ButtonCmp) WithVariant(variant ButtonVariant) *ButtonCmp {
104+ b.variant = variant
105+ b.updateStyles()
106+ return b
107+}
108+
109+// WithPayload sets the payload sent with button events
110+func (b *ButtonCmp) WithPayload(payload interface{}) *ButtonCmp {
111+ b.payload = payload
112+ return b
113+}
114+
115+// WithWidth sets a custom width
116+func (b *ButtonCmp) WithWidth(width int) *ButtonCmp {
117+ b.width = width
118+ b.updateStyles()
119+ return b
120+}
121+
122+// updateStyles recalculates styles based on current state and variant
123+func (b *ButtonCmp) updateStyles() {
124+ // Base styles
125+ b.style = styles.Regular.
126+ Padding(0, 1).
127+ Width(b.width).
128+ Align(lipgloss.Center).
129+ BorderStyle(lipgloss.RoundedBorder())
130+
131+ b.hoverStyle = b.style.
132+ Bold(true)
133+
134+ // Variant-specific styling
135+ switch b.variant {
136+ case ButtonPrimary:
137+ b.style = b.style.
138+ Foreground(styles.Base).
139+ Background(styles.Primary).
140+ BorderForeground(styles.Primary)
141+
142+ b.hoverStyle = b.hoverStyle.
143+ Foreground(styles.Base).
144+ Background(styles.Blue).
145+ BorderForeground(styles.Blue)
146+
147+ case ButtonSecondary:
148+ b.style = b.style.
149+ Foreground(styles.Base).
150+ Background(styles.Secondary).
151+ BorderForeground(styles.Secondary)
152+
153+ b.hoverStyle = b.hoverStyle.
154+ Foreground(styles.Base).
155+ Background(styles.Mauve).
156+ BorderForeground(styles.Mauve)
157+
158+ case ButtonDanger:
159+ b.style = b.style.
160+ Foreground(styles.Base).
161+ Background(styles.Error).
162+ BorderForeground(styles.Error)
163+
164+ b.hoverStyle = b.hoverStyle.
165+ Foreground(styles.Base).
166+ Background(styles.Red).
167+ BorderForeground(styles.Red)
168+
169+ case ButtonWarning:
170+ b.style = b.style.
171+ Foreground(styles.Text).
172+ Background(styles.Warning).
173+ BorderForeground(styles.Warning)
174+
175+ b.hoverStyle = b.hoverStyle.
176+ Foreground(styles.Text).
177+ Background(styles.Peach).
178+ BorderForeground(styles.Peach)
179+
180+ case ButtonNeutral:
181+ b.style = b.style.
182+ Foreground(styles.Text).
183+ Background(styles.Grey).
184+ BorderForeground(styles.Grey)
185+
186+ b.hoverStyle = b.hoverStyle.
187+ Foreground(styles.Text).
188+ Background(styles.DarkGrey).
189+ BorderForeground(styles.DarkGrey)
190+ }
191+
192+ // Disabled style override
193+ if b.state == ButtonDisabled {
194+ b.style = b.style.
195+ Foreground(styles.SubText0).
196+ Background(styles.LightGrey).
197+ BorderForeground(styles.LightGrey)
198+ }
199+}
200+
201+// SetSize sets the button size
202+func (b *ButtonCmp) SetSize(width, height int) {
203+ b.width = width
204+ b.height = height
205+ b.updateStyles()
206+}
207+
208+// Focus sets the button to focused state
209+func (b *ButtonCmp) Focus() tea.Cmd {
210+ if b.state != ButtonDisabled {
211+ b.state = ButtonHovered
212+ }
213+ return nil
214+}
215+
216+// Blur sets the button to normal state
217+func (b *ButtonCmp) Blur() tea.Cmd {
218+ if b.state != ButtonDisabled {
219+ b.state = ButtonNormal
220+ }
221+ return nil
222+}
223+
224+// Disable sets the button to disabled state
225+func (b *ButtonCmp) Disable() {
226+ b.state = ButtonDisabled
227+ b.updateStyles()
228+}
229+
230+// Enable enables the button if disabled
231+func (b *ButtonCmp) Enable() {
232+ if b.state == ButtonDisabled {
233+ b.state = ButtonNormal
234+ b.updateStyles()
235+ }
236+}
237+
238+// IsDisabled returns whether the button is disabled
239+func (b *ButtonCmp) IsDisabled() bool {
240+ return b.state == ButtonDisabled
241+}
242+
243+// IsFocused returns whether the button is focused
244+func (b *ButtonCmp) IsFocused() bool {
245+ return b.state == ButtonHovered
246+}
247+
248+// Init initializes the button
249+func (b *ButtonCmp) Init() tea.Cmd {
250+ return nil
251+}
252+
253+// Update handles messages and user input
254+func (b *ButtonCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
255+ // Skip updates if disabled
256+ if b.state == ButtonDisabled {
257+ return b, nil
258+ }
259+
260+ switch msg := msg.(type) {
261+ case tea.KeyMsg:
262+ // Handle key presses when focused
263+ if b.state == ButtonHovered {
264+ switch {
265+ case key.Matches(msg, b.keyMap.Enter):
266+ b.state = ButtonPressed
267+ return b, func() tea.Msg {
268+ return ButtonMsg{
269+ ID: b.id,
270+ Payload: b.payload,
271+ }
272+ }
273+ }
274+ }
275+ }
276+
277+ return b, nil
278+}
279+
280+// View renders the button
281+func (b *ButtonCmp) View() string {
282+ if b.state == ButtonHovered || b.state == ButtonPressed {
283+ return b.hoverStyle.Render(b.label)
284+ }
285+ return b.style.Render(b.label)
286+}
287+
internal/tui/components/dialog/permission.gonew file+167
@@ -0,0 +1,167 @@
1+package dialog
2+
3+import (
4+ "github.com/charmbracelet/bubbles/key"
5+ tea "github.com/charmbracelet/bubbletea"
6+ "github.com/charmbracelet/lipgloss"
7+ "github.com/kujtimiihoxha/termai/internal/permission"
8+ "github.com/kujtimiihoxha/termai/internal/tui/components/core"
9+ "github.com/kujtimiihoxha/termai/internal/tui/layout"
10+ "github.com/kujtimiihoxha/termai/internal/tui/styles"
11+ "github.com/kujtimiihoxha/termai/internal/tui/util"
12+
13+ "github.com/charmbracelet/huh"
14+)
15+
16+type PermissionAction string
17+
18+// Permission responses
19+const (
20+ PermissionAllow PermissionAction = "allow"
21+ PermissionAllowForSession PermissionAction = "allow_session"
22+ PermissionDeny PermissionAction = "deny"
23+)
24+
25+// PermissionResponseMsg represents the user's response to a permission request
26+type PermissionResponseMsg struct {
27+ Permission permission.PermissionRequest
28+ Action PermissionAction
29+}
30+
31+// Width and height constants for the dialog
32+var (
33+ permissionWidth = 60
34+ permissionHeight = 10
35+)
36+
37+// PermissionDialog interface for permission dialog component
38+type PermissionDialog interface {
39+ tea.Model
40+ layout.Sizeable
41+ layout.Bindings
42+}
43+
44+// permissionDialogCmp is the implementation of PermissionDialog
45+type permissionDialogCmp struct {
46+ form *huh.Form
47+ content string
48+ width int
49+ height int
50+ permission permission.PermissionRequest
51+}
52+
53+func (p *permissionDialogCmp) Init() tea.Cmd {
54+ return nil
55+}
56+
57+func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
58+ var cmds []tea.Cmd
59+
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)
65+ }
66+
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+ )
76+ }
77+
78+ return p, tea.Batch(cmds...)
79+}
80+
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)
87+
88+ return lipgloss.JoinVertical(
89+ lipgloss.Center,
90+ contentStyle.Render(p.content),
91+ p.form.View(),
92+ )
93+}
94+
95+func (p *permissionDialogCmp) GetSize() (int, int) {
96+ return p.width, p.height
97+}
98+
99+func (p *permissionDialogCmp) SetSize(width int, height int) {
100+ p.width = width
101+ p.height = height
102+}
103+
104+func (p *permissionDialogCmp) BindingKeys() []key.Binding {
105+ return p.form.KeyBinds()
106+}
107+
108+func newPermissionDialogCmp(permission permission.PermissionRequest, content string) PermissionDialog {
109+ // Create a note field for displaying the content
110+
111+ // Create select field for the permission options
112+ selectOption := huh.NewSelect[string]().
113+ Key("action").
114+ Options(
115+ huh.NewOption("Allow", string(PermissionAllow)),
116+ huh.NewOption("Allow for this session", string(PermissionAllowForSession)),
117+ huh.NewOption("Deny", string(PermissionDeny)),
118+ ).
119+ Title("Permission Request")
120+
121+ // Apply theme
122+ theme := styles.HuhTheme()
123+
124+ // Setup form width and height
125+ form := huh.NewForm(huh.NewGroup(selectOption)).
126+ WithWidth(permissionWidth - 2).
127+ WithShowHelp(false).
128+ WithTheme(theme).
129+ WithShowErrors(false)
130+
131+ // Focus the form for immediate interaction
132+ selectOption.Focus()
133+
134+ return &permissionDialogCmp{
135+ permission: permission,
136+ form: form,
137+ content: content,
138+ width: permissionWidth,
139+ height: permissionHeight,
140+ }
141+}
142+
143+// NewPermissionDialogCmd creates a new permission dialog command
144+func NewPermissionDialogCmd(permission permission.PermissionRequest, content string) tea.Cmd {
145+ permDialog := newPermissionDialogCmp(permission, content)
146+
147+ // Create the dialog layout
148+ dialogPane := layout.NewSinglePane(
149+ permDialog.(*permissionDialogCmp),
150+ layout.WithSignlePaneSize(permissionWidth+2, permissionHeight+2),
151+ layout.WithSinglePaneBordered(true),
152+ layout.WithSinglePaneFocusable(true),
153+ layout.WithSinglePaneActiveColor(styles.Blue),
154+ layout.WithSignlePaneBorderText(map[layout.BorderPosition]string{
155+ layout.TopMiddleBorder: " Permission Required ",
156+ }),
157+ )
158+
159+ // Focus the dialog
160+ dialogPane.Focus()
161+
162+ // Return the dialog command
163+ return util.CmdHandler(core.DialogMsg{
164+ Content: dialogPane,
165+ })
166+}
167+
internal/tui/components/messages/message.gonew file+108
@@ -0,0 +1,108 @@
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+11−3
@@ -5,9 +5,11 @@ import (
5
5
6 "github.com/charmbracelet/bubbles/key"
6 "github.com/charmbracelet/bubbles/key"
7 tea "github.com/charmbracelet/bubbletea"
7 tea "github.com/charmbracelet/bubbletea"
8+ "github.com/charmbracelet/lipgloss"
8 "github.com/cloudwego/eino/schema"
9 "github.com/cloudwego/eino/schema"
9 "github.com/kujtimiihoxha/termai/internal/app"
10 "github.com/kujtimiihoxha/termai/internal/app"
10 "github.com/kujtimiihoxha/termai/internal/tui/layout"
11 "github.com/kujtimiihoxha/termai/internal/tui/layout"
12+ "github.com/kujtimiihoxha/termai/internal/tui/styles"
11 "github.com/kujtimiihoxha/vimtea"
13 "github.com/kujtimiihoxha/vimtea"
12)
14)
13
15
@@ -105,8 +107,12 @@ func (m *editorCmp) Blur() tea.Cmd {
105}
107}
106
108
107func (m *editorCmp) BorderText() map[layout.BorderPosition]string {
109func (m *editorCmp) BorderText() map[layout.BorderPosition]string {
110+ title := "New Message"
111+ if m.focused {
112+ title = lipgloss.NewStyle().Foreground(styles.Primary).Render(title)
113+ }
108 return map[layout.BorderPosition]string{
114 return map[layout.BorderPosition]string{
109− layout.TopLeftBorder: "New Message",
115+ layout.TopLeftBorder: title,
110 }
116 }
111}
117}
112
118
@@ -148,7 +154,9 @@ func (m *editorCmp) BindingKeys() []key.Binding {
148
154
149func NewEditorCmp(app *app.App) EditorCmp {
155func NewEditorCmp(app *app.App) EditorCmp {
150 return &editorCmp{
156 return &editorCmp{
151− app: app,
157+ app: app,
152− editor: vimtea.NewEditor(),
158+ editor: vimtea.NewEditor(
159+ vimtea.WithFileName("message.md"),
160+ ),
153 }
161 }
154}
162}
internal/tui/components/repl/messages.go+157−23
@@ -1,15 +1,22 @@
1package repl
1package repl
2
2
3import (
3import (
4+ "fmt"
5+ "slices"
6+ "strings"
7+
4 "github.com/charmbracelet/bubbles/key"
8 "github.com/charmbracelet/bubbles/key"
5 "github.com/charmbracelet/bubbles/viewport"
9 "github.com/charmbracelet/bubbles/viewport"
6 tea "github.com/charmbracelet/bubbletea"
10 tea "github.com/charmbracelet/bubbletea"
11+ "github.com/charmbracelet/glamour"
7 "github.com/charmbracelet/lipgloss"
12 "github.com/charmbracelet/lipgloss"
13+ "github.com/cloudwego/eino/schema"
8 "github.com/kujtimiihoxha/termai/internal/app"
14 "github.com/kujtimiihoxha/termai/internal/app"
9 "github.com/kujtimiihoxha/termai/internal/message"
15 "github.com/kujtimiihoxha/termai/internal/message"
10 "github.com/kujtimiihoxha/termai/internal/pubsub"
16 "github.com/kujtimiihoxha/termai/internal/pubsub"
11 "github.com/kujtimiihoxha/termai/internal/session"
17 "github.com/kujtimiihoxha/termai/internal/session"
12 "github.com/kujtimiihoxha/termai/internal/tui/layout"
18 "github.com/kujtimiihoxha/termai/internal/tui/layout"
19+ "github.com/kujtimiihoxha/termai/internal/tui/styles"
13)
20)
14
21
15type MessagesCmp interface {
22type MessagesCmp interface {
@@ -21,13 +28,15 @@ type MessagesCmp interface {
21}
28}
22
29
23type messagesCmp struct {
30type messagesCmp struct {
24− app *app.App
31+ app *app.App
25− messages []message.Message
32+ messages []message.Message
26− session session.Session
33+ session session.Session
27− viewport viewport.Model
34+ viewport viewport.Model
35+ mdRenderer *glamour.TermRenderer
28− width int
36+ width int
29− height int
37+ height int
30− focused bool
38+ focused bool
39+ cachedView string
31}
40}
32
41
33func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
42func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -35,6 +44,8 @@ func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
35 case pubsub.Event[message.Message]:
44 case pubsub.Event[message.Message]:
36 if msg.Type == pubsub.CreatedEvent {
45 if msg.Type == pubsub.CreatedEvent {
37 m.messages = append(m.messages, msg.Payload)
46 m.messages = append(m.messages, msg.Payload)
47+ m.renderView()
48+ m.viewport.GotoBottom()
38 }
49 }
39 case pubsub.Event[session.Session]:
50 case pubsub.Event[session.Session]:
40 if msg.Type == pubsub.UpdatedEvent {
51 if msg.Type == pubsub.UpdatedEvent {
@@ -45,60 +56,182 @@ func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
45 case SelectedSessionMsg:
56 case SelectedSessionMsg:
46 m.session, _ = m.app.Sessions.Get(msg.SessionID)
57 m.session, _ = m.app.Sessions.Get(msg.SessionID)
47 m.messages, _ = m.app.Messages.List(m.session.ID)
58 m.messages, _ = m.app.Messages.List(m.session.ID)
59+ m.renderView()
60+ m.viewport.GotoBottom()
61+ }
62+ if m.focused {
63+ u, cmd := m.viewport.Update(msg)
64+ m.viewport = u
65+ return m, cmd
48 }
66 }
49 return m, nil
67 return m, nil
50}
68}
51
69
52−func (i *messagesCmp) View() string {
70+func borderColor(role schema.RoleType) lipgloss.TerminalColor {
53− stringMessages := make([]string, len(i.messages))
71+ switch role {
54− for idx, msg := range i.messages {
72+ case schema.Assistant:
55− stringMessages[idx] = msg.MessageData.Content
73+ return styles.Mauve
74+ case schema.User:
75+ return styles.Rosewater
76+ case schema.Tool:
77+ return styles.Peach
56 }
78 }
79+ return styles.Blue
80+}
81+
82+func borderText(msgRole schema.RoleType, currentMessage int) map[layout.BorderPosition]string {
83+ role := ""
84+ icon := ""
85+ switch msgRole {
86+ case schema.Assistant:
87+ role = "Assistant"
88+ icon = styles.BotIcon
89+ case schema.User:
90+ role = "User"
91+ icon = styles.UserIcon
92+ }
93+ return map[layout.BorderPosition]string{
94+ layout.TopLeftBorder: lipgloss.NewStyle().
95+ Padding(0, 1).
96+ Bold(true).
97+ Foreground(styles.Crust).
98+ Background(borderColor(msgRole)).
99+ Render(fmt.Sprintf("%s %s ", role, icon)),
100+ layout.TopRightBorder: lipgloss.NewStyle().
101+ Padding(0, 1).
102+ Bold(true).
103+ Foreground(styles.Crust).
104+ Background(borderColor(msgRole)).
105+ Render(fmt.Sprintf("#%d ", currentMessage)),
106+ }
107+}
108+
109+func (m *messagesCmp) renderView() {
110+ stringMessages := make([]string, 0)
111+ r, _ := glamour.NewTermRenderer(
112+ glamour.WithStyles(styles.CatppuccinMarkdownStyle()),
113+ glamour.WithWordWrap(m.width-10),
114+ glamour.WithEmoji(),
115+ )
116+ textStyle := lipgloss.NewStyle().Width(m.width - 4)
117+ 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+ }
156+ }
157+ content, _ = r.Render(content)
158+ stringMessages = append(stringMessages, layout.Borderize(
159+ textStyle.Render(content),
160+ 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+ },
181+ },
182+ ))
183+ currentMessage++
184+ }
185+ }
57− return lipgloss.JoinVertical(lipgloss.Top, stringMessages...)
186+ m.viewport.SetContent(lipgloss.JoinVertical(lipgloss.Top, stringMessages...))
187+}
188+
189+func (m *messagesCmp) View() string {
190+ return lipgloss.NewStyle().Padding(1).Render(m.viewport.View())
58}
191}
59
192
60−// BindingKeys implements MessagesCmp.
61func (m *messagesCmp) BindingKeys() []key.Binding {
193func (m *messagesCmp) BindingKeys() []key.Binding {
62− return []key.Binding{}
194+ return layout.KeyMapToSlice(m.viewport.KeyMap)
63}
195}
64
196
65−// Blur implements MessagesCmp.
66func (m *messagesCmp) Blur() tea.Cmd {
197func (m *messagesCmp) Blur() tea.Cmd {
67 m.focused = false
198 m.focused = false
68 return nil
199 return nil
69}
200}
70
201
71−// BorderText implements MessagesCmp.
72func (m *messagesCmp) BorderText() map[layout.BorderPosition]string {
202func (m *messagesCmp) BorderText() map[layout.BorderPosition]string {
73 title := m.session.Title
203 title := m.session.Title
204+ titleWidth := m.width / 2
74− if len(title) > 20 {
205+ if len(title) > titleWidth {
75− title = title[:20] + "..."
206+ title = title[:titleWidth] + "..."
207+ }
208+ if m.focused {
209+ title = lipgloss.NewStyle().Foreground(styles.Primary).Render(title)
76 }
210 }
77 return map[layout.BorderPosition]string{
211 return map[layout.BorderPosition]string{
78− layout.TopLeftBorder: title,
212+ layout.TopLeftBorder: title,
213+ layout.BottomRightBorder: formatTokensAndCost(m.session.CompletionTokens+m.session.PromptTokens, m.session.Cost),
79 }
214 }
80}
215}
81
216
82−// Focus implements MessagesCmp.
83func (m *messagesCmp) Focus() tea.Cmd {
217func (m *messagesCmp) Focus() tea.Cmd {
84 m.focused = true
218 m.focused = true
85 return nil
219 return nil
86}
220}
87
221
88−// GetSize implements MessagesCmp.
89func (m *messagesCmp) GetSize() (int, int) {
222func (m *messagesCmp) GetSize() (int, int) {
90 return m.width, m.height
223 return m.width, m.height
91}
224}
92
225
93−// IsFocused implements MessagesCmp.
94func (m *messagesCmp) IsFocused() bool {
226func (m *messagesCmp) IsFocused() bool {
95 return m.focused
227 return m.focused
96}
228}
97
229
98−// SetSize implements MessagesCmp.
99func (m *messagesCmp) SetSize(width int, height int) {
230func (m *messagesCmp) SetSize(width int, height int) {
100 m.width = width
231 m.width = width
101 m.height = height
232 m.height = height
233+ m.viewport.Width = width - 2 // padding
234+ m.viewport.Height = height - 2 // padding
102}
235}
103
236
104func (m *messagesCmp) Init() tea.Cmd {
237func (m *messagesCmp) Init() tea.Cmd {
@@ -109,5 +242,6 @@ func NewMessagesCmp(app *app.App) MessagesCmp {
109 return &messagesCmp{
242 return &messagesCmp{
110 app: app,
243 app: app,
111 messages: []message.Message{},
244 messages: []message.Message{},
245+ viewport: viewport.New(0, 0),
112 }
246 }
113}
247}
internal/tui/components/repl/sessions.go+7−1
@@ -7,6 +7,7 @@ import (
7 "github.com/charmbracelet/bubbles/key"
7 "github.com/charmbracelet/bubbles/key"
8 "github.com/charmbracelet/bubbles/list"
8 "github.com/charmbracelet/bubbles/list"
9 tea "github.com/charmbracelet/bubbletea"
9 tea "github.com/charmbracelet/bubbletea"
10+ "github.com/charmbracelet/lipgloss"
10 "github.com/kujtimiihoxha/termai/internal/app"
11 "github.com/kujtimiihoxha/termai/internal/app"
11 "github.com/kujtimiihoxha/termai/internal/pubsub"
12 "github.com/kujtimiihoxha/termai/internal/pubsub"
12 "github.com/kujtimiihoxha/termai/internal/session"
13 "github.com/kujtimiihoxha/termai/internal/session"
@@ -160,8 +161,13 @@ func (i *sessionsCmp) BorderText() map[layout.BorderPosition]string {
160 current,
161 current,
161 totalCount,
162 totalCount,
162 )
163 )
164+
165+ title := "Sessions"
166+ if i.focused {
167+ title = lipgloss.NewStyle().Foreground(styles.Primary).Render(title)
168+ }
163 return map[layout.BorderPosition]string{
169 return map[layout.BorderPosition]string{
164− layout.TopMiddleBorder: "Sessions",
170+ layout.TopMiddleBorder: title,
165 layout.BottomMiddleBorder: pageInfo,
171 layout.BottomMiddleBorder: pageInfo,
166 }
172 }
167}
173}
internal/tui/layout/border.go+37−18
@@ -24,24 +24,43 @@ var (
24 InactivePreviewBorder = styles.Grey
24 InactivePreviewBorder = styles.Grey
25)
25)
26
26
27+type BorderOptions struct {
28+ Active bool
29+ EmbeddedText map[BorderPosition]string
30+ ActiveColor lipgloss.TerminalColor
31+ InactiveColor lipgloss.TerminalColor
32+ ActiveBorder lipgloss.Border
33+ InactiveBorder lipgloss.Border
34+}
35+
27−func Borderize(content string, active bool, embeddedText map[BorderPosition]string, activeColor lipgloss.TerminalColor) string {
36+func Borderize(content string, opts BorderOptions) string {
28− if embeddedText == nil {
37+ if opts.EmbeddedText == nil {
29− embeddedText = make(map[BorderPosition]string)
38+ opts.EmbeddedText = make(map[BorderPosition]string)
39+ }
40+ if opts.ActiveColor == nil {
41+ opts.ActiveColor = ActiveBorder
30 }
42 }
31− if activeColor == nil {
43+ if opts.InactiveColor == nil {
32− activeColor = ActiveBorder
44+ opts.InactiveColor = InactivePreviewBorder
33 }
45 }
46+ if opts.ActiveBorder == (lipgloss.Border{}) {
47+ opts.ActiveBorder = lipgloss.ThickBorder()
48+ }
49+ if opts.InactiveBorder == (lipgloss.Border{}) {
50+ opts.InactiveBorder = lipgloss.NormalBorder()
51+ }
52+
34 var (
53 var (
35 thickness = map[bool]lipgloss.Border{
54 thickness = map[bool]lipgloss.Border{
36− true: lipgloss.Border(lipgloss.ThickBorder()),
55+ true: opts.ActiveBorder,
37− false: lipgloss.Border(lipgloss.NormalBorder()),
56+ false: opts.InactiveBorder,
38 }
57 }
39 color = map[bool]lipgloss.TerminalColor{
58 color = map[bool]lipgloss.TerminalColor{
40− true: activeColor,
59+ true: opts.ActiveColor,
41− false: InactivePreviewBorder,
60+ false: opts.InactiveColor,
42 }
61 }
43− border = thickness[active]
62+ border = thickness[opts.Active]
44− style = lipgloss.NewStyle().Foreground(color[active])
63+ style = lipgloss.NewStyle().Foreground(color[opts.Active])
45 width = lipgloss.Width(content)
64 width = lipgloss.Width(content)
46 )
65 )
47
66
@@ -80,20 +99,20 @@ func Borderize(content string, active bool, embeddedText map[BorderPosition]stri
80 // Stack top border, content and horizontal borders, and bottom border.
99 // Stack top border, content and horizontal borders, and bottom border.
81 return strings.Join([]string{
100 return strings.Join([]string{
82 buildHorizontalBorder(
101 buildHorizontalBorder(
83− embeddedText[TopLeftBorder],
102+ opts.EmbeddedText[TopLeftBorder],
84− embeddedText[TopMiddleBorder],
103+ opts.EmbeddedText[TopMiddleBorder],
85− embeddedText[TopRightBorder],
104+ opts.EmbeddedText[TopRightBorder],
86 border.TopLeft,
105 border.TopLeft,
87 border.Top,
106 border.Top,
88 border.TopRight,
107 border.TopRight,
89 ),
108 ),
90 lipgloss.NewStyle().
109 lipgloss.NewStyle().
91− BorderForeground(color[active]).
110+ BorderForeground(color[opts.Active]).
92 Border(border, false, true, false, true).Render(content),
111 Border(border, false, true, false, true).Render(content),
93 buildHorizontalBorder(
112 buildHorizontalBorder(
94− embeddedText[BottomLeftBorder],
113+ opts.EmbeddedText[BottomLeftBorder],
95− embeddedText[BottomMiddleBorder],
114+ opts.EmbeddedText[BottomMiddleBorder],
96− embeddedText[BottomRightBorder],
115+ opts.EmbeddedText[BottomRightBorder],
97 border.BottomLeft,
116 border.BottomLeft,
98 border.Bottom,
117 border.Bottom,
99 border.BottomRight,
118 border.BottomRight,
internal/tui/layout/grid.gonew file+254
@@ -0,0 +1,254 @@
1+package layout
2+
3+import (
4+ "github.com/charmbracelet/bubbles/key"
5+ tea "github.com/charmbracelet/bubbletea"
6+ "github.com/charmbracelet/lipgloss"
7+)
8+
9+type GridLayout interface {
10+ tea.Model
11+ Sizeable
12+ Bindings
13+ Panes() [][]tea.Model
14+}
15+
16+type gridLayout struct {
17+ width int
18+ height int
19+
20+ rows int
21+ columns int
22+
23+ panes [][]tea.Model
24+
25+ gap int
26+ bordered bool
27+ focusable bool
28+
29+ currentRow int
30+ currentColumn int
31+
32+ activeColor lipgloss.TerminalColor
33+}
34+
35+type GridOption func(*gridLayout)
36+
37+func (g *gridLayout) Init() tea.Cmd {
38+ var cmds []tea.Cmd
39+ for i := range g.panes {
40+ for j := range g.panes[i] {
41+ if g.panes[i][j] != nil {
42+ cmds = append(cmds, g.panes[i][j].Init())
43+ }
44+ }
45+ }
46+ return tea.Batch(cmds...)
47+}
48+
49+func (g *gridLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
50+ var cmds []tea.Cmd
51+
52+ switch msg := msg.(type) {
53+ case tea.WindowSizeMsg:
54+ g.SetSize(msg.Width, msg.Height)
55+ return g, nil
56+ case tea.KeyMsg:
57+ if key.Matches(msg, g.nextPaneBinding()) {
58+ return g.focusNextPane()
59+ }
60+ }
61+
62+ // Update all panes
63+ for i := range g.panes {
64+ for j := range g.panes[i] {
65+ if g.panes[i][j] != nil {
66+ var cmd tea.Cmd
67+ g.panes[i][j], cmd = g.panes[i][j].Update(msg)
68+ if cmd != nil {
69+ cmds = append(cmds, cmd)
70+ }
71+ }
72+ }
73+ }
74+
75+ return g, tea.Batch(cmds...)
76+}
77+
78+func (g *gridLayout) focusNextPane() (tea.Model, tea.Cmd) {
79+ if !g.focusable {
80+ return g, nil
81+ }
82+
83+ var cmds []tea.Cmd
84+
85+ // Blur current pane
86+ if g.currentRow < len(g.panes) && g.currentColumn < len(g.panes[g.currentRow]) {
87+ if currentPane, ok := g.panes[g.currentRow][g.currentColumn].(Focusable); ok {
88+ cmds = append(cmds, currentPane.Blur())
89+ }
90+ }
91+
92+ // Find next valid pane
93+ g.currentColumn++
94+ if g.currentColumn >= len(g.panes[g.currentRow]) {
95+ g.currentColumn = 0
96+ g.currentRow++
97+ if g.currentRow >= len(g.panes) {
98+ g.currentRow = 0
99+ }
100+ }
101+
102+ // Focus next pane
103+ if g.currentRow < len(g.panes) && g.currentColumn < len(g.panes[g.currentRow]) {
104+ if nextPane, ok := g.panes[g.currentRow][g.currentColumn].(Focusable); ok {
105+ cmds = append(cmds, nextPane.Focus())
106+ }
107+ }
108+
109+ return g, tea.Batch(cmds...)
110+}
111+
112+func (g *gridLayout) nextPaneBinding() key.Binding {
113+ return key.NewBinding(
114+ key.WithKeys("tab"),
115+ key.WithHelp("tab", "next pane"),
116+ )
117+}
118+
119+func (g *gridLayout) View() string {
120+ if len(g.panes) == 0 {
121+ return ""
122+ }
123+
124+ // Calculate dimensions for each cell
125+ cellWidth := (g.width - (g.columns-1)*g.gap) / g.columns
126+ cellHeight := (g.height - (g.rows-1)*g.gap) / g.rows
127+
128+ // Render each row
129+ rows := make([]string, g.rows)
130+ for i := 0; i < g.rows; i++ {
131+ // Render each column in this row
132+ cols := make([]string, len(g.panes[i]))
133+ for j := 0; j < len(g.panes[i]); j++ {
134+ if g.panes[i][j] == nil {
135+ cols[j] = ""
136+ continue
137+ }
138+
139+ // Set size for each pane
140+ if sizable, ok := g.panes[i][j].(Sizeable); ok {
141+ effectiveWidth, effectiveHeight := cellWidth, cellHeight
142+ if g.bordered {
143+ effectiveWidth -= 2
144+ effectiveHeight -= 2
145+ }
146+ sizable.SetSize(effectiveWidth, effectiveHeight)
147+ }
148+
149+ // Render the pane
150+ content := g.panes[i][j].View()
151+
152+ // Apply border if needed
153+ if g.bordered {
154+ isFocused := false
155+ if focusable, ok := g.panes[i][j].(Focusable); ok {
156+ isFocused = focusable.IsFocused()
157+ }
158+
159+ borderText := map[BorderPosition]string{}
160+ if bordered, ok := g.panes[i][j].(Bordered); ok {
161+ borderText = bordered.BorderText()
162+ }
163+
164+ content = Borderize(content, BorderOptions{
165+ Active: isFocused,
166+ EmbeddedText: borderText,
167+ })
168+ }
169+
170+ cols[j] = content
171+ }
172+
173+ // Join columns with gap
174+ rows[i] = lipgloss.JoinHorizontal(lipgloss.Top, cols...)
175+ }
176+
177+ // Join rows with gap
178+ return lipgloss.JoinVertical(lipgloss.Left, rows...)
179+}
180+
181+func (g *gridLayout) SetSize(width, height int) {
182+ g.width = width
183+ g.height = height
184+}
185+
186+func (g *gridLayout) GetSize() (int, int) {
187+ return g.width, g.height
188+}
189+
190+func (g *gridLayout) BindingKeys() []key.Binding {
191+ var bindings []key.Binding
192+ bindings = append(bindings, g.nextPaneBinding())
193+
194+ // Collect bindings from all panes
195+ for i := range g.panes {
196+ for j := range g.panes[i] {
197+ if g.panes[i][j] != nil {
198+ if bindable, ok := g.panes[i][j].(Bindings); ok {
199+ bindings = append(bindings, bindable.BindingKeys()...)
200+ }
201+ }
202+ }
203+ }
204+
205+ return bindings
206+}
207+
208+func (g *gridLayout) Panes() [][]tea.Model {
209+ return g.panes
210+}
211+
212+// NewGridLayout creates a new grid layout with the given number of rows and columns
213+func NewGridLayout(rows, cols int, panes [][]tea.Model, opts ...GridOption) GridLayout {
214+ grid := &gridLayout{
215+ rows: rows,
216+ columns: cols,
217+ panes: panes,
218+ gap: 1,
219+ }
220+
221+ for _, opt := range opts {
222+ opt(grid)
223+ }
224+
225+ return grid
226+}
227+
228+// WithGridGap sets the gap between cells
229+func WithGridGap(gap int) GridOption {
230+ return func(g *gridLayout) {
231+ g.gap = gap
232+ }
233+}
234+
235+// WithGridBordered sets whether cells should have borders
236+func WithGridBordered(bordered bool) GridOption {
237+ return func(g *gridLayout) {
238+ g.bordered = bordered
239+ }
240+}
241+
242+// WithGridFocusable sets whether the grid supports focus navigation
243+func WithGridFocusable(focusable bool) GridOption {
244+ return func(g *gridLayout) {
245+ g.focusable = focusable
246+ }
247+}
248+
249+// WithGridActiveColor sets the active border color
250+func WithGridActiveColor(color lipgloss.TerminalColor) GridOption {
251+ return func(g *gridLayout) {
252+ g.activeColor = color
253+ }
254+}
internal/tui/layout/single.go+4−1
@@ -64,7 +64,10 @@ func (s *singlePaneLayout) View() string {
64 if bordered, ok := s.content.(Bordered); ok {
64 if bordered, ok := s.content.(Bordered); ok {
65 s.borderText = bordered.BorderText()
65 s.borderText = bordered.BorderText()
66 }
66 }
67− return Borderize(content, s.focused, s.borderText, s.activeColor)
67+ return Borderize(content, BorderOptions{
68+ Active: s.focused,
69+ EmbeddedText: s.borderText,
70+ })
68 }
71 }
69 return content
72 return content
70}
73}
internal/tui/page/init.go+280−9
@@ -1,36 +1,307 @@
1package page
1package page
2
2
3import (
3import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "strconv"
8+
9+ "github.com/charmbracelet/bubbles/key"
4 tea "github.com/charmbracelet/bubbletea"
10 tea "github.com/charmbracelet/bubbletea"
11+ "github.com/charmbracelet/huh"
12+ "github.com/charmbracelet/lipgloss"
13+ "github.com/kujtimiihoxha/termai/internal/llm/models"
5 "github.com/kujtimiihoxha/termai/internal/tui/layout"
14 "github.com/kujtimiihoxha/termai/internal/tui/layout"
15+ "github.com/kujtimiihoxha/termai/internal/tui/styles"
16+ "github.com/kujtimiihoxha/termai/internal/tui/util"
17+ "github.com/spf13/viper"
6)
18)
7
19
8var InitPage PageID = "init"
20var InitPage PageID = "init"
9
21
22+type configSaved struct{}
23+
10type initPage struct {
24type initPage struct {
11− layout layout.SinglePaneLayout
25+ form *huh.Form
26+ width int
27+ height int
28+ saved bool
29+ errorMsg string
30+ statusMsg string
31+ modelOpts []huh.Option[string]
32+ bigModel string
33+ smallModel string
34+ openAIKey string
35+ anthropicKey string
36+ groqKey string
37+ maxTokens string
38+ dataDir string
39+ agent string
40+}
41+
42+func (i *initPage) Init() tea.Cmd {
43+ return i.form.Init()
44+}
45+
46+func (i *initPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
47+ var cmds []tea.Cmd
48+
49+ switch msg := msg.(type) {
50+ case tea.WindowSizeMsg:
51+ i.width = msg.Width - 4 // Account for border
52+ i.height = msg.Height - 4
53+ i.form = i.form.WithWidth(i.width).WithHeight(i.height)
54+ return i, nil
55+
56+ case configSaved:
57+ i.saved = true
58+ i.statusMsg = "Configuration saved successfully. Press any key to continue."
59+ return i, nil
60+ }
61+
62+ if i.saved {
63+ switch msg.(type) {
64+ case tea.KeyMsg:
65+ return i, util.CmdHandler(PageChangeMsg{ID: ReplPage})
66+ }
67+ return i, nil
68+ }
69+
70+ // Process the form
71+ form, cmd := i.form.Update(msg)
72+ if f, ok := form.(*huh.Form); ok {
73+ i.form = f
74+ cmds = append(cmds, cmd)
75+ }
76+
77+ if i.form.State == huh.StateCompleted {
78+ // Save configuration to file
79+ configPath := filepath.Join(os.Getenv("HOME"), ".termai.yaml")
80+ maxTokens, _ := strconv.Atoi(i.maxTokens)
81+ config := map[string]interface{}{
82+ "models": map[string]string{
83+ "big": i.bigModel,
84+ "small": i.smallModel,
85+ },
86+ "providers": map[string]interface{}{
87+ "openai": map[string]string{
88+ "key": i.openAIKey,
89+ },
90+ "anthropic": map[string]string{
91+ "key": i.anthropicKey,
92+ },
93+ "groq": map[string]string{
94+ "key": i.groqKey,
95+ },
96+ "common": map[string]int{
97+ "max_tokens": maxTokens,
98+ },
99+ },
100+ "data": map[string]string{
101+ "dir": i.dataDir,
102+ },
103+ "agents": map[string]string{
104+ "default": i.agent,
105+ },
106+ "log": map[string]string{
107+ "level": "info",
108+ },
109+ }
110+
111+ // Write config to viper
112+ for k, v := range config {
113+ viper.Set(k, v)
114+ }
115+
116+ // Save configuration
117+ err := viper.WriteConfigAs(configPath)
118+ if err != nil {
119+ i.errorMsg = fmt.Sprintf("Failed to save configuration: %s", err)
120+ return i, nil
121+ }
122+
123+ // Return to main page
124+ return i, util.CmdHandler(configSaved{})
125+ }
126+
127+ return i, tea.Batch(cmds...)
12}
128}
13
129
14−func (i initPage) Init() tea.Cmd {
130+func (i *initPage) View() string {
131+ if i.saved {
132+ return lipgloss.NewStyle().
133+ Width(i.width).
134+ Height(i.height).
135+ Align(lipgloss.Center, lipgloss.Center).
136+ Render(lipgloss.JoinVertical(
137+ lipgloss.Center,
138+ lipgloss.NewStyle().Foreground(styles.Green).Render("✓ Configuration Saved"),
139+ "",
140+ lipgloss.NewStyle().Foreground(styles.Blue).Render(i.statusMsg),
141+ ))
142+ }
143+
144+ view := i.form.View()
145+ if i.errorMsg != "" {
146+ errorBox := lipgloss.NewStyle().
147+ Padding(1).
148+ Border(lipgloss.RoundedBorder()).
149+ BorderForeground(styles.Red).
150+ Width(i.width - 4).
151+ Render(i.errorMsg)
152+ view = lipgloss.JoinVertical(lipgloss.Left, errorBox, view)
153+ }
15− return nil
154+ return view
16}
155}
17
156
18−func (i initPage) Update(_ tea.Msg) (tea.Model, tea.Cmd) {
157+func (i *initPage) GetSize() (int, int) {
19− return i, nil
158+ return i.width, i.height
20}
159}
21
160
22−func (i initPage) View() string {
161+func (i *initPage) SetSize(width int, height int) {
23− return "Initializing..."
162+ i.width = width
163+ i.height = height
164+ i.form = i.form.WithWidth(width).WithHeight(height)
165+}
166+
167+func (i *initPage) BindingKeys() []key.Binding {
168+ if i.saved {
169+ return []key.Binding{
170+ key.NewBinding(
171+ key.WithKeys("enter", "space", "esc"),
172+ key.WithHelp("any key", "continue"),
173+ ),
174+ }
175+ }
176+ return i.form.KeyBinds()
24}
177}
25
178
26func NewInitPage() tea.Model {
179func NewInitPage() tea.Model {
180+ // Create model options
181+ var modelOpts []huh.Option[string]
182+ for id, model := range models.SupportedModels {
183+ modelOpts = append(modelOpts, huh.NewOption(model.Name, string(id)))
184+ }
185+
186+ // Create agent options
187+ agentOpts := []huh.Option[string]{
188+ huh.NewOption("Coder", "coder"),
189+ huh.NewOption("Assistant", "assistant"),
190+ }
191+
192+ // Init page with form
193+ initModel := &initPage{
194+ modelOpts: modelOpts,
195+ bigModel: string(models.DefaultBigModel),
196+ smallModel: string(models.DefaultLittleModel),
197+ maxTokens: "4000",
198+ dataDir: ".termai",
199+ agent: "coder",
200+ }
201+
202+ // API Keys group
203+ apiKeysGroup := huh.NewGroup(
204+ huh.NewNote().
205+ Title("API Keys").
206+ Description("You need to provide at least one API key to use termai"),
207+
208+ huh.NewInput().
209+ Title("OpenAI API Key").
210+ Placeholder("sk-...").
211+ Key("openai_key").
212+ Value(&initModel.openAIKey),
213+
214+ huh.NewInput().
215+ Title("Anthropic API Key").
216+ Placeholder("sk-ant-...").
217+ Key("anthropic_key").
218+ Value(&initModel.anthropicKey),
219+
220+ huh.NewInput().
221+ Title("Groq API Key").
222+ Placeholder("gsk_...").
223+ Key("groq_key").
224+ Value(&initModel.groqKey),
225+ )
226+
227+ // Model configuration group
228+ modelsGroup := huh.NewGroup(
229+ huh.NewNote().
230+ Title("Model Configuration").
231+ Description("Select which models to use"),
232+
233+ huh.NewSelect[string]().
234+ Title("Big Model").
235+ Options(modelOpts...).
236+ Key("big_model").
237+ Value(&initModel.bigModel),
238+
239+ huh.NewSelect[string]().
240+ Title("Small Model").
241+ Options(modelOpts...).
242+ Key("small_model").
243+ Value(&initModel.smallModel),
244+
245+ huh.NewInput().
246+ Title("Max Tokens").
247+ Placeholder("4000").
248+ Key("max_tokens").
249+ CharLimit(5).
250+ Validate(func(s string) error {
251+ var n int
252+ _, err := fmt.Sscanf(s, "%d", &n)
253+ if err != nil || n <= 0 {
254+ return fmt.Errorf("must be a positive number")
255+ }
256+ initModel.maxTokens = s
257+ return nil
258+ }).
259+ Value(&initModel.maxTokens),
260+ )
261+
262+ // General settings group
263+ generalGroup := huh.NewGroup(
264+ huh.NewNote().
265+ Title("General Settings").
266+ Description("Configure general termai settings"),
267+
268+ huh.NewInput().
269+ Title("Data Directory").
270+ Placeholder(".termai").
271+ Key("data_dir").
272+ Value(&initModel.dataDir),
273+
274+ huh.NewSelect[string]().
275+ Title("Default Agent").
276+ Options(agentOpts...).
277+ Key("agent").
278+ Value(&initModel.agent),
279+
280+ huh.NewConfirm().
281+ Title("Save Configuration").
282+ Affirmative("Save").
283+ Negative("Cancel"),
284+ )
285+
286+ // Create form with theme
287+ form := huh.NewForm(
288+ apiKeysGroup,
289+ modelsGroup,
290+ generalGroup,
291+ ).WithTheme(styles.HuhTheme()).
292+ WithShowHelp(true).
293+ WithShowErrors(true)
294+
295+ // Set the form in the model
296+ initModel.form = form
297+
27 return layout.NewSinglePane(
298 return layout.NewSinglePane(
28− &initPage{},
299+ initModel,
29 layout.WithSinglePaneFocusable(true),
300 layout.WithSinglePaneFocusable(true),
30 layout.WithSinglePaneBordered(true),
301 layout.WithSinglePaneBordered(true),
31 layout.WithSignlePaneBorderText(
302 layout.WithSignlePaneBorderText(
32 map[layout.BorderPosition]string{
303 map[layout.BorderPosition]string{
33− layout.TopMiddleBorder: "Welcome to termai",
304+ layout.TopMiddleBorder: "Welcome to termai - Initial Setup",
34 },
305 },
35 ),
306 ),
36 )
307 )
internal/tui/page/page.go+5
@@ -1,3 +1,8 @@
1package page
1package page
2
2
3type PageID string
3type PageID string
4+
5+// PageChangeMsg is used to change the current page
6+type PageChangeMsg struct {
7+ ID PageID
8+}
internal/tui/styles/markdown.go+2−2
@@ -146,8 +146,8 @@ var catppuccinDark = ansi.StyleConfig{
146 Code: ansi.StyleBlock{
146 Code: ansi.StyleBlock{
147 StylePrimitive: ansi.StylePrimitive{
147 StylePrimitive: ansi.StylePrimitive{
148 Color: stringPtr(dark.Green().Hex),
148 Color: stringPtr(dark.Green().Hex),
149− Prefix: " ",
149+ Prefix: "",
150− Suffix: " ",
150+ Suffix: "",
151 },
151 },
152 },
152 },
153 CodeBlock: ansi.StyleCodeBlock{
153 CodeBlock: ansi.StyleCodeBlock{
internal/tui/styles/styles.go+1
@@ -20,6 +20,7 @@ var (
20 DoubleBorder = Regular.Border(lipgloss.DoubleBorder())
20 DoubleBorder = Regular.Border(lipgloss.DoubleBorder())
21
21
22 // Colors
22 // Colors
23+ White = lipgloss.Color("#ffffff")
23
24
24 Surface0 = lipgloss.AdaptiveColor{
25 Surface0 = lipgloss.AdaptiveColor{
25 Dark: dark.Surface0().Hex,
26 Dark: dark.Surface0().Hex,
internal/tui/tui.go+41−2
@@ -1,13 +1,17 @@
1package tui
1package tui
2
2
3import (
3import (
4+ "fmt"
4 "log"
5 "log"
6+ "os"
7+ "path/filepath"
5
8
6 "github.com/charmbracelet/bubbles/key"
9 "github.com/charmbracelet/bubbles/key"
7 tea "github.com/charmbracelet/bubbletea"
10 tea "github.com/charmbracelet/bubbletea"
8 "github.com/charmbracelet/lipgloss"
11 "github.com/charmbracelet/lipgloss"
9 "github.com/kujtimiihoxha/termai/internal/app"
12 "github.com/kujtimiihoxha/termai/internal/app"
10 "github.com/kujtimiihoxha/termai/internal/llm"
13 "github.com/kujtimiihoxha/termai/internal/llm"
14+ "github.com/kujtimiihoxha/termai/internal/permission"
11 "github.com/kujtimiihoxha/termai/internal/pubsub"
15 "github.com/kujtimiihoxha/termai/internal/pubsub"
12 "github.com/kujtimiihoxha/termai/internal/tui/components/core"
16 "github.com/kujtimiihoxha/termai/internal/tui/components/core"
13 "github.com/kujtimiihoxha/termai/internal/tui/components/dialog"
17 "github.com/kujtimiihoxha/termai/internal/tui/components/dialog"
@@ -48,6 +52,11 @@ var keys = keyMap{
48 ),
52 ),
49}
53}
50
54
55+var editorKeyMap = key.NewBinding(
56+ key.WithKeys("i"),
57+ key.WithHelp("i", "insert mode"),
58+)
59+
51type appModel struct {
60type appModel struct {
52 width, height int
61 width, height int
53 currentPage page.PageID
62 currentPage page.PageID
@@ -71,8 +80,27 @@ func (a appModel) Init() tea.Cmd {
71func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
80func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
72 switch msg := msg.(type) {
81 switch msg := msg.(type) {
73 case pubsub.Event[llm.AgentEvent]:
82 case pubsub.Event[llm.AgentEvent]:
74− log.Println("Event received")
83+ log.Println("AgentEvent")
75 log.Println(msg)
84 log.Println(msg)
85+ 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+ )
95+ case dialog.PermissionResponseMsg:
96+ switch msg.Action {
97+ case dialog.PermissionAllow:
98+ permission.Default.Grant(msg.Permission)
99+ case dialog.PermissionAllowForSession:
100+ permission.Default.GrantPersistant(msg.Permission)
101+ case dialog.PermissionDeny:
102+ permission.Default.Deny(msg.Permission)
103+ }
76 case vimtea.EditorModeMsg:
104 case vimtea.EditorModeMsg:
77 a.editorMode = msg.Mode
105 a.editorMode = msg.Mode
78 case tea.WindowSizeMsg:
106 case tea.WindowSizeMsg:
@@ -97,6 +125,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
97 a.dialog = d.(core.DialogCmp)
125 a.dialog = d.(core.DialogCmp)
98 a.dialogVisible = false
126 a.dialogVisible = false
99 return a, cmd
127 return a, cmd
128+ case page.PageChangeMsg:
129+ return a, a.moveToPage(msg.ID)
100 case util.InfoMsg:
130 case util.InfoMsg:
101 a.status, _ = a.status.Update(msg)
131 a.status, _ = a.status.Update(msg)
102 case util.ErrorMsg:
132 case util.ErrorMsg:
@@ -201,8 +231,17 @@ func (a appModel) View() string {
201}
231}
202
232
203func New(app *app.App) tea.Model {
233func 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+
238+ startPage := page.ReplPage
239+ if _, err := os.Stat(configPath); os.IsNotExist(err) {
240+ startPage = page.InitPage
241+ }
242+
204 return &appModel{
243 return &appModel{
205− currentPage: page.ReplPage,
244+ currentPage: startPage,
206 loadedPages: make(map[page.PageID]bool),
245 loadedPages: make(map[page.PageID]bool),
207 status: core.NewStatusCmp(),
246 status: core.NewStatusCmp(),
208 help: core.NewHelpCmp(),
247 help: core.NewHelpCmp(),