on main help sign in
additional tools
commit904061c243f70696bfe781e97bf4e392e6954d07 authorKujtim Hoxha <kujtimii.h@gmail.com> dateTue 25 Mar 2025, 13:04 (2 years ago) parent005b8ac
33 files changed+3258−236
README.md+13 cmd/root.go+14 go.mod+5−1 go.sum+9−2 internal/app/services.go+11−8 internal/llm/agent/coder.go+8−1 internal/llm/llm.go−2 internal/llm/models/models.go+8−6 internal/llm/tools/agent.go+141 internal/llm/tools/bash.go+36−156 internal/llm/tools/edit.go+420 internal/llm/tools/file.go+53 internal/llm/tools/glob.go+174 internal/llm/tools/grep.go+260 internal/llm/tools/ls.go+229 internal/llm/tools/view.go+247 internal/llm/tools/write.go+165 internal/permission/permission.go+103 internal/pubsub/broker.go+1−1 internal/tui/components/core/button.go+287 internal/tui/components/dialog/permission.go+167 internal/tui/components/messages/message.go+108 internal/tui/components/repl/editor.go+11−3 internal/tui/components/repl/messages.go+157−23 internal/tui/components/repl/sessions.go+7−1 internal/tui/layout/border.go+37−18 internal/tui/layout/grid.go+254 internal/tui/layout/single.go+4−1 internal/tui/page/init.go+280−9 internal/tui/page/page.go+5 internal/tui/styles/markdown.go+2−2 internal/tui/styles/styles.go+1 internal/tui/tui.go+41−2
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 (
1010 "github.com/kujtimiihoxha/termai/internal/db"
1111 "github.com/kujtimiihoxha/termai/internal/llm/models"
1212 "github.com/kujtimiihoxha/termai/internal/tui"
13+ zone "github.com/lrstanley/bubblezone"
1314 "github.com/spf13/cobra"
1415 "github.com/spf13/viper"
1516)
@@ -37,9 +38,11 @@ var rootCmd = &cobra.Command{
3738
3839 app := app.New(ctx, conn)
3940 app.Logger.Info("Starting termai...")
41+ zone.NewGlobal()
4042 tui := tea.NewProgram(
4143 tui.New(app),
4244 tea.WithAltScreen(),
45+ tea.WithMouseCellMotion(),
4346 )
4447 app.Logger.Info("Setting up subscriptions...")
4548 ch, unsub := setupSubscriptions(app)
@@ -102,6 +105,16 @@ func setupSubscriptions(app *app.App) (chan tea.Msg, func()) {
102105 wg.Done()
103106 }()
104107 }
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+ }
105118 return ch, func() {
106119 cancel()
107120 wg.Wait()
@@ -130,6 +143,7 @@ func loadConfig() {
130143 // LLM
131144 viper.SetDefault("models.big", string(models.DefaultBigModel))
132145 viper.SetDefault("models.small", string(models.DefaultLittleModel))
146+
133147 viper.SetDefault("providers.openai.key", os.Getenv("OPENAI_API_KEY"))
134148 viper.SetDefault("providers.anthropic.key", os.Getenv("ANTHROPIC_API_KEY"))
135149 viper.SetDefault("providers.groq.key", os.Getenv("GROQ_API_KEY"))
go.mod+5−1
@@ -3,6 +3,7 @@ module github.com/kujtimiihoxha/termai
33go 1.23.5
44
55require (
6+ github.com/bmatcuk/doublestar/v4 v4.8.1
67 github.com/catppuccin/go v0.3.0
78 github.com/charmbracelet/bubbles v0.20.0
89 github.com/charmbracelet/bubbletea v1.3.4
@@ -16,11 +17,13 @@ require (
1617 github.com/golang-migrate/migrate/v4 v4.18.2
1718 github.com/google/uuid v1.6.0
1819 github.com/kujtimiihoxha/vimtea v0.0.3-0.20250317175717-9d8ba9c69840
20+ github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231
1921 github.com/mattn/go-runewidth v0.0.16
2022 github.com/mattn/go-sqlite3 v1.14.24
2123 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
2224 github.com/muesli/reflow v0.3.0
2325 github.com/muesli/termenv v0.16.0
26+ github.com/sergi/go-diff v1.3.1
2427 github.com/spf13/cobra v1.9.1
2528 github.com/spf13/viper v1.20.0
2629 golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1
@@ -112,7 +115,8 @@ require (
112115 golang.org/x/arch v0.11.0 // indirect
113116 golang.org/x/net v0.33.0 // indirect
114117 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
116120 golang.org/x/text v0.23.0 // indirect
117121 gopkg.in/yaml.v2 v2.4.0 // indirect
118122 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/
4646github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
4747github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
4848github.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=
4951github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
5052github.com/bugsnag/bugsnag-go v1.4.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8=
5153github.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
175177github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
176178github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
177179github.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=
178182github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
179183github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
180184github.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=
241245github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
242246github.com/sashabaranov/go-openai v1.32.5 h1:/eNVa8KzlE7mJdKPZDj6886MUzZQjoVHyn0sLvIt5qA=
243247github.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=
244250github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
245251github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
246252github.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
268274github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
269275github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
270276github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
277+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
271278github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
272279github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
273280github.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
327334golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
328335golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
329336golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
330−golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
331−golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
337+golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
338+golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
332339golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
333340golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
334341golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
internal/app/services.go+11−8
@@ -8,6 +8,7 @@ import (
88 "github.com/kujtimiihoxha/termai/internal/llm"
99 "github.com/kujtimiihoxha/termai/internal/logging"
1010 "github.com/kujtimiihoxha/termai/internal/message"
11+ "github.com/kujtimiihoxha/termai/internal/permission"
1112 "github.com/kujtimiihoxha/termai/internal/session"
1213 "github.com/spf13/viper"
1314)
@@ -15,9 +16,10 @@ import (
1516type App struct {
1617 Context context.Context
1718
18− Sessions session.Service
19− Messages message.Service
20− LLM llm.Service
19+ Sessions session.Service
20+ Messages message.Service
21+ Permissions permission.Service
22+ LLM llm.Service
2123
2224 Logger logging.Interface
2325}
@@ -32,10 +34,11 @@ func New(ctx context.Context, conn *sql.DB) *App {
3234 llm := llm.NewService(ctx, log, sessions, messages)
3335
3436 return &App{
35− Context: ctx,
36− Sessions: sessions,
37− Messages: messages,
38− LLM: llm,
39− Logger: log,
37+ Context: ctx,
38+ Sessions: sessions,
39+ Messages: messages,
40+ Permissions: permission.Default,
41+ LLM: llm,
42+ Logger: log,
4043 }
4144}
internal/llm/agent/coder.go+8−1
@@ -17,8 +17,15 @@ import (
1717)
1818
1919func coderTools() []tool.BaseTool {
20+ wd := viper.GetString("wd")
2021 return []tool.BaseTool{
21− tools.NewBashTool(viper.GetString("wd")),
22+ tools.NewAgentTool(wd),
23+ tools.NewBashTool(wd),
24+ tools.NewLsTool(wd),
25+ tools.NewGlobTool(wd),
26+ tools.NewViewTool(wd),
27+ tools.NewWriteTool(wd),
28+ tools.NewEditTool(wd),
2229 }
2330}
2431
internal/llm/llm.go−2
@@ -88,7 +88,6 @@ func (s *service) handleRequest(id string, sessionID string, content string) {
8888 return
8989 }
9090
91− log.Printf("Request: %s", content)
9291 currentAgent, systemMessage, err := agent.GetAgent(s.ctx, viper.GetString("agents.default"))
9392 if err != nil {
9493 s.Publish(AgentErrorEvent, AgentEvent{
@@ -172,7 +171,6 @@ func (s *service) handleRequest(id string, sessionID string, content string) {
172171 }
173172 session.PromptTokens += int64(usage.PromptTokens)
174173 session.CompletionTokens += int64(usage.CompletionTokens)
175− // TODO: calculate cost
176174 model := models.SupportedModels[models.ModelID(viper.GetString("models.big"))]
177175 session.Cost += float64(usage.PromptTokens)*(model.CostPer1MIn/1_000_000) +
178176 float64(usage.CompletionTokens)*(model.CostPer1MOut/1_000_000)
internal/llm/models/models.go+8−6
@@ -26,8 +26,8 @@ type Model struct {
2626}
2727
2828const (
29− DefaultBigModel = GPT4oMini
30− DefaultLittleModel = GPT4oMini
29+ DefaultBigModel = Claude37Sonnet
30+ DefaultLittleModel = Claude37Sonnet
3131)
3232
3333// Model IDs
@@ -118,10 +118,12 @@ var SupportedModels = map[ModelID]Model{
118118 APIModel: "claude-3-haiku",
119119 },
120120 Claude37Sonnet: {
121− ID: Claude37Sonnet,
122− Name: "Claude 3.7 Sonnet",
123− Provider: ProviderAnthropic,
124− APIModel: "claude-3-7-sonnet-20250219",
121+ ID: Claude37Sonnet,
122+ Name: "Claude 3.7 Sonnet",
123+ Provider: ProviderAnthropic,
124+ APIModel: "claude-3-7-sonnet-20250219",
125+ CostPer1MIn: 3.0,
126+ CostPer1MOut: 15.0,
125127 },
126128 // Google
127129 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
33import (
44 "context"
55 "encoding/json"
6− "errors"
76 "fmt"
8− "log"
97 "strings"
108
119 "github.com/cloudwego/eino/components/tool"
1210 "github.com/cloudwego/eino/schema"
1311 "github.com/kujtimiihoxha/termai/internal/llm/tools/shell"
12+ "github.com/kujtimiihoxha/termai/internal/permission"
1413)
1514
1615type bashTool struct {
@@ -36,6 +35,11 @@ var BannedCommands = []string{
3635 "http-prompt", "chrome", "firefox", "safari",
3736}
3837
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+
3943func (b *bashTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
4044 return &schema.ToolInfo{
4145 Name: BashToolName,
@@ -56,7 +60,6 @@ func (b *bashTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
5660
5761// Handle implements Tool.
5862func (b *bashTool) InvokableRun(ctx context.Context, args string, opts ...tool.Option) (string, error) {
59− log.Printf("BashTool InvokableRun: %s", args)
6063 var params BashParams
6164 if err := json.Unmarshal([]byte(args), &params); err != nil {
6265 return "", err
@@ -69,13 +72,37 @@ func (b *bashTool) InvokableRun(ctx context.Context, args string, opts ...tool.O
6972 }
7073
7174 if params.Command == "" {
72− return "", errors.New("missing command")
75+ return "missing command", nil
7376 }
7477
7578 baseCmd := strings.Fields(params.Command)[0]
7679 for _, banned := range BannedCommands {
7780 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)
79106 }
80107 }
81108
@@ -125,8 +152,9 @@ func (b *bashTool) InvokableRun(ctx context.Context, args string, opts ...tool.O
125152 stdout += "\n" + errorMessage
126153 }
127154
128− log.Printf("BashTool InvokableRun: stdout: %s, stderr: %s, exitCode: %d, interrupted: %t", stdout, stderr, exitCode, interrupted)
129−
155+ if stdout == "" {
156+ return "no output", nil
157+ }
130158 return stdout, nil
131159}
132160
@@ -150,7 +178,7 @@ func countLines(s string) int {
150178 return len(strings.Split(s, "\n"))
151179}
152180
153−func bashDescriptionBCP() string {
181+func bashDescription() string {
154182 bannedCommandsStr := strings.Join(BannedCommands, ", ")
155183 return fmt.Sprintf(`Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
156184
@@ -299,154 +327,6 @@ Important:
299327- Never update git config`, bannedCommandsStr, MaxOutputLength)
300328}
301329
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−
450330func NewBashTool(workingDir string) tool.InvokableTool {
451331 return &bashTool{
452332 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 (
55 "sync"
66)
77
8−const bufferSize = 1024
8+const bufferSize = 1024 * 1024
99
1010type Logger interface {
1111 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 (
55
66 "github.com/charmbracelet/bubbles/key"
77 tea "github.com/charmbracelet/bubbletea"
8+ "github.com/charmbracelet/lipgloss"
89 "github.com/cloudwego/eino/schema"
910 "github.com/kujtimiihoxha/termai/internal/app"
1011 "github.com/kujtimiihoxha/termai/internal/tui/layout"
12+ "github.com/kujtimiihoxha/termai/internal/tui/styles"
1113 "github.com/kujtimiihoxha/vimtea"
1214)
1315
@@ -105,8 +107,12 @@ func (m *editorCmp) Blur() tea.Cmd {
105107}
106108
107109func (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+ }
108114 return map[layout.BorderPosition]string{
109− layout.TopLeftBorder: "New Message",
115+ layout.TopLeftBorder: title,
110116 }
111117}
112118
@@ -148,7 +154,9 @@ func (m *editorCmp) BindingKeys() []key.Binding {
148154
149155func NewEditorCmp(app *app.App) EditorCmp {
150156 return &editorCmp{
151− app: app,
152− editor: vimtea.NewEditor(),
157+ app: app,
158+ editor: vimtea.NewEditor(
159+ vimtea.WithFileName("message.md"),
160+ ),
153161 }
154162}
internal/tui/components/repl/messages.go+157−23
@@ -1,15 +1,22 @@
11package repl
22
33import (
4+ "fmt"
5+ "slices"
6+ "strings"
7+
48 "github.com/charmbracelet/bubbles/key"
59 "github.com/charmbracelet/bubbles/viewport"
610 tea "github.com/charmbracelet/bubbletea"
11+ "github.com/charmbracelet/glamour"
712 "github.com/charmbracelet/lipgloss"
13+ "github.com/cloudwego/eino/schema"
814 "github.com/kujtimiihoxha/termai/internal/app"
915 "github.com/kujtimiihoxha/termai/internal/message"
1016 "github.com/kujtimiihoxha/termai/internal/pubsub"
1117 "github.com/kujtimiihoxha/termai/internal/session"
1218 "github.com/kujtimiihoxha/termai/internal/tui/layout"
19+ "github.com/kujtimiihoxha/termai/internal/tui/styles"
1320)
1421
1522type MessagesCmp interface {
@@ -21,13 +28,15 @@ type MessagesCmp interface {
2128}
2229
2330type messagesCmp struct {
24− app *app.App
25− messages []message.Message
26− session session.Session
27− viewport viewport.Model
28− width int
29− height int
30− focused bool
31+ app *app.App
32+ messages []message.Message
33+ session session.Session
34+ viewport viewport.Model
35+ mdRenderer *glamour.TermRenderer
36+ width int
37+ height int
38+ focused bool
39+ cachedView string
3140}
3241
3342func (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) {
3544 case pubsub.Event[message.Message]:
3645 if msg.Type == pubsub.CreatedEvent {
3746 m.messages = append(m.messages, msg.Payload)
47+ m.renderView()
48+ m.viewport.GotoBottom()
3849 }
3950 case pubsub.Event[session.Session]:
4051 if msg.Type == pubsub.UpdatedEvent {
@@ -45,60 +56,182 @@ func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
4556 case SelectedSessionMsg:
4657 m.session, _ = m.app.Sessions.Get(msg.SessionID)
4758 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
4866 }
4967 return m, nil
5068}
5169
52−func (i *messagesCmp) View() string {
53− stringMessages := make([]string, len(i.messages))
54− for idx, msg := range i.messages {
55− stringMessages[idx] = msg.MessageData.Content
70+func borderColor(role schema.RoleType) lipgloss.TerminalColor {
71+ switch role {
72+ case schema.Assistant:
73+ return styles.Mauve
74+ case schema.User:
75+ return styles.Rosewater
76+ case schema.Tool:
77+ return styles.Peach
5678 }
57− return lipgloss.JoinVertical(lipgloss.Top, stringMessages...)
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+ }
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())
58191}
59192
60−// BindingKeys implements MessagesCmp.
61193func (m *messagesCmp) BindingKeys() []key.Binding {
62− return []key.Binding{}
194+ return layout.KeyMapToSlice(m.viewport.KeyMap)
63195}
64196
65−// Blur implements MessagesCmp.
66197func (m *messagesCmp) Blur() tea.Cmd {
67198 m.focused = false
68199 return nil
69200}
70201
71−// BorderText implements MessagesCmp.
72202func (m *messagesCmp) BorderText() map[layout.BorderPosition]string {
73203 title := m.session.Title
74− if len(title) > 20 {
75− title = title[:20] + "..."
204+ titleWidth := m.width / 2
205+ if len(title) > titleWidth {
206+ title = title[:titleWidth] + "..."
207+ }
208+ if m.focused {
209+ title = lipgloss.NewStyle().Foreground(styles.Primary).Render(title)
76210 }
77211 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),
79214 }
80215}
81216
82−// Focus implements MessagesCmp.
83217func (m *messagesCmp) Focus() tea.Cmd {
84218 m.focused = true
85219 return nil
86220}
87221
88−// GetSize implements MessagesCmp.
89222func (m *messagesCmp) GetSize() (int, int) {
90223 return m.width, m.height
91224}
92225
93−// IsFocused implements MessagesCmp.
94226func (m *messagesCmp) IsFocused() bool {
95227 return m.focused
96228}
97229
98−// SetSize implements MessagesCmp.
99230func (m *messagesCmp) SetSize(width int, height int) {
100231 m.width = width
101232 m.height = height
233+ m.viewport.Width = width - 2 // padding
234+ m.viewport.Height = height - 2 // padding
102235}
103236
104237func (m *messagesCmp) Init() tea.Cmd {
@@ -109,5 +242,6 @@ func NewMessagesCmp(app *app.App) MessagesCmp {
109242 return &messagesCmp{
110243 app: app,
111244 messages: []message.Message{},
245+ viewport: viewport.New(0, 0),
112246 }
113247}
internal/tui/components/repl/sessions.go+7−1
@@ -7,6 +7,7 @@ import (
77 "github.com/charmbracelet/bubbles/key"
88 "github.com/charmbracelet/bubbles/list"
99 tea "github.com/charmbracelet/bubbletea"
10+ "github.com/charmbracelet/lipgloss"
1011 "github.com/kujtimiihoxha/termai/internal/app"
1112 "github.com/kujtimiihoxha/termai/internal/pubsub"
1213 "github.com/kujtimiihoxha/termai/internal/session"
@@ -160,8 +161,13 @@ func (i *sessionsCmp) BorderText() map[layout.BorderPosition]string {
160161 current,
161162 totalCount,
162163 )
164+
165+ title := "Sessions"
166+ if i.focused {
167+ title = lipgloss.NewStyle().Foreground(styles.Primary).Render(title)
168+ }
163169 return map[layout.BorderPosition]string{
164− layout.TopMiddleBorder: "Sessions",
170+ layout.TopMiddleBorder: title,
165171 layout.BottomMiddleBorder: pageInfo,
166172 }
167173}
internal/tui/layout/border.go+37−18
@@ -24,24 +24,43 @@ var (
2424 InactivePreviewBorder = styles.Grey
2525)
2626
27−func Borderize(content string, active bool, embeddedText map[BorderPosition]string, activeColor lipgloss.TerminalColor) string {
28− if embeddedText == nil {
29− embeddedText = make(map[BorderPosition]string)
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+
36+func Borderize(content string, opts BorderOptions) string {
37+ if opts.EmbeddedText == nil {
38+ opts.EmbeddedText = make(map[BorderPosition]string)
39+ }
40+ if opts.ActiveColor == nil {
41+ opts.ActiveColor = ActiveBorder
3042 }
31− if activeColor == nil {
32− activeColor = ActiveBorder
43+ if opts.InactiveColor == nil {
44+ opts.InactiveColor = InactivePreviewBorder
3345 }
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+
3453 var (
3554 thickness = map[bool]lipgloss.Border{
36− true: lipgloss.Border(lipgloss.ThickBorder()),
37− false: lipgloss.Border(lipgloss.NormalBorder()),
55+ true: opts.ActiveBorder,
56+ false: opts.InactiveBorder,
3857 }
3958 color = map[bool]lipgloss.TerminalColor{
40− true: activeColor,
41− false: InactivePreviewBorder,
59+ true: opts.ActiveColor,
60+ false: opts.InactiveColor,
4261 }
43− border = thickness[active]
44− style = lipgloss.NewStyle().Foreground(color[active])
62+ border = thickness[opts.Active]
63+ style = lipgloss.NewStyle().Foreground(color[opts.Active])
4564 width = lipgloss.Width(content)
4665 )
4766
@@ -80,20 +99,20 @@ func Borderize(content string, active bool, embeddedText map[BorderPosition]stri
8099 // Stack top border, content and horizontal borders, and bottom border.
81100 return strings.Join([]string{
82101 buildHorizontalBorder(
83− embeddedText[TopLeftBorder],
84− embeddedText[TopMiddleBorder],
85− embeddedText[TopRightBorder],
102+ opts.EmbeddedText[TopLeftBorder],
103+ opts.EmbeddedText[TopMiddleBorder],
104+ opts.EmbeddedText[TopRightBorder],
86105 border.TopLeft,
87106 border.Top,
88107 border.TopRight,
89108 ),
90109 lipgloss.NewStyle().
91− BorderForeground(color[active]).
110+ BorderForeground(color[opts.Active]).
92111 Border(border, false, true, false, true).Render(content),
93112 buildHorizontalBorder(
94− embeddedText[BottomLeftBorder],
95− embeddedText[BottomMiddleBorder],
96− embeddedText[BottomRightBorder],
113+ opts.EmbeddedText[BottomLeftBorder],
114+ opts.EmbeddedText[BottomMiddleBorder],
115+ opts.EmbeddedText[BottomRightBorder],
97116 border.BottomLeft,
98117 border.Bottom,
99118 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 {
6464 if bordered, ok := s.content.(Bordered); ok {
6565 s.borderText = bordered.BorderText()
6666 }
67− return Borderize(content, s.focused, s.borderText, s.activeColor)
67+ return Borderize(content, BorderOptions{
68+ Active: s.focused,
69+ EmbeddedText: s.borderText,
70+ })
6871 }
6972 return content
7073}
internal/tui/page/init.go+280−9
@@ -1,36 +1,307 @@
11package page
22
33import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "strconv"
8+
9+ "github.com/charmbracelet/bubbles/key"
410 tea "github.com/charmbracelet/bubbletea"
11+ "github.com/charmbracelet/huh"
12+ "github.com/charmbracelet/lipgloss"
13+ "github.com/kujtimiihoxha/termai/internal/llm/models"
514 "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"
618)
719
820var InitPage PageID = "init"
921
22+type configSaved struct{}
23+
1024type 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...)
12128}
13129
14−func (i initPage) Init() tea.Cmd {
15− return nil
130+func (i *initPage) View() string {
131+ if i.saved {
132+ return lipgloss.NewStyle().
133+ Width(i.width).
134+ Height(i.height).
135+ Align(lipgloss.Center, lipgloss.Center).
136+ Render(lipgloss.JoinVertical(
137+ lipgloss.Center,
138+ lipgloss.NewStyle().Foreground(styles.Green).Render("✓ Configuration Saved"),
139+ "",
140+ lipgloss.NewStyle().Foreground(styles.Blue).Render(i.statusMsg),
141+ ))
142+ }
143+
144+ view := i.form.View()
145+ if i.errorMsg != "" {
146+ errorBox := lipgloss.NewStyle().
147+ Padding(1).
148+ Border(lipgloss.RoundedBorder()).
149+ BorderForeground(styles.Red).
150+ Width(i.width - 4).
151+ Render(i.errorMsg)
152+ view = lipgloss.JoinVertical(lipgloss.Left, errorBox, view)
153+ }
154+ return view
16155}
17156
18−func (i initPage) Update(_ tea.Msg) (tea.Model, tea.Cmd) {
19− return i, nil
157+func (i *initPage) GetSize() (int, int) {
158+ return i.width, i.height
20159}
21160
22−func (i initPage) View() string {
23− return "Initializing..."
161+func (i *initPage) SetSize(width int, height int) {
162+ i.width = width
163+ i.height = height
164+ i.form = i.form.WithWidth(width).WithHeight(height)
165+}
166+
167+func (i *initPage) BindingKeys() []key.Binding {
168+ if i.saved {
169+ return []key.Binding{
170+ key.NewBinding(
171+ key.WithKeys("enter", "space", "esc"),
172+ key.WithHelp("any key", "continue"),
173+ ),
174+ }
175+ }
176+ return i.form.KeyBinds()
24177}
25178
26179func 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+
27298 return layout.NewSinglePane(
28− &initPage{},
299+ initModel,
29300 layout.WithSinglePaneFocusable(true),
30301 layout.WithSinglePaneBordered(true),
31302 layout.WithSignlePaneBorderText(
32303 map[layout.BorderPosition]string{
33− layout.TopMiddleBorder: "Welcome to termai",
304+ layout.TopMiddleBorder: "Welcome to termai - Initial Setup",
34305 },
35306 ),
36307 )
internal/tui/page/page.go+5
@@ -1,3 +1,8 @@
11package page
22
33type 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{
146146 Code: ansi.StyleBlock{
147147 StylePrimitive: ansi.StylePrimitive{
148148 Color: stringPtr(dark.Green().Hex),
149− Prefix: " ",
150− Suffix: " ",
149+ Prefix: "",
150+ Suffix: "",
151151 },
152152 },
153153 CodeBlock: ansi.StyleCodeBlock{
internal/tui/styles/styles.go+1
@@ -20,6 +20,7 @@ var (
2020 DoubleBorder = Regular.Border(lipgloss.DoubleBorder())
2121
2222 // Colors
23+ White = lipgloss.Color("#ffffff")
2324
2425 Surface0 = lipgloss.AdaptiveColor{
2526 Dark: dark.Surface0().Hex,
internal/tui/tui.go+41−2
@@ -1,13 +1,17 @@
11package tui
22
33import (
4+ "fmt"
45 "log"
6+ "os"
7+ "path/filepath"
58
69 "github.com/charmbracelet/bubbles/key"
710 tea "github.com/charmbracelet/bubbletea"
811 "github.com/charmbracelet/lipgloss"
912 "github.com/kujtimiihoxha/termai/internal/app"
1013 "github.com/kujtimiihoxha/termai/internal/llm"
14+ "github.com/kujtimiihoxha/termai/internal/permission"
1115 "github.com/kujtimiihoxha/termai/internal/pubsub"
1216 "github.com/kujtimiihoxha/termai/internal/tui/components/core"
1317 "github.com/kujtimiihoxha/termai/internal/tui/components/dialog"
@@ -48,6 +52,11 @@ var keys = keyMap{
4852 ),
4953}
5054
55+var editorKeyMap = key.NewBinding(
56+ key.WithKeys("i"),
57+ key.WithHelp("i", "insert mode"),
58+)
59+
5160type appModel struct {
5261 width, height int
5362 currentPage page.PageID
@@ -71,8 +80,27 @@ func (a appModel) Init() tea.Cmd {
7180func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
7281 switch msg := msg.(type) {
7382 case pubsub.Event[llm.AgentEvent]:
74− log.Println("Event received")
83+ log.Println("AgentEvent")
7584 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+ }
76104 case vimtea.EditorModeMsg:
77105 a.editorMode = msg.Mode
78106 case tea.WindowSizeMsg:
@@ -97,6 +125,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
97125 a.dialog = d.(core.DialogCmp)
98126 a.dialogVisible = false
99127 return a, cmd
128+ case page.PageChangeMsg:
129+ return a, a.moveToPage(msg.ID)
100130 case util.InfoMsg:
101131 a.status, _ = a.status.Update(msg)
102132 case util.ErrorMsg:
@@ -201,8 +231,17 @@ func (a appModel) View() string {
201231}
202232
203233func 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+
204243 return &appModel{
205− currentPage: page.ReplPage,
244+ currentPage: startPage,
206245 loadedPages: make(map[page.PageID]bool),
207246 status: core.NewStatusCmp(),
208247 help: core.NewHelpCmp(),