on main help sign in
structure tools the same
9492394Kujtim Hoxha
cmd/root.go+1−1
@@ -50,7 +50,7 @@ var rootCmd = &cobra.Command{
50
50
51 go func() {
51 go func() {
52 // Set this up once
52 // Set this up once
53− agent.GetMcpTools(ctx)
53+ agent.GetMcpTools(ctx, app.Permissions)
54 for msg := range ch {
54 for msg := range ch {
55 tui.Send(msg)
55 tui.Send(msg)
56 }
56 }
internal/app/services.go+1−1
@@ -41,7 +41,7 @@ func New(ctx context.Context, conn *sql.DB) *App {
41 Context: ctx,
41 Context: ctx,
42 Sessions: sessions,
42 Sessions: sessions,
43 Messages: messages,
43 Messages: messages,
44− Permissions: permission.Default,
44+ Permissions: permission.NewPermissionService(),
45 Logger: log,
45 Logger: log,
46 LSPClients: make(map[string]*lsp.Client),
46 LSPClients: make(map[string]*lsp.Client),
47 }
47 }
internal/llm/agent/coder.go+5−5
@@ -44,7 +44,7 @@ func NewCoderAgent(app *app.App) (Agent, error) {
44 return nil, err
44 return nil, err
45 }
45 }
46
46
47− otherTools := GetMcpTools(app.Context)
47+ otherTools := GetMcpTools(app.Context, app.Permissions)
48 if len(app.LSPClients) > 0 {
48 if len(app.LSPClients) > 0 {
49 otherTools = append(otherTools, tools.NewDiagnosticsTool(app.LSPClients))
49 otherTools = append(otherTools, tools.NewDiagnosticsTool(app.LSPClients))
50 }
50 }
@@ -53,15 +53,15 @@ func NewCoderAgent(app *app.App) (Agent, error) {
53 App: app,
53 App: app,
54 tools: append(
54 tools: append(
55 []tools.BaseTool{
55 []tools.BaseTool{
56− tools.NewBashTool(),
56+ tools.NewBashTool(app.Permissions),
57− tools.NewEditTool(app.LSPClients),
57+ tools.NewEditTool(app.LSPClients, app.Permissions),
58− tools.NewFetchTool(),
58+ tools.NewFetchTool(app.Permissions),
59 tools.NewGlobTool(),
59 tools.NewGlobTool(),
60 tools.NewGrepTool(),
60 tools.NewGrepTool(),
61 tools.NewLsTool(),
61 tools.NewLsTool(),
62 tools.NewSourcegraphTool(),
62 tools.NewSourcegraphTool(),
63 tools.NewViewTool(app.LSPClients),
63 tools.NewViewTool(app.LSPClients),
64− tools.NewWriteTool(app.LSPClients),
64+ tools.NewWriteTool(app.LSPClients, app.Permissions),
65 }, otherTools...,
65 }, otherTools...,
66 ),
66 ),
67 model: model,
67 model: model,
internal/llm/agent/mcp-tools.go+15−13
@@ -16,9 +16,10 @@ import (
16)
16)
17
17
18type mcpTool struct {
18type mcpTool struct {
19− mcpName string
19+ mcpName string
20− tool mcp.Tool
20+ tool mcp.Tool
21− mcpConfig config.MCPServer
21+ mcpConfig config.MCPServer
22+ permissions permission.Service
22}
23}
23
24
24type MCPClient interface {
25type MCPClient interface {
@@ -80,7 +81,7 @@ func runTool(ctx context.Context, c MCPClient, toolName string, input string) (t
80
81
81func (b *mcpTool) Run(ctx context.Context, params tools.ToolCall) (tools.ToolResponse, error) {
82func (b *mcpTool) Run(ctx context.Context, params tools.ToolCall) (tools.ToolResponse, error) {
82 permissionDescription := fmt.Sprintf("execute %s with the following parameters: %s", b.Info().Name, params.Input)
83 permissionDescription := fmt.Sprintf("execute %s with the following parameters: %s", b.Info().Name, params.Input)
83− p := permission.Default.Request(
84+ p := b.permissions.Request(
84 permission.CreatePermissionRequest{
85 permission.CreatePermissionRequest{
85 Path: config.WorkingDirectory(),
86 Path: config.WorkingDirectory(),
86 ToolName: b.Info().Name,
87 ToolName: b.Info().Name,
@@ -118,17 +119,18 @@ func (b *mcpTool) Run(ctx context.Context, params tools.ToolCall) (tools.ToolRes
118 return tools.NewTextErrorResponse("invalid mcp type"), nil
119 return tools.NewTextErrorResponse("invalid mcp type"), nil
119}
120}
120
121
121−func NewMcpTool(name string, tool mcp.Tool, mcpConfig config.MCPServer) tools.BaseTool {
122+func NewMcpTool(name string, tool mcp.Tool, permissions permission.Service, mcpConfig config.MCPServer) tools.BaseTool {
122 return &mcpTool{
123 return &mcpTool{
123− mcpName: name,
124+ mcpName: name,
124− tool: tool,
125+ tool: tool,
125− mcpConfig: mcpConfig,
126+ mcpConfig: mcpConfig,
127+ permissions: permissions,
126 }
128 }
127}
129}
128
130
129var mcpTools []tools.BaseTool
131var mcpTools []tools.BaseTool
130
132
131−func getTools(ctx context.Context, name string, m config.MCPServer, c MCPClient) []tools.BaseTool {
133+func getTools(ctx context.Context, name string, m config.MCPServer, permissions permission.Service, c MCPClient) []tools.BaseTool {
132 var stdioTools []tools.BaseTool
134 var stdioTools []tools.BaseTool
133 initRequest := mcp.InitializeRequest{}
135 initRequest := mcp.InitializeRequest{}
134 initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
136 initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
@@ -149,13 +151,13 @@ func getTools(ctx context.Context, name string, m config.MCPServer, c MCPClient)
149 return stdioTools
151 return stdioTools
150 }
152 }
151 for _, t := range tools.Tools {
153 for _, t := range tools.Tools {
152− stdioTools = append(stdioTools, NewMcpTool(name, t, m))
154+ stdioTools = append(stdioTools, NewMcpTool(name, t, permissions, m))
153 }
155 }
154 defer c.Close()
156 defer c.Close()
155 return stdioTools
157 return stdioTools
156}
158}
157
159
158−func GetMcpTools(ctx context.Context) []tools.BaseTool {
160+func GetMcpTools(ctx context.Context, permissions permission.Service) []tools.BaseTool {
159 if len(mcpTools) > 0 {
161 if len(mcpTools) > 0 {
160 return mcpTools
162 return mcpTools
161 }
163 }
@@ -172,7 +174,7 @@ func GetMcpTools(ctx context.Context) []tools.BaseTool {
172 continue
174 continue
173 }
175 }
174
176
175− mcpTools = append(mcpTools, getTools(ctx, name, m, c)...)
177+ mcpTools = append(mcpTools, getTools(ctx, name, m, permissions, c)...)
176 case config.MCPSse:
178 case config.MCPSse:
177 c, err := client.NewSSEMCPClient(
179 c, err := client.NewSSEMCPClient(
178 m.URL,
180 m.URL,
@@ -182,7 +184,7 @@ func GetMcpTools(ctx context.Context) []tools.BaseTool {
182 log.Printf("error creating mcp client: %s", err)
184 log.Printf("error creating mcp client: %s", err)
183 continue
185 continue
184 }
186 }
185− mcpTools = append(mcpTools, getTools(ctx, name, m, c)...)
187+ mcpTools = append(mcpTools, getTools(ctx, name, m, permissions, c)...)
186 }
188 }
187 }
189 }
188
190
internal/llm/tools/bash.go+147−173
@@ -11,16 +11,6 @@ import (
11 "github.com/kujtimiihoxha/termai/internal/permission"
11 "github.com/kujtimiihoxha/termai/internal/permission"
12)
12)
13
13
14−type bashTool struct{}
15−
16−const (
17− BashToolName = "bash"
18−
19− DefaultTimeout = 1 * 60 * 1000 // 1 minutes in milliseconds
20− MaxTimeout = 10 * 60 * 1000 // 10 minutes in milliseconds
21− MaxOutputLength = 30000
22−)
23−
24type BashParams struct {
14type BashParams struct {
25 Command string `json:"command"`
15 Command string `json:"command"`
26 Timeout int `json:"timeout"`
16 Timeout int `json:"timeout"`
@@ -31,180 +21,36 @@ type BashPermissionsParams struct {
31 Timeout int `json:"timeout"`
21 Timeout int `json:"timeout"`
32}
22}
33
23
34−var BannedCommands = []string{
24+type bashTool struct {
25+ permissions permission.Service
26+}
27+
28+const (
29+ BashToolName = "bash"
30+
31+ DefaultTimeout = 1 * 60 * 1000 // 1 minutes in milliseconds
32+ MaxTimeout = 10 * 60 * 1000 // 10 minutes in milliseconds
33+ MaxOutputLength = 30000
34+)
35+
36+var bannedCommands = []string{
35 "alias", "curl", "curlie", "wget", "axel", "aria2c",
37 "alias", "curl", "curlie", "wget", "axel", "aria2c",
36 "nc", "telnet", "lynx", "w3m", "links", "httpie", "xh",
38 "nc", "telnet", "lynx", "w3m", "links", "httpie", "xh",
37 "http-prompt", "chrome", "firefox", "safari",
39 "http-prompt", "chrome", "firefox", "safari",
38}
40}
39
41
40−var SafeReadOnlyCommands = []string{
42+var safeReadOnlyCommands = []string{
41− // Basic shell commands
42 "ls", "echo", "pwd", "date", "cal", "uptime", "whoami", "id", "groups", "env", "printenv", "set", "unset", "which", "type", "whereis",
43 "ls", "echo", "pwd", "date", "cal", "uptime", "whoami", "id", "groups", "env", "printenv", "set", "unset", "which", "type", "whereis",
43 "whatis", "uname", "hostname", "df", "du", "free", "top", "ps", "kill", "killall", "nice", "nohup", "time", "timeout",
44 "whatis", "uname", "hostname", "df", "du", "free", "top", "ps", "kill", "killall", "nice", "nohup", "time", "timeout",
44−
45+
45− // Git read-only commands
46 "git status", "git log", "git diff", "git show", "git branch", "git tag", "git remote", "git ls-files", "git ls-remote",
46 "git status", "git log", "git diff", "git show", "git branch", "git tag", "git remote", "git ls-files", "git ls-remote",
47 "git rev-parse", "git config --get", "git config --list", "git describe", "git blame", "git grep", "git shortlog",
47 "git rev-parse", "git config --get", "git config --list", "git describe", "git blame", "git grep", "git shortlog",
48−
49− // Go commands
50− "go version", "go list", "go env", "go doc", "go vet", "go fmt", "go mod", "go test", "go build", "go run", "go install", "go clean",
51−
52− // Node.js commands
53− "node", "npm", "npx", "yarn", "pnpm",
54−
55− // Python commands
56− "python", "python3", "pip", "pip3", "pytest", "pylint", "mypy", "black", "isort", "flake8", "ruff",
57−
58− // Docker commands
59− "docker ps", "docker images", "docker volume", "docker network", "docker info", "docker version",
60− "docker-compose ps", "docker-compose config",
61−
62− // Kubernetes commands
63− "kubectl get", "kubectl describe", "kubectl logs", "kubectl version", "kubectl config",
64−
65− // Rust commands
66− "cargo", "rustc", "rustup",
67−
68− // Java commands
69− "java", "javac", "mvn", "gradle",
70−
71− // Misc development tools
72− "make", "cmake", "bazel", "terraform plan", "terraform validate", "ansible",
73−}
74−
75−func (b *bashTool) Info() ToolInfo {
76− return ToolInfo{
77− Name: BashToolName,
78− Description: bashDescription(),
79− Parameters: map[string]any{
80− "command": map[string]any{
81− "type": "string",
82− "description": "The command to execute",
83− },
84− "timeout": map[string]any{
85− "type": "number",
86− "desription": "Optional timeout in milliseconds (max 600000)",
87− },
88− },
89− Required: []string{"command"},
90− }
91−}
92−
93−// Handle implements Tool.
94−func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
95− var params BashParams
96− if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
97− return NewTextErrorResponse("invalid parameters"), nil
98− }
99−
100− if params.Timeout > MaxTimeout {
101− params.Timeout = MaxTimeout
102− } else if params.Timeout <= 0 {
103− params.Timeout = DefaultTimeout
104− }
105−
106− if params.Command == "" {
107− return NewTextErrorResponse("missing command"), nil
108− }
109−
110− // Check for banned commands (first word only)
111− baseCmd := strings.Fields(params.Command)[0]
112− for _, banned := range BannedCommands {
113− if strings.EqualFold(baseCmd, banned) {
114− return NewTextErrorResponse(fmt.Sprintf("command '%s' is not allowed", baseCmd)), nil
115− }
116− }
117−
118− // Check for safe commands (can be multi-word)
119− isSafeReadOnly := false
120− cmdLower := strings.ToLower(params.Command)
121−
122− for _, safe := range SafeReadOnlyCommands {
123− // Check if command starts with the safe command pattern
124− if strings.HasPrefix(cmdLower, strings.ToLower(safe)) {
125− // Make sure it's either an exact match or followed by a space or flag
126− if len(cmdLower) == len(safe) || cmdLower[len(safe)] == ' ' || cmdLower[len(safe)] == '-' {
127− isSafeReadOnly = true
128− break
129− }
130− }
131− }
132− if !isSafeReadOnly {
133− p := permission.Default.Request(
134− permission.CreatePermissionRequest{
135− Path: config.WorkingDirectory(),
136− ToolName: BashToolName,
137− Action: "execute",
138− Description: fmt.Sprintf("Execute command: %s", params.Command),
139− Params: BashPermissionsParams{
140− Command: params.Command,
141− },
142− },
143− )
144− if !p {
145− return NewTextErrorResponse("permission denied"), nil
146− }
147− }
148− shell := shell.GetPersistentShell(config.WorkingDirectory())
149− stdout, stderr, exitCode, interrupted, err := shell.Exec(ctx, params.Command, params.Timeout)
150− if err != nil {
151− return NewTextErrorResponse(fmt.Sprintf("error executing command: %s", err)), nil
152− }
153−
154− stdout = truncateOutput(stdout)
155− stderr = truncateOutput(stderr)
156−
157− errorMessage := stderr
158− if interrupted {
159− if errorMessage != "" {
160− errorMessage += "\n"
161− }
162− errorMessage += "Command was aborted before completion"
163− } else if exitCode != 0 {
164− if errorMessage != "" {
165− errorMessage += "\n"
166− }
167− errorMessage += fmt.Sprintf("Exit code %d", exitCode)
168− }
169−
170− hasBothOutputs := stdout != "" && stderr != ""
171−
172− if hasBothOutputs {
173− stdout += "\n"
174− }
175−
176− if errorMessage != "" {
177− stdout += "\n" + errorMessage
178− }
179−
180− if stdout == "" {
181− return NewTextResponse("no output"), nil
182− }
183− return NewTextResponse(stdout), nil
184−}
185−
186−func truncateOutput(content string) string {
187− if len(content) <= MaxOutputLength {
188− return content
189− }
190−
191− halfLength := MaxOutputLength / 2
192− start := content[:halfLength]
193− end := content[len(content)-halfLength:]
194−
195− truncatedLinesCount := countLines(content[halfLength : len(content)-halfLength])
196− return fmt.Sprintf("%s\n\n... [%d lines truncated] ...\n\n%s", start, truncatedLinesCount, end)
197−}
198
48
199−func countLines(s string) int {
49+ "go version", "go list", "go env", "go doc", "go vet", "go fmt", "go mod", "go test", "go build", "go run", "go install", "go clean",
200− if s == "" {
201− return 0
202− }
203− return len(strings.Split(s, "\n"))
204}
50}
205
51
206func bashDescription() string {
52func bashDescription() string {
207− bannedCommandsStr := strings.Join(BannedCommands, ", ")
53+ bannedCommandsStr := strings.Join(bannedCommands, ", ")
208 return fmt.Sprintf(`Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
54 return fmt.Sprintf(`Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
209
55
210Before executing the command, please follow these steps:
56Before executing the command, please follow these steps:
@@ -352,6 +198,134 @@ Important:
352- Never update git config`, bannedCommandsStr, MaxOutputLength)
198- Never update git config`, bannedCommandsStr, MaxOutputLength)
353}
199}
354
200
355−func NewBashTool() BaseTool {
201+func NewBashTool(permission permission.Service) BaseTool {
356− return &bashTool{}
202+ return &bashTool{
203+ permissions: permission,
204+ }
205+}
206+
207+func (b *bashTool) Info() ToolInfo {
208+ return ToolInfo{
209+ Name: BashToolName,
210+ Description: bashDescription(),
211+ Parameters: map[string]any{
212+ "command": map[string]any{
213+ "type": "string",
214+ "description": "The command to execute",
215+ },
216+ "timeout": map[string]any{
217+ "type": "number",
218+ "desription": "Optional timeout in milliseconds (max 600000)",
219+ },
220+ },
221+ Required: []string{"command"},
222+ }
223+}
224+
225+func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
226+ var params BashParams
227+ if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
228+ return NewTextErrorResponse("invalid parameters"), nil
229+ }
230+
231+ if params.Timeout > MaxTimeout {
232+ params.Timeout = MaxTimeout
233+ } else if params.Timeout <= 0 {
234+ params.Timeout = DefaultTimeout
235+ }
236+
237+ if params.Command == "" {
238+ return NewTextErrorResponse("missing command"), nil
239+ }
240+
241+ baseCmd := strings.Fields(params.Command)[0]
242+ for _, banned := range bannedCommands {
243+ if strings.EqualFold(baseCmd, banned) {
244+ return NewTextErrorResponse(fmt.Sprintf("command '%s' is not allowed", baseCmd)), nil
245+ }
246+ }
247+
248+ isSafeReadOnly := false
249+ cmdLower := strings.ToLower(params.Command)
250+
251+ for _, safe := range safeReadOnlyCommands {
252+ if strings.HasPrefix(cmdLower, strings.ToLower(safe)) {
253+ if len(cmdLower) == len(safe) || cmdLower[len(safe)] == ' ' || cmdLower[len(safe)] == '-' {
254+ isSafeReadOnly = true
255+ break
256+ }
257+ }
258+ }
259+ if !isSafeReadOnly {
260+ p := b.permissions.Request(
261+ permission.CreatePermissionRequest{
262+ Path: config.WorkingDirectory(),
263+ ToolName: BashToolName,
264+ Action: "execute",
265+ Description: fmt.Sprintf("Execute command: %s", params.Command),
266+ Params: BashPermissionsParams{
267+ Command: params.Command,
268+ },
269+ },
270+ )
271+ if !p {
272+ return NewTextErrorResponse("permission denied"), nil
273+ }
274+ }
275+ shell := shell.GetPersistentShell(config.WorkingDirectory())
276+ stdout, stderr, exitCode, interrupted, err := shell.Exec(ctx, params.Command, params.Timeout)
277+ if err != nil {
278+ return NewTextErrorResponse(fmt.Sprintf("error executing command: %s", err)), nil
279+ }
280+
281+ stdout = truncateOutput(stdout)
282+ stderr = truncateOutput(stderr)
283+
284+ errorMessage := stderr
285+ if interrupted {
286+ if errorMessage != "" {
287+ errorMessage += "\n"
288+ }
289+ errorMessage += "Command was aborted before completion"
290+ } else if exitCode != 0 {
291+ if errorMessage != "" {
292+ errorMessage += "\n"
293+ }
294+ errorMessage += fmt.Sprintf("Exit code %d", exitCode)
295+ }
296+
297+ hasBothOutputs := stdout != "" && stderr != ""
298+
299+ if hasBothOutputs {
300+ stdout += "\n"
301+ }
302+
303+ if errorMessage != "" {
304+ stdout += "\n" + errorMessage
305+ }
306+
307+ if stdout == "" {
308+ return NewTextResponse("no output"), nil
309+ }
310+ return NewTextResponse(stdout), nil
311+}
312+
313+func truncateOutput(content string) string {
314+ if len(content) <= MaxOutputLength {
315+ return content
316+ }
317+
318+ halfLength := MaxOutputLength / 2
319+ start := content[:halfLength]
320+ end := content[len(content)-halfLength:]
321+
322+ truncatedLinesCount := countLines(content[halfLength : len(content)-halfLength])
323+ return fmt.Sprintf("%s\n\n... [%d lines truncated] ...\n\n%s", start, truncatedLinesCount, end)
324+}
325+
326+func countLines(s string) int {
327+ if s == "" {
328+ return 0
329+ }
330+ return len(strings.Split(s, "\n"))
357}
331}
internal/llm/tools/bash_test.go+16−45
@@ -15,7 +15,7 @@ import (
15)
15)
16
16
17func TestBashTool_Info(t *testing.T) {
17func TestBashTool_Info(t *testing.T) {
18− tool := NewBashTool()
18+ tool := NewBashTool(newMockPermissionService(true))
19 info := tool.Info()
19 info := tool.Info()
20
20
21 assert.Equal(t, BashToolName, info.Name)
21 assert.Equal(t, BashToolName, info.Name)
@@ -26,13 +26,6 @@ func TestBashTool_Info(t *testing.T) {
26}
26}
27
27
28func TestBashTool_Run(t *testing.T) {
28func TestBashTool_Run(t *testing.T) {
29− // Setup a mock permission handler that always allows
30− origPermission := permission.Default
31− defer func() {
32− permission.Default = origPermission
33− }()
34− permission.Default = newMockPermissionService(true)
35−
36 // Save original working directory
29 // Save original working directory
37 origWd, err := os.Getwd()
30 origWd, err := os.Getwd()
38 require.NoError(t, err)
31 require.NoError(t, err)
@@ -41,8 +34,7 @@ func TestBashTool_Run(t *testing.T) {
41 }()
34 }()
42
35
43 t.Run("executes command successfully", func(t *testing.T) {
36 t.Run("executes command successfully", func(t *testing.T) {
44− permission.Default = newMockPermissionService(true)
45− tool := NewBashTool()
37+ tool := NewBashTool(newMockPermissionService(true))
46 params := BashParams{
38 params := BashParams{
47 Command: "echo 'Hello World'",
39 Command: "echo 'Hello World'",
48 }
40 }
@@ -61,9 +53,7 @@ func TestBashTool_Run(t *testing.T) {
61 })
53 })
62
54
63 t.Run("handles invalid parameters", func(t *testing.T) {
55 t.Run("handles invalid parameters", func(t *testing.T) {
64− permission.Default = newMockPermissionService(true)
65−
66− tool := NewBashTool()
56+ tool := NewBashTool(newMockPermissionService(true))
67 call := ToolCall{
57 call := ToolCall{
68 Name: BashToolName,
58 Name: BashToolName,
69 Input: "invalid json",
59 Input: "invalid json",
@@ -75,9 +65,7 @@ func TestBashTool_Run(t *testing.T) {
75 })
65 })
76
66
77 t.Run("handles missing command", func(t *testing.T) {
67 t.Run("handles missing command", func(t *testing.T) {
78− permission.Default = newMockPermissionService(true)
79−
80− tool := NewBashTool()
68+ tool := NewBashTool(newMockPermissionService(true))
81 params := BashParams{
69 params := BashParams{
82 Command: "",
70 Command: "",
83 }
71 }
@@ -96,11 +84,9 @@ func TestBashTool_Run(t *testing.T) {
96 })
84 })
97
85
98 t.Run("handles banned commands", func(t *testing.T) {
86 t.Run("handles banned commands", func(t *testing.T) {
99− permission.Default = newMockPermissionService(true)
87+ tool := NewBashTool(newMockPermissionService(true))
100
88
101− tool := NewBashTool()
102−
103− for _, bannedCmd := range BannedCommands {
89+ for _, bannedCmd := range bannedCommands {
104 params := BashParams{
90 params := BashParams{
105 Command: bannedCmd + " arg1 arg2",
91 Command: bannedCmd + " arg1 arg2",
106 }
92 }
@@ -120,17 +106,11 @@ func TestBashTool_Run(t *testing.T) {
120 })
106 })
121
107
122 t.Run("handles multi-word safe commands without permission check", func(t *testing.T) {
108 t.Run("handles multi-word safe commands without permission check", func(t *testing.T) {
123− permission.Default = newMockPermissionService(false)
124−
125− tool := NewBashTool()
109+ tool := NewBashTool(newMockPermissionService(false))
126
110
127 // Test with multi-word safe commands
111 // Test with multi-word safe commands
128 multiWordCommands := []string{
112 multiWordCommands := []string{
129− "git status",
113+ "go env",
130− "git log -n 5",
131− "docker ps",
132− "go test ./...",
133− "kubectl get pods",
134 }
114 }
135
115
136 for _, cmd := range multiWordCommands {
116 for _, cmd := range multiWordCommands {
@@ -148,15 +128,13 @@ func TestBashTool_Run(t *testing.T) {
148
128
149 response, err := tool.Run(context.Background(), call)
129 response, err := tool.Run(context.Background(), call)
150 require.NoError(t, err)
130 require.NoError(t, err)
151− assert.NotContains(t, response.Content, "permission denied",
131+ assert.NotContains(t, response.Content, "permission denied",
152 "Command %s should be allowed without permission", cmd)
132 "Command %s should be allowed without permission", cmd)
153 }
133 }
154 })
134 })
155
135
156 t.Run("handles permission denied", func(t *testing.T) {
136 t.Run("handles permission denied", func(t *testing.T) {
157− permission.Default = newMockPermissionService(false)
158−
159− tool := NewBashTool()
137+ tool := NewBashTool(newMockPermissionService(false))
160
138
161 // Test with a command that requires permission
139 // Test with a command that requires permission
162 params := BashParams{
140 params := BashParams{
@@ -177,8 +155,7 @@ func TestBashTool_Run(t *testing.T) {
177 })
155 })
178
156
179 t.Run("handles command timeout", func(t *testing.T) {
157 t.Run("handles command timeout", func(t *testing.T) {
180− permission.Default = newMockPermissionService(true)
181− tool := NewBashTool()
158+ tool := NewBashTool(newMockPermissionService(true))
182 params := BashParams{
159 params := BashParams{
183 Command: "sleep 2",
160 Command: "sleep 2",
184 Timeout: 100, // 100ms timeout
161 Timeout: 100, // 100ms timeout
@@ -198,8 +175,7 @@ func TestBashTool_Run(t *testing.T) {
198 })
175 })
199
176
200 t.Run("handles command with stderr output", func(t *testing.T) {
177 t.Run("handles command with stderr output", func(t *testing.T) {
201− permission.Default = newMockPermissionService(true)
202− tool := NewBashTool()
178+ tool := NewBashTool(newMockPermissionService(true))
203 params := BashParams{
179 params := BashParams{
204 Command: "echo 'error message' >&2",
180 Command: "echo 'error message' >&2",
205 }
181 }
@@ -218,8 +194,7 @@ func TestBashTool_Run(t *testing.T) {
218 })
194 })
219
195
220 t.Run("handles command with both stdout and stderr", func(t *testing.T) {
196 t.Run("handles command with both stdout and stderr", func(t *testing.T) {
221− permission.Default = newMockPermissionService(true)
222− tool := NewBashTool()
197+ tool := NewBashTool(newMockPermissionService(true))
223 params := BashParams{
198 params := BashParams{
224 Command: "echo 'stdout message' && echo 'stderr message' >&2",
199 Command: "echo 'stdout message' && echo 'stderr message' >&2",
225 }
200 }
@@ -239,8 +214,7 @@ func TestBashTool_Run(t *testing.T) {
239 })
214 })
240
215
241 t.Run("handles context cancellation", func(t *testing.T) {
216 t.Run("handles context cancellation", func(t *testing.T) {
242− permission.Default = newMockPermissionService(true)
243− tool := NewBashTool()
217+ tool := NewBashTool(newMockPermissionService(true))
244 params := BashParams{
218 params := BashParams{
245 Command: "sleep 5",
219 Command: "sleep 5",
246 }
220 }
@@ -267,8 +241,7 @@ func TestBashTool_Run(t *testing.T) {
267 })
241 })
268
242
269 t.Run("respects max timeout", func(t *testing.T) {
243 t.Run("respects max timeout", func(t *testing.T) {
270− permission.Default = newMockPermissionService(true)
271− tool := NewBashTool()
244+ tool := NewBashTool(newMockPermissionService(true))
272 params := BashParams{
245 params := BashParams{
273 Command: "echo 'test'",
246 Command: "echo 'test'",
274 Timeout: MaxTimeout + 1000, // Exceeds max timeout
247 Timeout: MaxTimeout + 1000, // Exceeds max timeout
@@ -288,8 +261,7 @@ func TestBashTool_Run(t *testing.T) {
288 })
261 })
289
262
290 t.Run("uses default timeout for zero or negative timeout", func(t *testing.T) {
263 t.Run("uses default timeout for zero or negative timeout", func(t *testing.T) {
291− permission.Default = newMockPermissionService(true)
292− tool := NewBashTool()
264+ tool := NewBashTool(newMockPermissionService(true))
293 params := BashParams{
265 params := BashParams{
294 Command: "echo 'test'",
266 Command: "echo 'test'",
295 Timeout: -100, // Negative timeout
267 Timeout: -100, // Negative timeout
@@ -397,4 +369,3 @@ func newMockPermissionService(allow bool) permission.Service {
397 allow: allow,
369 allow: allow,
398 }
370 }
399}
371}
400−
internal/llm/tools/diagnostics.go+30−43
@@ -13,22 +13,48 @@ import (
13 "github.com/kujtimiihoxha/termai/internal/lsp/protocol"
13 "github.com/kujtimiihoxha/termai/internal/lsp/protocol"
14)
14)
15
15
16+type DiagnosticsParams struct {
17+ FilePath string `json:"file_path"`
18+}
16type diagnosticsTool struct {
19type diagnosticsTool struct {
17 lspClients map[string]*lsp.Client
20 lspClients map[string]*lsp.Client
18}
21}
19
22
20const (
23const (
21− DiagnosticsToolName = "diagnostics"
24+ DiagnosticsToolName = "diagnostics"
25+ diagnosticsDescription = `Get diagnostics for a file and/or project.
26+WHEN TO USE THIS TOOL:
27+- Use when you need to check for errors or warnings in your code
28+- Helpful for debugging and ensuring code quality
29+- Good for getting a quick overview of issues in a file or project
30+HOW TO USE:
31+- Provide a path to a file to get diagnostics for that file
32+- Leave the path empty to get diagnostics for the entire project
33+- Results are displayed in a structured format with severity levels
34+FEATURES:
35+- Displays errors, warnings, and hints
36+- Groups diagnostics by severity
37+- Provides detailed information about each diagnostic
38+LIMITATIONS:
39+- Results are limited to the diagnostics provided by the LSP clients
40+- May not cover all possible issues in the code
41+- Does not provide suggestions for fixing issues
42+TIPS:
43+- Use in conjunction with other tools for a comprehensive code review
44+- Combine with the LSP client for real-time diagnostics
45+`
22)
46)
23
47
24−type DiagnosticsParams struct {
48+func NewDiagnosticsTool(lspClients map[string]*lsp.Client) BaseTool {
25− FilePath string `json:"file_path"`
49+ return &diagnosticsTool{
50+ lspClients,
51+ }
26}
52}
27
53
28func (b *diagnosticsTool) Info() ToolInfo {
54func (b *diagnosticsTool) Info() ToolInfo {
29 return ToolInfo{
55 return ToolInfo{
30 Name: DiagnosticsToolName,
56 Name: DiagnosticsToolName,
31− Description: "Get diagnostics for a file and/or project.",
57+ Description: diagnosticsDescription,
32 Parameters: map[string]any{
58 Parameters: map[string]any{
33 "file_path": map[string]any{
59 "file_path": map[string]any{
34 "type": "string",
60 "type": "string",
@@ -63,31 +89,24 @@ func (b *diagnosticsTool) Run(ctx context.Context, call ToolCall) (ToolResponse,
63
89
64func notifyLspOpenFile(ctx context.Context, filePath string, lsps map[string]*lsp.Client) {
90func notifyLspOpenFile(ctx context.Context, filePath string, lsps map[string]*lsp.Client) {
65 for _, client := range lsps {
91 for _, client := range lsps {
66− // Open the file
67 err := client.OpenFile(ctx, filePath)
92 err := client.OpenFile(ctx, filePath)
68 if err != nil {
93 if err != nil {
69− // If there's an error opening the file, continue to the next client
70 continue
94 continue
71 }
95 }
72 }
96 }
73}
97}
74
98
75−// waitForLspDiagnostics opens a file in LSP clients and waits for diagnostics to be published
76func waitForLspDiagnostics(ctx context.Context, filePath string, lsps map[string]*lsp.Client) {
99func waitForLspDiagnostics(ctx context.Context, filePath string, lsps map[string]*lsp.Client) {
77 if len(lsps) == 0 {
100 if len(lsps) == 0 {
78 return
101 return
79 }
102 }
80
103
81− // Create a channel to receive diagnostic notifications
82 diagChan := make(chan struct{}, 1)
104 diagChan := make(chan struct{}, 1)
83
105
84− // Register a temporary diagnostic handler for each client
85 for _, client := range lsps {
106 for _, client := range lsps {
86− // Store the original diagnostics map to detect changes
87 originalDiags := make(map[protocol.DocumentUri][]protocol.Diagnostic)
107 originalDiags := make(map[protocol.DocumentUri][]protocol.Diagnostic)
88 maps.Copy(originalDiags, client.GetDiagnostics())
108 maps.Copy(originalDiags, client.GetDiagnostics())
89
109
90− // Create a notification handler that will signal when diagnostics are received
91 handler := func(params json.RawMessage) {
110 handler := func(params json.RawMessage) {
92 lsp.HandleDiagnostics(client, params)
111 lsp.HandleDiagnostics(client, params)
93 var diagParams protocol.PublishDiagnosticsParams
112 var diagParams protocol.PublishDiagnosticsParams
@@ -95,28 +114,22 @@ func waitForLspDiagnostics(ctx context.Context, filePath string, lsps map[string
95 return
114 return
96 }
115 }
97
116
98− // If this is for our file or we've received any new diagnostics, signal completion
99 if diagParams.URI.Path() == filePath || hasDiagnosticsChanged(client.GetDiagnostics(), originalDiags) {
117 if diagParams.URI.Path() == filePath || hasDiagnosticsChanged(client.GetDiagnostics(), originalDiags) {
100 select {
118 select {
101 case diagChan <- struct{}{}:
119 case diagChan <- struct{}{}:
102− // Signal sent
103 default:
120 default:
104− // Channel already has a value, no need to send again
105 }
121 }
106 }
122 }
107 }
123 }
108
124
109− // Register our temporary handler
110 client.RegisterNotificationHandler("textDocument/publishDiagnostics", handler)
125 client.RegisterNotificationHandler("textDocument/publishDiagnostics", handler)
111
126
112− // Notify change if the file is already open
113 if client.IsFileOpen(filePath) {
127 if client.IsFileOpen(filePath) {
114 err := client.NotifyChange(ctx, filePath)
128 err := client.NotifyChange(ctx, filePath)
115 if err != nil {
129 if err != nil {
116 continue
130 continue
117 }
131 }
118 } else {
132 } else {
119− // Open the file if it's not already open
120 err := client.OpenFile(ctx, filePath)
133 err := client.OpenFile(ctx, filePath)
121 if err != nil {
134 if err != nil {
122 continue
135 continue
@@ -124,22 +137,13 @@ func waitForLspDiagnostics(ctx context.Context, filePath string, lsps map[string
124 }
137 }
125 }
138 }
126
139
127− // Wait for diagnostics with a reasonable timeout
128 select {
140 select {
129 case <-diagChan:
141 case <-diagChan:
130− // Diagnostics received
131 case <-time.After(5 * time.Second):
142 case <-time.After(5 * time.Second):
132− // Timeout after 5 seconds - this is a fallback in case no diagnostics are published
133 case <-ctx.Done():
143 case <-ctx.Done():
134− // Context cancelled
135 }
144 }
136−
137− // Note: We're not unregistering our handler because the Client.RegisterNotificationHandler
138− // replaces any existing handler, and we'll be replaced by the original handler when
139− // the LSP client is reinitialized or when a new handler is registered.
140}
145}
141
146
142−// hasDiagnosticsChanged checks if there are any new diagnostics compared to the original set
143func hasDiagnosticsChanged(current, original map[protocol.DocumentUri][]protocol.Diagnostic) bool {
147func hasDiagnosticsChanged(current, original map[protocol.DocumentUri][]protocol.Diagnostic) bool {
144 for uri, diags := range current {
148 for uri, diags := range current {
145 origDiags, exists := original[uri]
149 origDiags, exists := original[uri]
@@ -154,9 +158,7 @@ func appendDiagnostics(filePath string, lsps map[string]*lsp.Client) string {
154 fileDiagnostics := []string{}
158 fileDiagnostics := []string{}
155 projectDiagnostics := []string{}
159 projectDiagnostics := []string{}
156
160
157− // Enhanced format function that includes more diagnostic information
158 formatDiagnostic := func(pth string, diagnostic protocol.Diagnostic, source string) string {
161 formatDiagnostic := func(pth string, diagnostic protocol.Diagnostic, source string) string {
159− // Base components
160 severity := "Info"
162 severity := "Info"
161 switch diagnostic.Severity {
163 switch diagnostic.Severity {
162 case protocol.SeverityError:
164 case protocol.SeverityError:
@@ -167,10 +169,8 @@ func appendDiagnostics(filePath string, lsps map[string]*lsp.Client) string {
167 severity = "Hint"
169 severity = "Hint"
168 }
170 }
169
171
170− // Location information
171 location := fmt.Sprintf("%s:%d:%d", pth, diagnostic.Range.Start.Line+1, diagnostic.Range.Start.Character+1)
172 location := fmt.Sprintf("%s:%d:%d", pth, diagnostic.Range.Start.Line+1, diagnostic.Range.Start.Character+1)
172
173
173− // Source information (LSP name)
174 sourceInfo := ""
174 sourceInfo := ""
175 if diagnostic.Source != "" {
175 if diagnostic.Source != "" {
176 sourceInfo = diagnostic.Source
176 sourceInfo = diagnostic.Source
@@ -178,13 +178,11 @@ func appendDiagnostics(filePath string, lsps map[string]*lsp.Client) string {
178 sourceInfo = source
178 sourceInfo = source
179 }
179 }
180
180
181− // Code information
182 codeInfo := ""
181 codeInfo := ""
183 if diagnostic.Code != nil {
182 if diagnostic.Code != nil {
184 codeInfo = fmt.Sprintf("[%v]", diagnostic.Code)
183 codeInfo = fmt.Sprintf("[%v]", diagnostic.Code)
185 }
184 }
186
185
187− // Tags information
188 tagsInfo := ""
186 tagsInfo := ""
189 if len(diagnostic.Tags) > 0 {
187 if len(diagnostic.Tags) > 0 {
190 tags := []string{}
188 tags := []string{}
@@ -201,7 +199,6 @@ func appendDiagnostics(filePath string, lsps map[string]*lsp.Client) string {
201 }
199 }
202 }
200 }
203
201
204− // Assemble the full diagnostic message
205 return fmt.Sprintf("%s: %s [%s]%s%s %s",
202 return fmt.Sprintf("%s: %s [%s]%s%s %s",
206 severity,
203 severity,
207 location,
204 location,
@@ -217,7 +214,6 @@ func appendDiagnostics(filePath string, lsps map[string]*lsp.Client) string {
217 for location, diags := range diagnostics {
214 for location, diags := range diagnostics {
218 isCurrentFile := location.Path() == filePath
215 isCurrentFile := location.Path() == filePath
219
216
220− // Group diagnostics by severity for better organization
221 for _, diag := range diags {
217 for _, diag := range diags {
222 formattedDiag := formatDiagnostic(location.Path(), diag, lspName)
218 formattedDiag := formatDiagnostic(location.Path(), diag, lspName)
223
219
@@ -231,7 +227,6 @@ func appendDiagnostics(filePath string, lsps map[string]*lsp.Client) string {
231 }
227 }
232 }
228 }
233
229
234− // Sort diagnostics by severity (errors first) and then by location
235 sort.Slice(fileDiagnostics, func(i, j int) bool {
230 sort.Slice(fileDiagnostics, func(i, j int) bool {
236 iIsError := strings.HasPrefix(fileDiagnostics[i], "Error")
231 iIsError := strings.HasPrefix(fileDiagnostics[i], "Error")
237 jIsError := strings.HasPrefix(fileDiagnostics[j], "Error")
232 jIsError := strings.HasPrefix(fileDiagnostics[j], "Error")
@@ -274,7 +269,6 @@ func appendDiagnostics(filePath string, lsps map[string]*lsp.Client) string {
274 output += "\n</project_diagnostics>\n"
269 output += "\n</project_diagnostics>\n"
275 }
270 }
276
271
277− // Add summary counts
278 if len(fileDiagnostics) > 0 || len(projectDiagnostics) > 0 {
272 if len(fileDiagnostics) > 0 || len(projectDiagnostics) > 0 {
279 fileErrors := countSeverity(fileDiagnostics, "Error")
273 fileErrors := countSeverity(fileDiagnostics, "Error")
280 fileWarnings := countSeverity(fileDiagnostics, "Warn")
274 fileWarnings := countSeverity(fileDiagnostics, "Warn")
@@ -290,7 +284,6 @@ func appendDiagnostics(filePath string, lsps map[string]*lsp.Client) string {
290 return output
284 return output
291}
285}
292
286
293−// Helper function to count diagnostics by severity
294func countSeverity(diagnostics []string, severity string) int {
287func countSeverity(diagnostics []string, severity string) int {
295 count := 0
288 count := 0
296 for _, diag := range diagnostics {
289 for _, diag := range diagnostics {
@@ -300,9 +293,3 @@ func countSeverity(diagnostics []string, severity string) int {
300 }
293 }
301 return count
294 return count
302}
295}
303−
304−func NewDiagnosticsTool(lspClients map[string]*lsp.Client) BaseTool {
305− return &diagnosticsTool{
306− lspClients,
307− }
308−}
internal/llm/tools/edit.go+73−80
@@ -15,14 +15,6 @@ import (
15 "github.com/sergi/go-diff/diffmatchpatch"
15 "github.com/sergi/go-diff/diffmatchpatch"
16)
16)
17
17
18−type editTool struct {
19− lspClients map[string]*lsp.Client
20−}
21−
22−const (
23− EditToolName = "edit"
24−)
25−
26type EditParams struct {
18type EditParams struct {
27 FilePath string `json:"file_path"`
19 FilePath string `json:"file_path"`
28 OldString string `json:"old_string"`
20 OldString string `json:"old_string"`
@@ -36,10 +28,73 @@ type EditPermissionsParams struct {
36 Diff string `json:"diff"`
28 Diff string `json:"diff"`
37}
29}
38
30
31+type editTool struct {
32+ lspClients map[string]*lsp.Client
33+ permissions permission.Service
34+}
35+
36+const (
37+ EditToolName = "edit"
38+ editDescription = `Edits files by replacing text, creating new files, or deleting content. For moving or renaming files, use the Bash tool with the 'mv' command instead. For larger file edits, use the FileWrite tool to overwrite files.
39+
40+Before using this tool:
41+
42+1. Use the FileRead tool to understand the file's contents and context
43+
44+2. Verify the directory path is correct (only applicable when creating new files):
45+ - Use the LS tool to verify the parent directory exists and is the correct location
46+
47+To make a file edit, provide the following:
48+1. file_path: The absolute path to the file to modify (must be absolute, not relative)
49+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)
50+3. new_string: The edited text to replace the old_string
51+
52+Special cases:
53+- To create a new file: provide file_path and new_string, leave old_string empty
54+- To delete content: provide file_path and old_string, leave new_string empty
55+
56+The tool will replace ONE occurrence of old_string with new_string in the specified file.
57+
58+CRITICAL REQUIREMENTS FOR USING THIS TOOL:
59+
60+1. UNIQUENESS: The old_string MUST uniquely identify the specific instance you want to change. This means:
61+ - Include AT LEAST 3-5 lines of context BEFORE the change point
62+ - Include AT LEAST 3-5 lines of context AFTER the change point
63+ - Include all whitespace, indentation, and surrounding code exactly as it appears in the file
64+
65+2. SINGLE INSTANCE: This tool can only change ONE instance at a time. If you need to change multiple instances:
66+ - Make separate calls to this tool for each instance
67+ - Each call must uniquely identify its specific instance using extensive context
68+
69+3. VERIFICATION: Before using this tool:
70+ - Check how many instances of the target text exist in the file
71+ - If multiple instances exist, gather enough context to uniquely identify each one
72+ - Plan separate tool calls for each instance
73+
74+WARNING: If you do not follow these requirements:
75+ - The tool will fail if old_string matches multiple locations
76+ - The tool will fail if old_string doesn't match exactly (including whitespace)
77+ - You may change the wrong instance if you don't include enough context
78+
79+When making edits:
80+ - Ensure the edit results in idiomatic, correct code
81+ - Do not leave the code in a broken state
82+ - Always use absolute file paths (starting with /)
83+
84+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.`
85+)
86+
87+func NewEditTool(lspClients map[string]*lsp.Client, permissions permission.Service) BaseTool {
88+ return &editTool{
89+ lspClients: lspClients,
90+ permissions: permissions,
91+ }
92+}
93+
39func (e *editTool) Info() ToolInfo {
94func (e *editTool) Info() ToolInfo {
40 return ToolInfo{
95 return ToolInfo{
41 Name: EditToolName,
96 Name: EditToolName,
42− Description: editDescription(),
97+ Description: editDescription,
43 Parameters: map[string]any{
98 Parameters: map[string]any{
44 "file_path": map[string]any{
99 "file_path": map[string]any{
45 "type": "string",
100 "type": "string",
@@ -58,7 +113,6 @@ func (e *editTool) Info() ToolInfo {
58 }
113 }
59}
114}
60
115
61−// Run implements Tool.
62func (e *editTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
116func (e *editTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
63 var params EditParams
117 var params EditParams
64 if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
118 if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
@@ -75,7 +129,7 @@ func (e *editTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
75 }
129 }
76
130
77 if params.OldString == "" {
131 if params.OldString == "" {
78− result, err := createNewFile(params.FilePath, params.NewString)
132+ result, err := e.createNewFile(params.FilePath, params.NewString)
79 if err != nil {
133 if err != nil {
80 return NewTextErrorResponse(fmt.Sprintf("error creating file: %s", err)), nil
134 return NewTextErrorResponse(fmt.Sprintf("error creating file: %s", err)), nil
81 }
135 }
@@ -83,26 +137,25 @@ func (e *editTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
83 }
137 }
84
138
85 if params.NewString == "" {
139 if params.NewString == "" {
86− result, err := deleteContent(params.FilePath, params.OldString)
140+ result, err := e.deleteContent(params.FilePath, params.OldString)
87 if err != nil {
141 if err != nil {
88 return NewTextErrorResponse(fmt.Sprintf("error deleting content: %s", err)), nil
142 return NewTextErrorResponse(fmt.Sprintf("error deleting content: %s", err)), nil
89 }
143 }
90 return NewTextResponse(result), nil
144 return NewTextResponse(result), nil
91 }
145 }
92
146
93− result, err := replaceContent(params.FilePath, params.OldString, params.NewString)
147+ result, err := e.replaceContent(params.FilePath, params.OldString, params.NewString)
94 if err != nil {
148 if err != nil {
95 return NewTextErrorResponse(fmt.Sprintf("error replacing content: %s", err)), nil
149 return NewTextErrorResponse(fmt.Sprintf("error replacing content: %s", err)), nil
96 }
150 }
97
151
98− // Wait for LSP diagnostics after editing the file
99 waitForLspDiagnostics(ctx, params.FilePath, e.lspClients)
152 waitForLspDiagnostics(ctx, params.FilePath, e.lspClients)
100 result = fmt.Sprintf("<result>\n%s\n</result>\n", result)
153 result = fmt.Sprintf("<result>\n%s\n</result>\n", result)
101 result += appendDiagnostics(params.FilePath, e.lspClients)
154 result += appendDiagnostics(params.FilePath, e.lspClients)
102 return NewTextResponse(result), nil
155 return NewTextResponse(result), nil
103}
156}
104
157
105−func createNewFile(filePath, content string) (string, error) {
158+func (e *editTool) createNewFile(filePath, content string) (string, error) {
106 fileInfo, err := os.Stat(filePath)
159 fileInfo, err := os.Stat(filePath)
107 if err == nil {
160 if err == nil {
108 if fileInfo.IsDir() {
161 if fileInfo.IsDir() {
@@ -118,7 +171,7 @@ func createNewFile(filePath, content string) (string, error) {
118 return "", fmt.Errorf("failed to create parent directories: %w", err)
171 return "", fmt.Errorf("failed to create parent directories: %w", err)
119 }
172 }
120
173
121− p := permission.Default.Request(
174+ p := e.permissions.Request(
122 permission.CreatePermissionRequest{
175 permission.CreatePermissionRequest{
123 Path: filepath.Dir(filePath),
176 Path: filepath.Dir(filePath),
124 ToolName: EditToolName,
177 ToolName: EditToolName,
@@ -147,7 +200,7 @@ func createNewFile(filePath, content string) (string, error) {
147 return "File created: " + filePath, nil
200 return "File created: " + filePath, nil
148}
201}
149
202
150−func deleteContent(filePath, oldString string) (string, error) {
203+func (e *editTool) deleteContent(filePath, oldString string) (string, error) {
151 fileInfo, err := os.Stat(filePath)
204 fileInfo, err := os.Stat(filePath)
152 if err != nil {
205 if err != nil {
153 if os.IsNotExist(err) {
206 if os.IsNotExist(err) {
@@ -190,7 +243,7 @@ func deleteContent(filePath, oldString string) (string, error) {
190
243
191 newContent := oldContent[:index] + oldContent[index+len(oldString):]
244 newContent := oldContent[:index] + oldContent[index+len(oldString):]
192
245
193− p := permission.Default.Request(
246+ p := e.permissions.Request(
194 permission.CreatePermissionRequest{
247 permission.CreatePermissionRequest{
195 Path: filepath.Dir(filePath),
248 Path: filepath.Dir(filePath),
196 ToolName: EditToolName,
249 ToolName: EditToolName,
@@ -219,7 +272,7 @@ func deleteContent(filePath, oldString string) (string, error) {
219 return "Content deleted from file: " + filePath, nil
272 return "Content deleted from file: " + filePath, nil
220}
273}
221
274
222−func replaceContent(filePath, oldString, newString string) (string, error) {
275+func (e *editTool) replaceContent(filePath, oldString, newString string) (string, error) {
223 fileInfo, err := os.Stat(filePath)
276 fileInfo, err := os.Stat(filePath)
224 if err != nil {
277 if err != nil {
225 if os.IsNotExist(err) {
278 if os.IsNotExist(err) {
@@ -268,7 +321,7 @@ func replaceContent(filePath, oldString, newString string) (string, error) {
268
321
269 diff := GenerateDiff(oldContent[startIndex:oldEndIndex], newContent[startIndex:newEndIndex])
322 diff := GenerateDiff(oldContent[startIndex:oldEndIndex], newContent[startIndex:newEndIndex])
270
323
271− p := permission.Default.Request(
324+ p := e.permissions.Request(
272 permission.CreatePermissionRequest{
325 permission.CreatePermissionRequest{
273 Path: filepath.Dir(filePath),
326 Path: filepath.Dir(filePath),
274 ToolName: EditToolName,
327 ToolName: EditToolName,
@@ -305,7 +358,6 @@ func GenerateDiff(oldContent, newContent string) string {
305 diffs = dmp.DiffCleanupSemantic(diffs)
358 diffs = dmp.DiffCleanupSemantic(diffs)
306 buff := strings.Builder{}
359 buff := strings.Builder{}
307
360
308− // Add a header to make the diff more readable
309 buff.WriteString("Changes:\n")
361 buff.WriteString("Changes:\n")
310
362
311 for _, diff := range diffs {
363 for _, diff := range diffs {
@@ -327,10 +379,8 @@ func GenerateDiff(oldContent, newContent string) string {
327 _, _ = buff.WriteString("- " + line + "\n")
379 _, _ = buff.WriteString("- " + line + "\n")
328 }
380 }
329 case diffmatchpatch.DiffEqual:
381 case diffmatchpatch.DiffEqual:
330− // Only show a small context for unchanged text
331 lines := strings.Split(text, "\n")
382 lines := strings.Split(text, "\n")
332 if len(lines) > 3 {
383 if len(lines) > 3 {
333− // Show only first and last line of context with a separator
334 if lines[0] != "" {
384 if lines[0] != "" {
335 _, _ = buff.WriteString(" " + lines[0] + "\n")
385 _, _ = buff.WriteString(" " + lines[0] + "\n")
336 }
386 }
@@ -339,7 +389,6 @@ func GenerateDiff(oldContent, newContent string) string {
339 _, _ = buff.WriteString(" " + lines[len(lines)-1] + "\n")
389 _, _ = buff.WriteString(" " + lines[len(lines)-1] + "\n")
340 }
390 }
341 } else {
391 } else {
342− // Show all lines for small contexts
343 for _, line := range lines {
392 for _, line := range lines {
344 if line == "" {
393 if line == "" {
345 continue
394 continue
@@ -351,59 +400,3 @@ func GenerateDiff(oldContent, newContent string) string {
351 }
400 }
352 return buff.String()
401 return buff.String()
353}
402}
354−
355−func editDescription() string {
356− return `Edits files by replacing text, creating new files, or deleting content. For moving or renaming files, use the Bash tool with the 'mv' command instead. For larger file edits, use the FileWrite tool to overwrite files.
357−
358−Before using this tool:
359−
360−1. Use the FileRead tool to understand the file's contents and context
361−
362−2. Verify the directory path is correct (only applicable when creating new files):
363− - Use the LS tool to verify the parent directory exists and is the correct location
364−
365−To make a file edit, provide the following:
366−1. file_path: The absolute path to the file to modify (must be absolute, not relative)
367−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)
368−3. new_string: The edited text to replace the old_string
369−
370−Special cases:
371−- To create a new file: provide file_path and new_string, leave old_string empty
372−- To delete content: provide file_path and old_string, leave new_string empty
373−
374−The tool will replace ONE occurrence of old_string with new_string in the specified file.
375−
376−CRITICAL REQUIREMENTS FOR USING THIS TOOL:
377−
378−1. UNIQUENESS: The old_string MUST uniquely identify the specific instance you want to change. This means:
379− - Include AT LEAST 3-5 lines of context BEFORE the change point
380− - Include AT LEAST 3-5 lines of context AFTER the change point
381− - Include all whitespace, indentation, and surrounding code exactly as it appears in the file
382−
383−2. SINGLE INSTANCE: This tool can only change ONE instance at a time. If you need to change multiple instances:
384− - Make separate calls to this tool for each instance
385− - Each call must uniquely identify its specific instance using extensive context
386−
387−3. VERIFICATION: Before using this tool:
388− - Check how many instances of the target text exist in the file
389− - If multiple instances exist, gather enough context to uniquely identify each one
390− - Plan separate tool calls for each instance
391−
392−WARNING: If you do not follow these requirements:
393− - The tool will fail if old_string matches multiple locations
394− - The tool will fail if old_string doesn't match exactly (including whitespace)
395− - You may change the wrong instance if you don't include enough context
396−
397−When making edits:
398− - Ensure the edit results in idiomatic, correct code
399− - Do not leave the code in a broken state
400− - Always use absolute file paths (starting with /)
401−
402−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.`
403−}
404−
405−func NewEditTool(lspClients map[string]*lsp.Client) BaseTool {
406− return &editTool{
407− lspClients,
408− }
409−}
internal/llm/tools/edit_test.gonew file+509
@@ -0,0 +1,509 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+ "time"
10+
11+ "github.com/kujtimiihoxha/termai/internal/lsp"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestEditTool_Info(t *testing.T) {
17+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
18+ info := tool.Info()
19+
20+ assert.Equal(t, EditToolName, info.Name)
21+ assert.NotEmpty(t, info.Description)
22+ assert.Contains(t, info.Parameters, "file_path")
23+ assert.Contains(t, info.Parameters, "old_string")
24+ assert.Contains(t, info.Parameters, "new_string")
25+ assert.Contains(t, info.Required, "file_path")
26+ assert.Contains(t, info.Required, "old_string")
27+ assert.Contains(t, info.Required, "new_string")
28+}
29+
30+func TestEditTool_Run(t *testing.T) {
31+ // Create a temporary directory for testing
32+ tempDir, err := os.MkdirTemp("", "edit_tool_test")
33+ require.NoError(t, err)
34+ defer os.RemoveAll(tempDir)
35+
36+ t.Run("creates a new file successfully", func(t *testing.T) {
37+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
38+
39+ filePath := filepath.Join(tempDir, "new_file.txt")
40+ content := "This is a test content"
41+
42+ params := EditParams{
43+ FilePath: filePath,
44+ OldString: "",
45+ NewString: content,
46+ }
47+
48+ paramsJSON, err := json.Marshal(params)
49+ require.NoError(t, err)
50+
51+ call := ToolCall{
52+ Name: EditToolName,
53+ Input: string(paramsJSON),
54+ }
55+
56+ response, err := tool.Run(context.Background(), call)
57+ require.NoError(t, err)
58+ assert.Contains(t, response.Content, "File created")
59+
60+ // Verify file was created with correct content
61+ fileContent, err := os.ReadFile(filePath)
62+ require.NoError(t, err)
63+ assert.Equal(t, content, string(fileContent))
64+ })
65+
66+ t.Run("creates file with nested directories", func(t *testing.T) {
67+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
68+
69+ filePath := filepath.Join(tempDir, "nested/dirs/new_file.txt")
70+ content := "Content in nested directory"
71+
72+ params := EditParams{
73+ FilePath: filePath,
74+ OldString: "",
75+ NewString: content,
76+ }
77+
78+ paramsJSON, err := json.Marshal(params)
79+ require.NoError(t, err)
80+
81+ call := ToolCall{
82+ Name: EditToolName,
83+ Input: string(paramsJSON),
84+ }
85+
86+ response, err := tool.Run(context.Background(), call)
87+ require.NoError(t, err)
88+ assert.Contains(t, response.Content, "File created")
89+
90+ // Verify file was created with correct content
91+ fileContent, err := os.ReadFile(filePath)
92+ require.NoError(t, err)
93+ assert.Equal(t, content, string(fileContent))
94+ })
95+
96+ t.Run("fails to create file that already exists", func(t *testing.T) {
97+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
98+
99+ // Create a file first
100+ filePath := filepath.Join(tempDir, "existing_file.txt")
101+ initialContent := "Initial content"
102+ err := os.WriteFile(filePath, []byte(initialContent), 0o644)
103+ require.NoError(t, err)
104+
105+ // Try to create the same file
106+ params := EditParams{
107+ FilePath: filePath,
108+ OldString: "",
109+ NewString: "New content",
110+ }
111+
112+ paramsJSON, err := json.Marshal(params)
113+ require.NoError(t, err)
114+
115+ call := ToolCall{
116+ Name: EditToolName,
117+ Input: string(paramsJSON),
118+ }
119+
120+ response, err := tool.Run(context.Background(), call)
121+ require.NoError(t, err)
122+ assert.Contains(t, response.Content, "file already exists")
123+ })
124+
125+ t.Run("fails to create file when path is a directory", func(t *testing.T) {
126+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
127+
128+ // Create a directory
129+ dirPath := filepath.Join(tempDir, "test_dir")
130+ err := os.Mkdir(dirPath, 0o755)
131+ require.NoError(t, err)
132+
133+ // Try to create a file with the same path as the directory
134+ params := EditParams{
135+ FilePath: dirPath,
136+ OldString: "",
137+ NewString: "Some content",
138+ }
139+
140+ paramsJSON, err := json.Marshal(params)
141+ require.NoError(t, err)
142+
143+ call := ToolCall{
144+ Name: EditToolName,
145+ Input: string(paramsJSON),
146+ }
147+
148+ response, err := tool.Run(context.Background(), call)
149+ require.NoError(t, err)
150+ assert.Contains(t, response.Content, "path is a directory")
151+ })
152+
153+ t.Run("replaces content successfully", func(t *testing.T) {
154+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
155+
156+ // Create a file first
157+ filePath := filepath.Join(tempDir, "replace_content.txt")
158+ initialContent := "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
159+ err := os.WriteFile(filePath, []byte(initialContent), 0o644)
160+ require.NoError(t, err)
161+
162+ // Record the file read to avoid modification time check failure
163+ recordFileRead(filePath)
164+
165+ // Replace content
166+ oldString := "Line 2\nLine 3"
167+ newString := "Line 2 modified\nLine 3 modified"
168+ params := EditParams{
169+ FilePath: filePath,
170+ OldString: oldString,
171+ NewString: newString,
172+ }
173+
174+ paramsJSON, err := json.Marshal(params)
175+ require.NoError(t, err)
176+
177+ call := ToolCall{
178+ Name: EditToolName,
179+ Input: string(paramsJSON),
180+ }
181+
182+ response, err := tool.Run(context.Background(), call)
183+ require.NoError(t, err)
184+ assert.Contains(t, response.Content, "Content replaced")
185+
186+ // Verify file was updated with correct content
187+ expectedContent := "Line 1\nLine 2 modified\nLine 3 modified\nLine 4\nLine 5"
188+ fileContent, err := os.ReadFile(filePath)
189+ require.NoError(t, err)
190+ assert.Equal(t, expectedContent, string(fileContent))
191+ })
192+
193+ t.Run("deletes content successfully", func(t *testing.T) {
194+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
195+
196+ // Create a file first
197+ filePath := filepath.Join(tempDir, "delete_content.txt")
198+ initialContent := "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
199+ err := os.WriteFile(filePath, []byte(initialContent), 0o644)
200+ require.NoError(t, err)
201+
202+ // Record the file read to avoid modification time check failure
203+ recordFileRead(filePath)
204+
205+ // Delete content
206+ oldString := "Line 2\nLine 3\n"
207+ params := EditParams{
208+ FilePath: filePath,
209+ OldString: oldString,
210+ NewString: "",
211+ }
212+
213+ paramsJSON, err := json.Marshal(params)
214+ require.NoError(t, err)
215+
216+ call := ToolCall{
217+ Name: EditToolName,
218+ Input: string(paramsJSON),
219+ }
220+
221+ response, err := tool.Run(context.Background(), call)
222+ require.NoError(t, err)
223+ assert.Contains(t, response.Content, "Content deleted")
224+
225+ // Verify file was updated with correct content
226+ expectedContent := "Line 1\nLine 4\nLine 5"
227+ fileContent, err := os.ReadFile(filePath)
228+ require.NoError(t, err)
229+ assert.Equal(t, expectedContent, string(fileContent))
230+ })
231+
232+ t.Run("handles invalid parameters", func(t *testing.T) {
233+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
234+
235+ call := ToolCall{
236+ Name: EditToolName,
237+ Input: "invalid json",
238+ }
239+
240+ response, err := tool.Run(context.Background(), call)
241+ require.NoError(t, err)
242+ assert.Contains(t, response.Content, "invalid parameters")
243+ })
244+
245+ t.Run("handles missing file_path", func(t *testing.T) {
246+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
247+
248+ params := EditParams{
249+ FilePath: "",
250+ OldString: "old",
251+ NewString: "new",
252+ }
253+
254+ paramsJSON, err := json.Marshal(params)
255+ require.NoError(t, err)
256+
257+ call := ToolCall{
258+ Name: EditToolName,
259+ Input: string(paramsJSON),
260+ }
261+
262+ response, err := tool.Run(context.Background(), call)
263+ require.NoError(t, err)
264+ assert.Contains(t, response.Content, "file_path is required")
265+ })
266+
267+ t.Run("handles file not found", func(t *testing.T) {
268+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
269+
270+ filePath := filepath.Join(tempDir, "non_existent_file.txt")
271+ params := EditParams{
272+ FilePath: filePath,
273+ OldString: "old content",
274+ NewString: "new content",
275+ }
276+
277+ paramsJSON, err := json.Marshal(params)
278+ require.NoError(t, err)
279+
280+ call := ToolCall{
281+ Name: EditToolName,
282+ Input: string(paramsJSON),
283+ }
284+
285+ response, err := tool.Run(context.Background(), call)
286+ require.NoError(t, err)
287+ assert.Contains(t, response.Content, "file not found")
288+ })
289+
290+ t.Run("handles old_string not found in file", func(t *testing.T) {
291+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
292+
293+ // Create a file first
294+ filePath := filepath.Join(tempDir, "content_not_found.txt")
295+ initialContent := "Line 1\nLine 2\nLine 3"
296+ err := os.WriteFile(filePath, []byte(initialContent), 0o644)
297+ require.NoError(t, err)
298+
299+ // Record the file read to avoid modification time check failure
300+ recordFileRead(filePath)
301+
302+ // Try to replace content that doesn't exist
303+ params := EditParams{
304+ FilePath: filePath,
305+ OldString: "This content does not exist",
306+ NewString: "new content",
307+ }
308+
309+ paramsJSON, err := json.Marshal(params)
310+ require.NoError(t, err)
311+
312+ call := ToolCall{
313+ Name: EditToolName,
314+ Input: string(paramsJSON),
315+ }
316+
317+ response, err := tool.Run(context.Background(), call)
318+ require.NoError(t, err)
319+ assert.Contains(t, response.Content, "old_string not found in file")
320+ })
321+
322+ t.Run("handles multiple occurrences of old_string", func(t *testing.T) {
323+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
324+
325+ // Create a file with duplicate content
326+ filePath := filepath.Join(tempDir, "duplicate_content.txt")
327+ initialContent := "Line 1\nDuplicate\nLine 3\nDuplicate\nLine 5"
328+ err := os.WriteFile(filePath, []byte(initialContent), 0o644)
329+ require.NoError(t, err)
330+
331+ // Record the file read to avoid modification time check failure
332+ recordFileRead(filePath)
333+
334+ // Try to replace content that appears multiple times
335+ params := EditParams{
336+ FilePath: filePath,
337+ OldString: "Duplicate",
338+ NewString: "Replaced",
339+ }
340+
341+ paramsJSON, err := json.Marshal(params)
342+ require.NoError(t, err)
343+
344+ call := ToolCall{
345+ Name: EditToolName,
346+ Input: string(paramsJSON),
347+ }
348+
349+ response, err := tool.Run(context.Background(), call)
350+ require.NoError(t, err)
351+ assert.Contains(t, response.Content, "appears multiple times")
352+ })
353+
354+ t.Run("handles file modified since last read", func(t *testing.T) {
355+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
356+
357+ // Create a file
358+ filePath := filepath.Join(tempDir, "modified_file.txt")
359+ initialContent := "Initial content"
360+ err := os.WriteFile(filePath, []byte(initialContent), 0o644)
361+ require.NoError(t, err)
362+
363+ // Record an old read time
364+ fileRecordMutex.Lock()
365+ fileRecords[filePath] = fileRecord{
366+ path: filePath,
367+ readTime: time.Now().Add(-1 * time.Hour),
368+ }
369+ fileRecordMutex.Unlock()
370+
371+ // Try to update the file
372+ params := EditParams{
373+ FilePath: filePath,
374+ OldString: "Initial",
375+ NewString: "Updated",
376+ }
377+
378+ paramsJSON, err := json.Marshal(params)
379+ require.NoError(t, err)
380+
381+ call := ToolCall{
382+ Name: EditToolName,
383+ Input: string(paramsJSON),
384+ }
385+
386+ response, err := tool.Run(context.Background(), call)
387+ require.NoError(t, err)
388+ assert.Contains(t, response.Content, "has been modified since it was last read")
389+
390+ // Verify file was not modified
391+ fileContent, err := os.ReadFile(filePath)
392+ require.NoError(t, err)
393+ assert.Equal(t, initialContent, string(fileContent))
394+ })
395+
396+ t.Run("handles file not read before editing", func(t *testing.T) {
397+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
398+
399+ // Create a file
400+ filePath := filepath.Join(tempDir, "not_read_file.txt")
401+ initialContent := "Initial content"
402+ err := os.WriteFile(filePath, []byte(initialContent), 0o644)
403+ require.NoError(t, err)
404+
405+ // Try to update the file without reading it first
406+ params := EditParams{
407+ FilePath: filePath,
408+ OldString: "Initial",
409+ NewString: "Updated",
410+ }
411+
412+ paramsJSON, err := json.Marshal(params)
413+ require.NoError(t, err)
414+
415+ call := ToolCall{
416+ Name: EditToolName,
417+ Input: string(paramsJSON),
418+ }
419+
420+ response, err := tool.Run(context.Background(), call)
421+ require.NoError(t, err)
422+ assert.Contains(t, response.Content, "you must read the file before editing it")
423+ })
424+
425+ t.Run("handles permission denied", func(t *testing.T) {
426+ tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(false))
427+
428+ // Create a file
429+ filePath := filepath.Join(tempDir, "permission_denied.txt")
430+ initialContent := "Initial content"
431+ err := os.WriteFile(filePath, []byte(initialContent), 0o644)
432+ require.NoError(t, err)
433+
434+ // Record the file read to avoid modification time check failure
435+ recordFileRead(filePath)
436+
437+ // Try to update the file
438+ params := EditParams{
439+ FilePath: filePath,
440+ OldString: "Initial",
441+ NewString: "Updated",
442+ }
443+
444+ paramsJSON, err := json.Marshal(params)
445+ require.NoError(t, err)
446+
447+ call := ToolCall{
448+ Name: EditToolName,
449+ Input: string(paramsJSON),
450+ }
451+
452+ response, err := tool.Run(context.Background(), call)
453+ require.NoError(t, err)
454+ assert.Contains(t, response.Content, "permission denied")
455+
456+ // Verify file was not modified
457+ fileContent, err := os.ReadFile(filePath)
458+ require.NoError(t, err)
459+ assert.Equal(t, initialContent, string(fileContent))
460+ })
461+}
462+
463+func TestGenerateDiff(t *testing.T) {
464+ testCases := []struct {
465+ name string
466+ oldContent string
467+ newContent string
468+ expectedDiff string
469+ }{
470+ {
471+ name: "add content",
472+ oldContent: "Line 1\nLine 2\n",
473+ newContent: "Line 1\nLine 2\nLine 3\n",
474+ expectedDiff: "Changes:\n Line 1\n Line 2\n+ Line 3\n",
475+ },
476+ {
477+ name: "remove content",
478+ oldContent: "Line 1\nLine 2\nLine 3\n",
479+ newContent: "Line 1\nLine 3\n",
480+ expectedDiff: "Changes:\n Line 1\n- Line 2\n Line 3\n",
481+ },
482+ {
483+ name: "replace content",
484+ oldContent: "Line 1\nLine 2\nLine 3\n",
485+ newContent: "Line 1\nModified Line\nLine 3\n",
486+ expectedDiff: "Changes:\n Line 1\n- Line 2\n+ Modified Line\n Line 3\n",
487+ },
488+ {
489+ name: "empty to content",
490+ oldContent: "",
491+ newContent: "Line 1\nLine 2\n",
492+ expectedDiff: "Changes:\n+ Line 1\n+ Line 2\n",
493+ },
494+ {
495+ name: "content to empty",
496+ oldContent: "Line 1\nLine 2\n",
497+ newContent: "",
498+ expectedDiff: "Changes:\n- Line 1\n- Line 2\n",
499+ },
500+ }
501+
502+ for _, tc := range testCases {
503+ t.Run(tc.name, func(t *testing.T) {
504+ diff := GenerateDiff(tc.oldContent, tc.newContent)
505+ assert.Contains(t, diff, tc.expectedDiff)
506+ })
507+ }
508+}
509+
internal/llm/tools/fetch.go+20−19
@@ -15,6 +15,23 @@ import (
15 "github.com/kujtimiihoxha/termai/internal/permission"
15 "github.com/kujtimiihoxha/termai/internal/permission"
16)
16)
17
17
18+type FetchParams struct {
19+ URL string `json:"url"`
20+ Format string `json:"format"`
21+ Timeout int `json:"timeout,omitempty"`
22+}
23+
24+type FetchPermissionsParams struct {
25+ URL string `json:"url"`
26+ Format string `json:"format"`
27+ Timeout int `json:"timeout,omitempty"`
28+}
29+
30+type fetchTool struct {
31+ client *http.Client
32+ permissions permission.Service
33+}
34+
18const (
35const (
19 FetchToolName = "fetch"
36 FetchToolName = "fetch"
20 fetchToolDescription = `Fetches content from a URL and returns it in the specified format.
37 fetchToolDescription = `Fetches content from a URL and returns it in the specified format.
@@ -48,27 +65,12 @@ TIPS:
48- Set appropriate timeouts for potentially slow websites`
65- Set appropriate timeouts for potentially slow websites`
49)
66)
50
67
51−type FetchParams struct {
52− URL string `json:"url"`
53− Format string `json:"format"`
54− Timeout int `json:"timeout,omitempty"`
55−}
56−
57−type FetchPermissionsParams struct {
58− URL string `json:"url"`
59− Format string `json:"format"`
60− Timeout int `json:"timeout,omitempty"`
61−}
62−
63−type fetchTool struct {
64− client *http.Client
65−}
66−
67−func NewFetchTool() BaseTool {
68+func NewFetchTool(permissions permission.Service) BaseTool {
68 return &fetchTool{
69 return &fetchTool{
69 client: &http.Client{
70 client: &http.Client{
70 Timeout: 30 * time.Second,
71 Timeout: 30 * time.Second,
71 },
72 },
73+ permissions: permissions,
72 }
74 }
73}
75}
74
76
@@ -113,7 +115,7 @@ func (t *fetchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
113 return NewTextErrorResponse("URL must start with http:// or https://"), nil
115 return NewTextErrorResponse("URL must start with http:// or https://"), nil
114 }
116 }
115
117
116− p := permission.Default.Request(
118+ p := t.permissions.Request(
117 permission.CreatePermissionRequest{
119 permission.CreatePermissionRequest{
118 Path: config.WorkingDirectory(),
120 Path: config.WorkingDirectory(),
119 ToolName: FetchToolName,
121 ToolName: FetchToolName,
@@ -220,4 +222,3 @@ func convertHTMLToMarkdown(html string) (string, error) {
220
222
221 return markdown, nil
223 return markdown, nil
222}
224}
223−
internal/llm/tools/glob.go+42−62
@@ -15,10 +15,42 @@ import (
15 "github.com/kujtimiihoxha/termai/internal/config"
15 "github.com/kujtimiihoxha/termai/internal/config"
16)
16)
17
17
18−type globTool struct{}
19−
20const (
18const (
21− GlobToolName = "glob"
19+ GlobToolName = "glob"
20+ globDescription = `Fast file pattern matching tool that finds files by name and pattern, returning matching paths sorted by modification time (newest first).
21+
22+WHEN TO USE THIS TOOL:
23+- Use when you need to find files by name patterns or extensions
24+- Great for finding specific file types across a directory structure
25+- Useful for discovering files that match certain naming conventions
26+
27+HOW TO USE:
28+- Provide a glob pattern to match against file paths
29+- Optionally specify a starting directory (defaults to current working directory)
30+- Results are sorted with most recently modified files first
31+
32+GLOB PATTERN SYNTAX:
33+- '*' matches any sequence of non-separator characters
34+- '**' matches any sequence of characters, including separators
35+- '?' matches any single non-separator character
36+- '[...]' matches any character in the brackets
37+- '[!...]' matches any character not in the brackets
38+
39+COMMON PATTERN EXAMPLES:
40+- '*.js' - Find all JavaScript files in the current directory
41+- '**/*.js' - Find all JavaScript files in any subdirectory
42+- 'src/**/*.{ts,tsx}' - Find all TypeScript files in the src directory
43+- '*.{html,css,js}' - Find all HTML, CSS, and JS files
44+
45+LIMITATIONS:
46+- Results are limited to 100 files (newest first)
47+- Does not search file contents (use Grep tool for that)
48+- Hidden files (starting with '.') are skipped
49+
50+TIPS:
51+- For the most useful results, combine with the Grep tool: first find files with Glob, then search their contents with Grep
52+- When doing iterative exploration that may require multiple rounds of searching, consider using the Agent tool instead
53+- Always check if results are truncated and refine your search pattern if needed`
22)
54)
23
55
24type fileInfo struct {
56type fileInfo struct {
@@ -31,10 +63,16 @@ type GlobParams struct {
31 Path string `json:"path"`
63 Path string `json:"path"`
32}
64}
33
65
66+type globTool struct{}
67+
68+func NewGlobTool() BaseTool {
69+ return &globTool{}
70+}
71+
34func (g *globTool) Info() ToolInfo {
72func (g *globTool) Info() ToolInfo {
35 return ToolInfo{
73 return ToolInfo{
36 Name: GlobToolName,
74 Name: GlobToolName,
37− Description: globDescription(),
75+ Description: globDescription,
38 Parameters: map[string]any{
76 Parameters: map[string]any{
39 "pattern": map[string]any{
77 "pattern": map[string]any{
40 "type": "string",
78 "type": "string",
@@ -49,7 +87,6 @@ func (g *globTool) Info() ToolInfo {
49 }
87 }
50}
88}
51
89
52−// Run implements Tool.
53func (g *globTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
90func (g *globTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
54 var params GlobParams
91 var params GlobParams
55 if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
92 if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
@@ -60,7 +97,6 @@ func (g *globTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
60 return NewTextErrorResponse("pattern is required"), nil
97 return NewTextErrorResponse("pattern is required"), nil
61 }
98 }
62
99
63− // If path is empty, use current working directory
64 searchPath := params.Path
100 searchPath := params.Path
65 if searchPath == "" {
101 if searchPath == "" {
66 searchPath = config.WorkingDirectory()
102 searchPath = config.WorkingDirectory()
@@ -71,7 +107,6 @@ func (g *globTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
71 return NewTextErrorResponse(fmt.Sprintf("error performing glob search: %s", err)), nil
107 return NewTextErrorResponse(fmt.Sprintf("error performing glob search: %s", err)), nil
72 }
108 }
73
109
74− // Format the output for the assistant
75 var output string
110 var output string
76 if len(files) == 0 {
111 if len(files) == 0 {
77 output = "No files found"
112 output = "No files found"
@@ -86,28 +121,20 @@ func (g *globTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
86}
121}
87
122
88func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) {
123func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) {
89− // Make sure pattern starts with the search path if not absolute
90 if !strings.HasPrefix(pattern, "/") && !strings.HasPrefix(pattern, searchPath) {
124 if !strings.HasPrefix(pattern, "/") && !strings.HasPrefix(pattern, searchPath) {
91− // If searchPath doesn't end with a slash, add one before appending the pattern
92 if !strings.HasSuffix(searchPath, "/") {
125 if !strings.HasSuffix(searchPath, "/") {
93 searchPath += "/"
126 searchPath += "/"
94 }
127 }
95 pattern = searchPath + pattern
128 pattern = searchPath + pattern
96 }
129 }
97
130
98− // Open the filesystem for walking
99 fsys := os.DirFS("/")
131 fsys := os.DirFS("/")
100
132
101− // Convert the absolute pattern to a relative one for the DirFS
102− // DirFS uses the root directory ("/") so we should strip leading "/"
103 relPattern := strings.TrimPrefix(pattern, "/")
133 relPattern := strings.TrimPrefix(pattern, "/")
104
134
105− // Collect matching files
106 var matches []fileInfo
135 var matches []fileInfo
107
136
108− // Use doublestar to walk the filesystem and find matches
109 err := doublestar.GlobWalk(fsys, relPattern, func(path string, d fs.DirEntry) error {
137 err := doublestar.GlobWalk(fsys, relPattern, func(path string, d fs.DirEntry) error {
110− // Skip directories from results
111 if d.IsDir() {
138 if d.IsDir() {
112 return nil
139 return nil
113 }
140 }
@@ -115,20 +142,17 @@ func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) {
115 return nil
142 return nil
116 }
143 }
117
144
118− // Get file info for modification time
119 info, err := d.Info()
145 info, err := d.Info()
120 if err != nil {
146 if err != nil {
121 return nil // Skip files we can't access
147 return nil // Skip files we can't access
122 }
148 }
123
149
124− // Add to matches
125 absPath := "/" + path // Restore absolute path
150 absPath := "/" + path // Restore absolute path
126 matches = append(matches, fileInfo{
151 matches = append(matches, fileInfo{
127 path: absPath,
152 path: absPath,
128 modTime: info.ModTime(),
153 modTime: info.ModTime(),
129 })
154 })
130
155
131− // Check limit
132 if len(matches) >= limit*2 { // Collect more than needed for sorting
156 if len(matches) >= limit*2 { // Collect more than needed for sorting
133 return fs.SkipAll
157 return fs.SkipAll
134 }
158 }
@@ -139,18 +163,15 @@ func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) {
139 return nil, false, fmt.Errorf("glob walk error: %w", err)
163 return nil, false, fmt.Errorf("glob walk error: %w", err)
140 }
164 }
141
165
142− // Sort files by modification time (newest first)
143 sort.Slice(matches, func(i, j int) bool {
166 sort.Slice(matches, func(i, j int) bool {
144 return matches[i].modTime.After(matches[j].modTime)
167 return matches[i].modTime.After(matches[j].modTime)
145 })
168 })
146
169
147− // Check if we need to truncate the results
148 truncated := len(matches) > limit
170 truncated := len(matches) > limit
149 if truncated {
171 if truncated {
150 matches = matches[:limit]
172 matches = matches[:limit]
151 }
173 }
152
174
153− // Extract just the paths
154 results := make([]string, len(matches))
175 results := make([]string, len(matches))
155 for i, m := range matches {
176 for i, m := range matches {
156 results[i] = m.path
177 results[i] = m.path
@@ -163,44 +184,3 @@ func skipHidden(path string) bool {
163 base := filepath.Base(path)
184 base := filepath.Base(path)
164 return base != "." && strings.HasPrefix(base, ".")
185 return base != "." && strings.HasPrefix(base, ".")
165}
186}
166−
167−func globDescription() string {
168− return `Fast file pattern matching tool that finds files by name and pattern, returning matching paths sorted by modification time (newest first).
169−
170−WHEN TO USE THIS TOOL:
171−- Use when you need to find files by name patterns or extensions
172−- Great for finding specific file types across a directory structure
173−- Useful for discovering files that match certain naming conventions
174−
175−HOW TO USE:
176−- Provide a glob pattern to match against file paths
177−- Optionally specify a starting directory (defaults to current working directory)
178−- Results are sorted with most recently modified files first
179−
180−GLOB PATTERN SYNTAX:
181−- '*' matches any sequence of non-separator characters
182−- '**' matches any sequence of characters, including separators
183−- '?' matches any single non-separator character
184−- '[...]' matches any character in the brackets
185−- '[!...]' matches any character not in the brackets
186−
187−COMMON PATTERN EXAMPLES:
188−- '*.js' - Find all JavaScript files in the current directory
189−- '**/*.js' - Find all JavaScript files in any subdirectory
190−- 'src/**/*.{ts,tsx}' - Find all TypeScript files in the src directory
191−- '*.{html,css,js}' - Find all HTML, CSS, and JS files
192−
193−LIMITATIONS:
194−- Results are limited to 100 files (newest first)
195−- Does not search file contents (use Grep tool for that)
196−- Hidden files (starting with '.') are skipped
197−
198−TIPS:
199−- For the most useful results, combine with the Grep tool: first find files with Glob, then search their contents with Grep
200−- When doing iterative exploration that may require multiple rounds of searching, consider using the Agent tool instead
201−- Always check if results are truncated and refine your search pattern if needed`
202−}
203−
204−func NewGlobTool() BaseTool {
205− return &globTool{}
206−}
internal/llm/tools/grep.go+45−61
@@ -16,12 +16,6 @@ import (
16 "github.com/kujtimiihoxha/termai/internal/config"
16 "github.com/kujtimiihoxha/termai/internal/config"
17)
17)
18
18
19−type grepTool struct{}
20−
21−const (
22− GrepToolName = "grep"
23−)
24−
25type GrepParams struct {
19type GrepParams struct {
26 Pattern string `json:"pattern"`
20 Pattern string `json:"pattern"`
27 Path string `json:"path"`
21 Path string `json:"path"`
@@ -33,10 +27,54 @@ type grepMatch struct {
33 modTime time.Time
27 modTime time.Time
34}
28}
35
29
30+type grepTool struct{}
31+
32+const (
33+ GrepToolName = "grep"
34+ grepDescription = `Fast content search tool that finds files containing specific text or patterns, returning matching file paths sorted by modification time (newest first).
35+
36+WHEN TO USE THIS TOOL:
37+- Use when you need to find files containing specific text or patterns
38+- Great for searching code bases for function names, variable declarations, or error messages
39+- Useful for finding all files that use a particular API or pattern
40+
41+HOW TO USE:
42+- Provide a regex pattern to search for within file contents
43+- Optionally specify a starting directory (defaults to current working directory)
44+- Optionally provide an include pattern to filter which files to search
45+- Results are sorted with most recently modified files first
46+
47+REGEX PATTERN SYNTAX:
48+- Supports standard regular expression syntax
49+- 'function' searches for the literal text "function"
50+- 'log\..*Error' finds text starting with "log." and ending with "Error"
51+- 'import\s+.*\s+from' finds import statements in JavaScript/TypeScript
52+
53+COMMON INCLUDE PATTERN EXAMPLES:
54+- '*.js' - Only search JavaScript files
55+- '*.{ts,tsx}' - Only search TypeScript files
56+- '*.go' - Only search Go files
57+
58+LIMITATIONS:
59+- Results are limited to 100 files (newest first)
60+- Performance depends on the number of files being searched
61+- Very large binary files may be skipped
62+- Hidden files (starting with '.') are skipped
63+
64+TIPS:
65+- For faster, more targeted searches, first use Glob to find relevant files, then use Grep
66+- When doing iterative exploration that may require multiple rounds of searching, consider using the Agent tool instead
67+- Always check if results are truncated and refine your search pattern if needed`
68+)
69+
70+func NewGrepTool() BaseTool {
71+ return &grepTool{}
72+}
73+
36func (g *grepTool) Info() ToolInfo {
74func (g *grepTool) Info() ToolInfo {
37 return ToolInfo{
75 return ToolInfo{
38 Name: GrepToolName,
76 Name: GrepToolName,
39− Description: grepDescription(),
77+ Description: grepDescription,
40 Parameters: map[string]any{
78 Parameters: map[string]any{
41 "pattern": map[string]any{
79 "pattern": map[string]any{
42 "type": "string",
80 "type": "string",
@@ -55,7 +93,6 @@ func (g *grepTool) Info() ToolInfo {
55 }
93 }
56}
94}
57
95
58−// Run implements Tool.
59func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
96func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
60 var params GrepParams
97 var params GrepParams
61 if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
98 if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
@@ -66,7 +103,6 @@ func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
66 return NewTextErrorResponse("pattern is required"), nil
103 return NewTextErrorResponse("pattern is required"), nil
67 }
104 }
68
105
69− // If path is empty, use current working directory
70 searchPath := params.Path
106 searchPath := params.Path
71 if searchPath == "" {
107 if searchPath == "" {
72 searchPath = config.WorkingDirectory()
108 searchPath = config.WorkingDirectory()
@@ -77,7 +113,6 @@ func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
77 return NewTextErrorResponse(fmt.Sprintf("error searching files: %s", err)), nil
113 return NewTextErrorResponse(fmt.Sprintf("error searching files: %s", err)), nil
78 }
114 }
79
115
80− // Format the output for the assistant
81 var output string
116 var output string
82 if len(matches) == 0 {
117 if len(matches) == 0 {
83 output = "No files found"
118 output = "No files found"
@@ -103,28 +138,23 @@ func pluralize(count int) string {
103}
138}
104
139
105func searchFiles(pattern, rootPath, include string, limit int) ([]string, bool, error) {
140func searchFiles(pattern, rootPath, include string, limit int) ([]string, bool, error) {
106− // First try using ripgrep if available for better performance
107 matches, err := searchWithRipgrep(pattern, rootPath, include)
141 matches, err := searchWithRipgrep(pattern, rootPath, include)
108 if err != nil {
142 if err != nil {
109− // Fall back to manual regex search if ripgrep is not available
110 matches, err = searchFilesWithRegex(pattern, rootPath, include)
143 matches, err = searchFilesWithRegex(pattern, rootPath, include)
111 if err != nil {
144 if err != nil {
112 return nil, false, err
145 return nil, false, err
113 }
146 }
114 }
147 }
115
148
116− // Sort files by modification time (newest first)
117 sort.Slice(matches, func(i, j int) bool {
149 sort.Slice(matches, func(i, j int) bool {
118 return matches[i].modTime.After(matches[j].modTime)
150 return matches[i].modTime.After(matches[j].modTime)
119 })
151 })
120
152
121− // Check if we need to truncate the results
122 truncated := len(matches) > limit
153 truncated := len(matches) > limit
123 if truncated {
154 if truncated {
124 matches = matches[:limit]
155 matches = matches[:limit]
125 }
156 }
126
157
127− // Extract just the paths
128 results := make([]string, len(matches))
158 results := make([]string, len(matches))
129 for i, m := range matches {
159 for i, m := range matches {
130 results[i] = m.path
160 results[i] = m.path
@@ -149,7 +179,6 @@ func searchWithRipgrep(pattern, path, include string) ([]grepMatch, error) {
149 output, err := cmd.Output()
179 output, err := cmd.Output()
150 if err != nil {
180 if err != nil {
151 if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
181 if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
152− // Exit code 1 means no matches, which isn't an error for our purposes
153 return []grepMatch{}, nil
182 return []grepMatch{}, nil
154 }
183 }
155 return nil, err
184 return nil, err
@@ -203,17 +232,14 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
203 return nil // Skip directories
232 return nil // Skip directories
204 }
233 }
205
234
206− // Skip hidden files
207 if skipHidden(path) {
235 if skipHidden(path) {
208 return nil
236 return nil
209 }
237 }
210
238
211− // Check include pattern if provided
212 if includePattern != nil && !includePattern.MatchString(path) {
239 if includePattern != nil && !includePattern.MatchString(path) {
213 return nil
240 return nil
214 }
241 }
215
242
216− // Check file contents for the pattern
217 match, err := fileContainsPattern(path, regex)
243 match, err := fileContainsPattern(path, regex)
218 if err != nil {
244 if err != nil {
219 return nil // Skip files we can't read
245 return nil // Skip files we can't read
@@ -225,7 +251,6 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
225 modTime: info.ModTime(),
251 modTime: info.ModTime(),
226 })
252 })
227
253
228− // Check if we've hit the limit (collect double for sorting)
229 if len(matches) >= 200 {
254 if len(matches) >= 200 {
230 return filepath.SkipAll
255 return filepath.SkipAll
231 }
256 }
@@ -270,44 +295,3 @@ func globToRegex(glob string) string {
270
295
271 return regexPattern
296 return regexPattern
272}
297}
273−
274−func grepDescription() string {
275− return `Fast content search tool that finds files containing specific text or patterns, returning matching file paths sorted by modification time (newest first).
276−
277−WHEN TO USE THIS TOOL:
278−- Use when you need to find files containing specific text or patterns
279−- Great for searching code bases for function names, variable declarations, or error messages
280−- Useful for finding all files that use a particular API or pattern
281−
282−HOW TO USE:
283−- Provide a regex pattern to search for within file contents
284−- Optionally specify a starting directory (defaults to current working directory)
285−- Optionally provide an include pattern to filter which files to search
286−- Results are sorted with most recently modified files first
287−
288−REGEX PATTERN SYNTAX:
289−- Supports standard regular expression syntax
290−- 'function' searches for the literal text "function"
291−- 'log\..*Error' finds text starting with "log." and ending with "Error"
292−- 'import\s+.*\s+from' finds import statements in JavaScript/TypeScript
293−
294−COMMON INCLUDE PATTERN EXAMPLES:
295−- '*.js' - Only search JavaScript files
296−- '*.{ts,tsx}' - Only search TypeScript files
297−- '*.go' - Only search Go files
298−
299−LIMITATIONS:
300−- Results are limited to 100 files (newest first)
301−- Performance depends on the number of files being searched
302−- Very large binary files may be skipped
303−- Hidden files (starting with '.') are skipped
304−
305−TIPS:
306−- For faster, more targeted searches, first use Glob to find relevant files, then use Grep
307−- When doing iterative exploration that may require multiple rounds of searching, consider using the Agent tool instead
308−- Always check if results are truncated and refine your search pattern if needed`
309−}
310−
311−func NewGrepTool() BaseTool {
312− return &grepTool{}
313−}
internal/llm/tools/ls.go+40−55
@@ -11,13 +11,6 @@ import (
11 "github.com/kujtimiihoxha/termai/internal/config"
11 "github.com/kujtimiihoxha/termai/internal/config"
12)
12)
13
13
14−type lsTool struct{}
15−
16−const (
17− LSToolName = "ls"
18− MaxLSFiles = 1000
19−)
20−
21type LSParams struct {
14type LSParams struct {
22 Path string `json:"path"`
15 Path string `json:"path"`
23 Ignore []string `json:"ignore"`
16 Ignore []string `json:"ignore"`
@@ -30,10 +23,49 @@ type TreeNode struct {
30 Children []*TreeNode `json:"children,omitempty"`
23 Children []*TreeNode `json:"children,omitempty"`
31}
24}
32
25
26+type lsTool struct{}
27+
28+const (
29+ LSToolName = "ls"
30+ MaxLSFiles = 1000
31+ lsDescription = `Directory listing tool that shows files and subdirectories in a tree structure, helping you explore and understand the project organization.
32+
33+WHEN TO USE THIS TOOL:
34+- Use when you need to explore the structure of a directory
35+- Helpful for understanding the organization of a project
36+- Good first step when getting familiar with a new codebase
37+
38+HOW TO USE:
39+- Provide a path to list (defaults to current working directory)
40+- Optionally specify glob patterns to ignore
41+- Results are displayed in a tree structure
42+
43+FEATURES:
44+- Displays a hierarchical view of files and directories
45+- Automatically skips hidden files/directories (starting with '.')
46+- Skips common system directories like __pycache__
47+- Can filter out files matching specific patterns
48+
49+LIMITATIONS:
50+- Results are limited to 1000 files
51+- Very large directories will be truncated
52+- Does not show file sizes or permissions
53+- Cannot recursively list all directories in a large project
54+
55+TIPS:
56+- Use Glob tool for finding files by name patterns instead of browsing
57+- Use Grep tool for searching file contents
58+- Combine with other tools for more effective exploration`
59+)
60+
61+func NewLsTool() BaseTool {
62+ return &lsTool{}
63+}
64+
33func (l *lsTool) Info() ToolInfo {
65func (l *lsTool) Info() ToolInfo {
34 return ToolInfo{
66 return ToolInfo{
35 Name: LSToolName,
67 Name: LSToolName,
36− Description: lsDescription(),
68+ Description: lsDescription,
37 Parameters: map[string]any{
69 Parameters: map[string]any{
38 "path": map[string]any{
70 "path": map[string]any{
39 "type": "string",
71 "type": "string",
@@ -51,25 +83,21 @@ func (l *lsTool) Info() ToolInfo {
51 }
83 }
52}
84}
53
85
54−// Run implements Tool.
55func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
86func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
56 var params LSParams
87 var params LSParams
57 if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
88 if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
58 return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
89 return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
59 }
90 }
60
91
61− // If path is empty, use current working directory
62 searchPath := params.Path
92 searchPath := params.Path
63 if searchPath == "" {
93 if searchPath == "" {
64 searchPath = config.WorkingDirectory()
94 searchPath = config.WorkingDirectory()
65 }
95 }
66
96
67− // Ensure the path is absolute
68 if !filepath.IsAbs(searchPath) {
97 if !filepath.IsAbs(searchPath) {
69 searchPath = filepath.Join(config.WorkingDirectory(), searchPath)
98 searchPath = filepath.Join(config.WorkingDirectory(), searchPath)
70 }
99 }
71
100
72− // Check if the path exists
73 if _, err := os.Stat(searchPath); os.IsNotExist(err) {
101 if _, err := os.Stat(searchPath); os.IsNotExist(err) {
74 return NewTextErrorResponse(fmt.Sprintf("path does not exist: %s", searchPath)), nil
102 return NewTextErrorResponse(fmt.Sprintf("path does not exist: %s", searchPath)), nil
75 }
103 }
@@ -129,12 +157,10 @@ func listDirectory(initialPath string, ignorePatterns []string, limit int) ([]st
129func shouldSkip(path string, ignorePatterns []string) bool {
157func shouldSkip(path string, ignorePatterns []string) bool {
130 base := filepath.Base(path)
158 base := filepath.Base(path)
131
159
132− // Skip hidden files and directories
133 if base != "." && strings.HasPrefix(base, ".") {
160 if base != "." && strings.HasPrefix(base, ".") {
134 return true
161 return true
135 }
162 }
136
163
137− // Skip common directories and files
138 commonIgnored := []string{
164 commonIgnored := []string{
139 "__pycache__",
165 "__pycache__",
140 "node_modules",
166 "node_modules",
@@ -156,32 +182,26 @@ func shouldSkip(path string, ignorePatterns []string) bool {
156 "*.exe",
182 "*.exe",
157 }
183 }
158
184
159− // Skip __pycache__ directories
160 if strings.Contains(path, filepath.Join("__pycache__", "")) {
185 if strings.Contains(path, filepath.Join("__pycache__", "")) {
161 return true
186 return true
162 }
187 }
163
188
164− // Check against common ignored patterns
165 for _, ignored := range commonIgnored {
189 for _, ignored := range commonIgnored {
166 if strings.HasSuffix(ignored, "/") {
190 if strings.HasSuffix(ignored, "/") {
167− // Directory pattern
168 if strings.Contains(path, filepath.Join(ignored[:len(ignored)-1], "")) {
191 if strings.Contains(path, filepath.Join(ignored[:len(ignored)-1], "")) {
169 return true
192 return true
170 }
193 }
171 } else if strings.HasPrefix(ignored, "*.") {
194 } else if strings.HasPrefix(ignored, "*.") {
172− // File extension pattern
173 if strings.HasSuffix(base, ignored[1:]) {
195 if strings.HasSuffix(base, ignored[1:]) {
174 return true
196 return true
175 }
197 }
176 } else {
198 } else {
177− // Exact match
178 if base == ignored {
199 if base == ignored {
179 return true
200 return true
180 }
201 }
181 }
202 }
182 }
203 }
183
204
184− // Check against ignore patterns
185 for _, pattern := range ignorePatterns {
205 for _, pattern := range ignorePatterns {
186 matched, err := filepath.Match(pattern, base)
206 matched, err := filepath.Match(pattern, base)
187 if err == nil && matched {
207 if err == nil && matched {
@@ -283,38 +303,3 @@ func printNode(builder *strings.Builder, node *TreeNode, level int) {
283 }
303 }
284 }
304 }
285}
305}
286−
287−func lsDescription() string {
288− return `Directory listing tool that shows files and subdirectories in a tree structure, helping you explore and understand the project organization.
289−
290−WHEN TO USE THIS TOOL:
291−- Use when you need to explore the structure of a directory
292−- Helpful for understanding the organization of a project
293−- Good first step when getting familiar with a new codebase
294−
295−HOW TO USE:
296−- Provide a path to list (defaults to current working directory)
297−- Optionally specify glob patterns to ignore
298−- Results are displayed in a tree structure
299−
300−FEATURES:
301−- Displays a hierarchical view of files and directories
302−- Automatically skips hidden files/directories (starting with '.')
303−- Skips common system directories like __pycache__
304−- Can filter out files matching specific patterns
305−
306−LIMITATIONS:
307−- Results are limited to 1000 files
308−- Very large directories will be truncated
309−- Does not show file sizes or permissions
310−- Cannot recursively list all directories in a large project
311−
312−TIPS:
313−- Use Glob tool for finding files by name patterns instead of browsing
314−- Use Grep tool for searching file contents
315−- Combine with other tools for more effective exploration`
316−}
317−
318−func NewLsTool() BaseTool {
319− return &lsTool{}
320−}
internal/llm/tools/sourcegraph.go+11−33
@@ -11,6 +11,17 @@ import (
11 "time"
11 "time"
12)
12)
13
13
14+type SourcegraphParams struct {
15+ Query string `json:"query"`
16+ Count int `json:"count,omitempty"`
17+ ContextWindow int `json:"context_window,omitempty"`
18+ Timeout int `json:"timeout,omitempty"`
19+}
20+
21+type sourcegraphTool struct {
22+ client *http.Client
23+}
24+
14const (
25const (
15 SourcegraphToolName = "sourcegraph"
26 SourcegraphToolName = "sourcegraph"
16 sourcegraphToolDescription = `Search code across public repositories using Sourcegraph's GraphQL API.
27 sourcegraphToolDescription = `Search code across public repositories using Sourcegraph's GraphQL API.
@@ -110,17 +121,6 @@ TIPS:
110- For more details on query syntax, visit: https://docs.sourcegraph.com/code_search/queries`
121- For more details on query syntax, visit: https://docs.sourcegraph.com/code_search/queries`
111)
122)
112
123
113−type SourcegraphParams struct {
114− Query string `json:"query"`
115− Count int `json:"count,omitempty"`
116− ContextWindow int `json:"context_window,omitempty"`
117− Timeout int `json:"timeout,omitempty"`
118−}
119−
120−type sourcegraphTool struct {
121− client *http.Client
122−}
123−
124func NewSourcegraphTool() BaseTool {
124func NewSourcegraphTool() BaseTool {
125 return &sourcegraphTool{
125 return &sourcegraphTool{
126 client: &http.Client{
126 client: &http.Client{
@@ -165,7 +165,6 @@ func (t *sourcegraphTool) Run(ctx context.Context, call ToolCall) (ToolResponse,
165 return NewTextErrorResponse("Query parameter is required"), nil
165 return NewTextErrorResponse("Query parameter is required"), nil
166 }
166 }
167
167
168− // Set default count if not specified
169 if params.Count <= 0 {
168 if params.Count <= 0 {
170 params.Count = 10
169 params.Count = 10
171 } else if params.Count > 20 {
170 } else if params.Count > 20 {
@@ -186,8 +185,6 @@ func (t *sourcegraphTool) Run(ctx context.Context, call ToolCall) (ToolResponse,
186 }
185 }
187 }
186 }
188
187
189− // GraphQL query for Sourcegraph search
190− // Create a properly escaped JSON structure
191 type graphqlRequest struct {
188 type graphqlRequest struct {
192 Query string `json:"query"`
189 Query string `json:"query"`
193 Variables struct {
190 Variables struct {
@@ -200,14 +197,12 @@ func (t *sourcegraphTool) Run(ctx context.Context, call ToolCall) (ToolResponse,
200 }
197 }
201 request.Variables.Query = params.Query
198 request.Variables.Query = params.Query
202
199
203− // Marshal to JSON to ensure proper escaping
204 graphqlQueryBytes, err := json.Marshal(request)
200 graphqlQueryBytes, err := json.Marshal(request)
205 if err != nil {
201 if err != nil {
206 return NewTextErrorResponse("Failed to create GraphQL request: " + err.Error()), nil
202 return NewTextErrorResponse("Failed to create GraphQL request: " + err.Error()), nil
207 }
203 }
208 graphqlQuery := string(graphqlQueryBytes)
204 graphqlQuery := string(graphqlQueryBytes)
209
205
210− // Create request to Sourcegraph API
211 req, err := http.NewRequestWithContext(
206 req, err := http.NewRequestWithContext(
212 ctx,
207 ctx,
213 "POST",
208 "POST",
@@ -228,7 +223,6 @@ func (t *sourcegraphTool) Run(ctx context.Context, call ToolCall) (ToolResponse,
228 defer resp.Body.Close()
223 defer resp.Body.Close()
229
224
230 if resp.StatusCode != http.StatusOK {
225 if resp.StatusCode != http.StatusOK {
231− // log the error response
232 body, _ := io.ReadAll(resp.Body)
226 body, _ := io.ReadAll(resp.Body)
233 if len(body) > 0 {
227 if len(body) > 0 {
234 return NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d, response: %s", resp.StatusCode, string(body))), nil
228 return NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d, response: %s", resp.StatusCode, string(body))), nil
@@ -241,13 +235,11 @@ func (t *sourcegraphTool) Run(ctx context.Context, call ToolCall) (ToolResponse,
241 return NewTextErrorResponse("Failed to read response body: " + err.Error()), nil
235 return NewTextErrorResponse("Failed to read response body: " + err.Error()), nil
242 }
236 }
243
237
244− // Parse the GraphQL response
245 var result map[string]any
238 var result map[string]any
246 if err = json.Unmarshal(body, &result); err != nil {
239 if err = json.Unmarshal(body, &result); err != nil {
247 return NewTextErrorResponse("Failed to parse response: " + err.Error()), nil
240 return NewTextErrorResponse("Failed to parse response: " + err.Error()), nil
248 }
241 }
249
242
250− // Format the results in a readable way
251 formattedResults, err := formatSourcegraphResults(result, params.ContextWindow)
243 formattedResults, err := formatSourcegraphResults(result, params.ContextWindow)
252 if err != nil {
244 if err != nil {
253 return NewTextErrorResponse("Failed to format results: " + err.Error()), nil
245 return NewTextErrorResponse("Failed to format results: " + err.Error()), nil
@@ -259,7 +251,6 @@ func (t *sourcegraphTool) Run(ctx context.Context, call ToolCall) (ToolResponse,
259func formatSourcegraphResults(result map[string]any, contextWindow int) (string, error) {
251func formatSourcegraphResults(result map[string]any, contextWindow int) (string, error) {
260 var buffer strings.Builder
252 var buffer strings.Builder
261
253
262− // Check for errors in the GraphQL response
263 if errors, ok := result["errors"].([]any); ok && len(errors) > 0 {
254 if errors, ok := result["errors"].([]any); ok && len(errors) > 0 {
264 buffer.WriteString("## Sourcegraph API Error\n\n")
255 buffer.WriteString("## Sourcegraph API Error\n\n")
265 for _, err := range errors {
256 for _, err := range errors {
@@ -272,7 +263,6 @@ func formatSourcegraphResults(result map[string]any, contextWindow int) (string,
272 return buffer.String(), nil
263 return buffer.String(), nil
273 }
264 }
274
265
275− // Extract data from the response
276 data, ok := result["data"].(map[string]any)
266 data, ok := result["data"].(map[string]any)
277 if !ok {
267 if !ok {
278 return "", fmt.Errorf("invalid response format: missing data field")
268 return "", fmt.Errorf("invalid response format: missing data field")
@@ -288,7 +278,6 @@ func formatSourcegraphResults(result map[string]any, contextWindow int) (string,
288 return "", fmt.Errorf("invalid response format: missing results field")
278 return "", fmt.Errorf("invalid response format: missing results field")
289 }
279 }
290
280
291− // Write search metadata
292 matchCount, _ := searchResults["matchCount"].(float64)
281 matchCount, _ := searchResults["matchCount"].(float64)
293 resultCount, _ := searchResults["resultCount"].(float64)
282 resultCount, _ := searchResults["resultCount"].(float64)
294 limitHit, _ := searchResults["limitHit"].(bool)
283 limitHit, _ := searchResults["limitHit"].(bool)
@@ -302,33 +291,28 @@ func formatSourcegraphResults(result map[string]any, contextWindow int) (string,
302
291
303 buffer.WriteString("\n")
292 buffer.WriteString("\n")
304
293
305− // Process results
306 results, ok := searchResults["results"].([]any)
294 results, ok := searchResults["results"].([]any)
307 if !ok || len(results) == 0 {
295 if !ok || len(results) == 0 {
308 buffer.WriteString("No results found. Try a different query.\n")
296 buffer.WriteString("No results found. Try a different query.\n")
309 return buffer.String(), nil
297 return buffer.String(), nil
310 }
298 }
311
299
312− // Limit to 10 results
313 maxResults := 10
300 maxResults := 10
314 if len(results) > maxResults {
301 if len(results) > maxResults {
315 results = results[:maxResults]
302 results = results[:maxResults]
316 }
303 }
317
304
318− // Process each result
319 for i, res := range results {
305 for i, res := range results {
320 fileMatch, ok := res.(map[string]any)
306 fileMatch, ok := res.(map[string]any)
321 if !ok {
307 if !ok {
322 continue
308 continue
323 }
309 }
324
310
325− // Skip non-FileMatch results
326 typeName, _ := fileMatch["__typename"].(string)
311 typeName, _ := fileMatch["__typename"].(string)
327 if typeName != "FileMatch" {
312 if typeName != "FileMatch" {
328 continue
313 continue
329 }
314 }
330
315
331− // Extract repository and file information
332 repo, _ := fileMatch["repository"].(map[string]any)
316 repo, _ := fileMatch["repository"].(map[string]any)
333 file, _ := fileMatch["file"].(map[string]any)
317 file, _ := fileMatch["file"].(map[string]any)
334 lineMatches, _ := fileMatch["lineMatches"].([]any)
318 lineMatches, _ := fileMatch["lineMatches"].([]any)
@@ -348,7 +332,6 @@ func formatSourcegraphResults(result map[string]any, contextWindow int) (string,
348 buffer.WriteString(fmt.Sprintf("URL: %s\n\n", fileURL))
332 buffer.WriteString(fmt.Sprintf("URL: %s\n\n", fileURL))
349 }
333 }
350
334
351− // Show line matches with context
352 if len(lineMatches) > 0 {
335 if len(lineMatches) > 0 {
353 for _, lm := range lineMatches {
336 for _, lm := range lineMatches {
354 lineMatch, ok := lm.(map[string]any)
337 lineMatch, ok := lm.(map[string]any)
@@ -359,13 +342,11 @@ func formatSourcegraphResults(result map[string]any, contextWindow int) (string,
359 lineNumber, _ := lineMatch["lineNumber"].(float64)
342 lineNumber, _ := lineMatch["lineNumber"].(float64)
360 preview, _ := lineMatch["preview"].(string)
343 preview, _ := lineMatch["preview"].(string)
361
344
362− // Extract context from file content if available
363 if fileContent != "" {
345 if fileContent != "" {
364 lines := strings.Split(fileContent, "\n")
346 lines := strings.Split(fileContent, "\n")
365
347
366 buffer.WriteString("```\n")
348 buffer.WriteString("```\n")
367
349
368− // Display context before the match (up to 10 lines)
369 startLine := max(1, int(lineNumber)-contextWindow)
350 startLine := max(1, int(lineNumber)-contextWindow)
370
351
371 for j := startLine - 1; j < int(lineNumber)-1 && j < len(lines); j++ {
352 for j := startLine - 1; j < int(lineNumber)-1 && j < len(lines); j++ {
@@ -374,10 +355,8 @@ func formatSourcegraphResults(result map[string]any, contextWindow int) (string,
374 }
355 }
375 }
356 }
376
357
377− // Display the matching line (highlighted)
378 buffer.WriteString(fmt.Sprintf("%d| %s\n", int(lineNumber), preview))
358 buffer.WriteString(fmt.Sprintf("%d| %s\n", int(lineNumber), preview))
379
359
380− // Display context after the match (up to 10 lines)
381 endLine := int(lineNumber) + contextWindow
360 endLine := int(lineNumber) + contextWindow
382
361
383 for j := int(lineNumber); j < endLine && j < len(lines); j++ {
362 for j := int(lineNumber); j < endLine && j < len(lines); j++ {
@@ -388,7 +367,6 @@ func formatSourcegraphResults(result map[string]any, contextWindow int) (string,
388
367
389 buffer.WriteString("```\n\n")
368 buffer.WriteString("```\n\n")
390 } else {
369 } else {
391− // If file content is not available, just show the preview
392 buffer.WriteString("```\n")
370 buffer.WriteString("```\n")
393 buffer.WriteString(fmt.Sprintf("%d| %s\n", int(lineNumber), preview))
371 buffer.WriteString(fmt.Sprintf("%d| %s\n", int(lineNumber), preview))
394 buffer.WriteString("```\n\n")
372 buffer.WriteString("```\n\n")
internal/llm/tools/sourcegraph_test.go−29
@@ -5,7 +5,6 @@ import (
5 "encoding/json"
5 "encoding/json"
6 "testing"
6 "testing"
7
7
8− "github.com/kujtimiihoxha/termai/internal/permission"
9 "github.com/stretchr/testify/assert"
8 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/require"
9 "github.com/stretchr/testify/require"
11)
10)
@@ -23,13 +22,6 @@ func TestSourcegraphTool_Info(t *testing.T) {
23}
22}
24
23
25func TestSourcegraphTool_Run(t *testing.T) {
24func TestSourcegraphTool_Run(t *testing.T) {
26− // Setup a mock permission handler that always allows
27− origPermission := permission.Default
28− defer func() {
29− permission.Default = origPermission
30− }()
31− permission.Default = newMockPermissionService(true)
32−
33 t.Run("handles missing query parameter", func(t *testing.T) {
25 t.Run("handles missing query parameter", func(t *testing.T) {
34 tool := NewSourcegraphTool()
26 tool := NewSourcegraphTool()
35 params := SourcegraphParams{
27 params := SourcegraphParams{
@@ -61,27 +53,6 @@ func TestSourcegraphTool_Run(t *testing.T) {
61 assert.Contains(t, response.Content, "Failed to parse sourcegraph parameters")
53 assert.Contains(t, response.Content, "Failed to parse sourcegraph parameters")
62 })
54 })
63
55
64− t.Run("handles permission denied", func(t *testing.T) {
65− permission.Default = newMockPermissionService(false)
66−
67− tool := NewSourcegraphTool()
68− params := SourcegraphParams{
69− Query: "test query",
70− }
71−
72− paramsJSON, err := json.Marshal(params)
73− require.NoError(t, err)
74−
75− call := ToolCall{
76− Name: SourcegraphToolName,
77− Input: string(paramsJSON),
78− }
79−
80− response, err := tool.Run(context.Background(), call)
81− require.NoError(t, err)
82− assert.Contains(t, response.Content, "Permission denied")
83− })
84−
85 t.Run("normalizes count parameter", func(t *testing.T) {
56 t.Run("normalizes count parameter", func(t *testing.T) {
86 // Test cases for count normalization
57 // Test cases for count normalization
87 testCases := []struct {
58 testCases := []struct {
internal/llm/tools/view.go+41−44
@@ -14,6 +14,12 @@ import (
14 "github.com/kujtimiihoxha/termai/internal/lsp"
14 "github.com/kujtimiihoxha/termai/internal/lsp"
15)
15)
16
16
17+type ViewParams struct {
18+ FilePath string `json:"file_path"`
19+ Offset int `json:"offset"`
20+ Limit int `json:"limit"`
21+}
22+
17type viewTool struct {
23type viewTool struct {
18 lspClients map[string]*lsp.Client
24 lspClients map[string]*lsp.Client
19}
25}
@@ -23,18 +29,48 @@ const (
23 MaxReadSize = 250 * 1024
29 MaxReadSize = 250 * 1024
24 DefaultReadLimit = 2000
30 DefaultReadLimit = 2000
25 MaxLineLength = 2000
31 MaxLineLength = 2000
32+ viewDescription = `File viewing tool that reads and displays the contents of files with line numbers, allowing you to examine code, logs, or text data.
33+
34+WHEN TO USE THIS TOOL:
35+- Use when you need to read the contents of a specific file
36+- Helpful for examining source code, configuration files, or log files
37+- Perfect for looking at text-based file formats
38+
39+HOW TO USE:
40+- Provide the path to the file you want to view
41+- Optionally specify an offset to start reading from a specific line
42+- Optionally specify a limit to control how many lines are read
43+
44+FEATURES:
45+- Displays file contents with line numbers for easy reference
46+- Can read from any position in a file using the offset parameter
47+- Handles large files by limiting the number of lines read
48+- Automatically truncates very long lines for better display
49+- Suggests similar file names when the requested file isn't found
50+
51+LIMITATIONS:
52+- Maximum file size is 250KB
53+- Default reading limit is 2000 lines
54+- Lines longer than 2000 characters are truncated
55+- Cannot display binary files or images
56+- Images can be identified but not displayed
57+
58+TIPS:
59+- Use with Glob tool to first find files you want to view
60+- For code exploration, first use Grep to find relevant files, then View to examine them
61+- When viewing large files, use the offset parameter to read specific sections`
26)
62)
27
63
28−type ViewParams struct {
64+func NewViewTool(lspClients map[string]*lsp.Client) BaseTool {
29− FilePath string `json:"file_path"`
65+ return &viewTool{
30− Offset int `json:"offset"`
66+ lspClients,
31− Limit int `json:"limit"`
67+ }
32}
68}
33
69
34func (v *viewTool) Info() ToolInfo {
70func (v *viewTool) Info() ToolInfo {
35 return ToolInfo{
71 return ToolInfo{
36 Name: ViewToolName,
72 Name: ViewToolName,
37− Description: viewDescription(),
73+ Description: viewDescription,
38 Parameters: map[string]any{
74 Parameters: map[string]any{
39 "file_path": map[string]any{
75 "file_path": map[string]any{
40 "type": "string",
76 "type": "string",
@@ -262,42 +298,3 @@ func (s *LineScanner) Text() string {
262func (s *LineScanner) Err() error {
298func (s *LineScanner) Err() error {
263 return s.scanner.Err()
299 return s.scanner.Err()
264}
300}
265−
266−func viewDescription() string {
267− return `File viewing tool that reads and displays the contents of files with line numbers, allowing you to examine code, logs, or text data.
268−
269−WHEN TO USE THIS TOOL:
270−- Use when you need to read the contents of a specific file
271−- Helpful for examining source code, configuration files, or log files
272−- Perfect for looking at text-based file formats
273−
274−HOW TO USE:
275−- Provide the path to the file you want to view
276−- Optionally specify an offset to start reading from a specific line
277−- Optionally specify a limit to control how many lines are read
278−
279−FEATURES:
280−- Displays file contents with line numbers for easy reference
281−- Can read from any position in a file using the offset parameter
282−- Handles large files by limiting the number of lines read
283−- Automatically truncates very long lines for better display
284−- Suggests similar file names when the requested file isn't found
285−
286−LIMITATIONS:
287−- Maximum file size is 250KB
288−- Default reading limit is 2000 lines
289−- Lines longer than 2000 characters are truncated
290−- Cannot display binary files or images
291−- Images can be identified but not displayed
292−
293−TIPS:
294−- Use with Glob tool to first find files you want to view
295−- For code exploration, first use Grep to find relevant files, then View to examine them
296−- When viewing large files, use the offset parameter to read specific sections`
297−}
298−
299−func NewViewTool(lspClients map[string]*lsp.Client) BaseTool {
300− return &viewTool{
301− lspClients,
302− }
303−}
internal/llm/tools/write.go+47−58
@@ -13,14 +13,6 @@ import (
13 "github.com/kujtimiihoxha/termai/internal/permission"
13 "github.com/kujtimiihoxha/termai/internal/permission"
14)
14)
15
15
16−type writeTool struct {
17− lspClients map[string]*lsp.Client
18−}
19−
20−const (
21− WriteToolName = "write"
22−)
23−
24type WriteParams struct {
16type WriteParams struct {
25 FilePath string `json:"file_path"`
17 FilePath string `json:"file_path"`
26 Content string `json:"content"`
18 Content string `json:"content"`
@@ -31,10 +23,54 @@ type WritePermissionsParams struct {
31 Content string `json:"content"`
23 Content string `json:"content"`
32}
24}
33
25
26+type writeTool struct {
27+ lspClients map[string]*lsp.Client
28+ permissions permission.Service
29+}
30+
31+const (
32+ WriteToolName = "write"
33+ writeDescription = `File writing tool that creates or updates files in the filesystem, allowing you to save or modify text content.
34+
35+WHEN TO USE THIS TOOL:
36+- Use when you need to create a new file
37+- Helpful for updating existing files with modified content
38+- Perfect for saving generated code, configurations, or text data
39+
40+HOW TO USE:
41+- Provide the path to the file you want to write
42+- Include the content to be written to the file
43+- The tool will create any necessary parent directories
44+
45+FEATURES:
46+- Can create new files or overwrite existing ones
47+- Creates parent directories automatically if they don't exist
48+- Checks if the file has been modified since last read for safety
49+- Avoids unnecessary writes when content hasn't changed
50+
51+LIMITATIONS:
52+- You should read a file before writing to it to avoid conflicts
53+- Cannot append to files (rewrites the entire file)
54+
55+
56+TIPS:
57+- Use the View tool first to examine existing files before modifying them
58+- Use the LS tool to verify the correct location when creating new files
59+- Combine with Glob and Grep tools to find and modify multiple files
60+- Always include descriptive comments when making changes to existing code`
61+)
62+
63+func NewWriteTool(lspClients map[string]*lsp.Client, permissions permission.Service) BaseTool {
64+ return &writeTool{
65+ lspClients: lspClients,
66+ permissions: permissions,
67+ }
68+}
69+
34func (w *writeTool) Info() ToolInfo {
70func (w *writeTool) Info() ToolInfo {
35 return ToolInfo{
71 return ToolInfo{
36 Name: WriteToolName,
72 Name: WriteToolName,
37− Description: writeDescription(),
73+ Description: writeDescription,
38 Parameters: map[string]any{
74 Parameters: map[string]any{
39 "file_path": map[string]any{
75 "file_path": map[string]any{
40 "type": "string",
76 "type": "string",
@@ -49,7 +85,6 @@ func (w *writeTool) Info() ToolInfo {
49 }
85 }
50}
86}
51
87
52−// Run implements Tool.
53func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
88func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
54 var params WriteParams
89 var params WriteParams
55 if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
90 if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
@@ -64,20 +99,17 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
64 return NewTextErrorResponse("content is required"), nil
99 return NewTextErrorResponse("content is required"), nil
65 }
100 }
66
101
67− // Handle relative paths
68 filePath := params.FilePath
102 filePath := params.FilePath
69 if !filepath.IsAbs(filePath) {
103 if !filepath.IsAbs(filePath) {
70 filePath = filepath.Join(config.WorkingDirectory(), filePath)
104 filePath = filepath.Join(config.WorkingDirectory(), filePath)
71 }
105 }
72
106
73− // Check if file exists and is a directory
74 fileInfo, err := os.Stat(filePath)
107 fileInfo, err := os.Stat(filePath)
75 if err == nil {
108 if err == nil {
76 if fileInfo.IsDir() {
109 if fileInfo.IsDir() {
77 return NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
110 return NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
78 }
111 }
79
112
80− // Check if file was modified since last read
81 modTime := fileInfo.ModTime()
113 modTime := fileInfo.ModTime()
82 lastRead := getLastReadTime(filePath)
114 lastRead := getLastReadTime(filePath)
83 if modTime.After(lastRead) {
115 if modTime.After(lastRead) {
@@ -85,7 +117,6 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
85 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))), nil
117 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))), nil
86 }
118 }
87
119
88− // Optional: Get old content for diff
89 oldContent, readErr := os.ReadFile(filePath)
120 oldContent, readErr := os.ReadFile(filePath)
90 if readErr == nil && string(oldContent) == params.Content {
121 if readErr == nil && string(oldContent) == params.Content {
91 return NewTextErrorResponse(fmt.Sprintf("File %s already contains the exact content. No changes made.", filePath)), nil
122 return NewTextErrorResponse(fmt.Sprintf("File %s already contains the exact content. No changes made.", filePath)), nil
@@ -94,13 +125,11 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
94 return NewTextErrorResponse(fmt.Sprintf("Failed to access file: %s", err)), nil
125 return NewTextErrorResponse(fmt.Sprintf("Failed to access file: %s", err)), nil
95 }
126 }
96
127
97− // Create parent directories if needed
98 dir := filepath.Dir(filePath)
128 dir := filepath.Dir(filePath)
99 if err = os.MkdirAll(dir, 0o755); err != nil {
129 if err = os.MkdirAll(dir, 0o755); err != nil {
100 return NewTextErrorResponse(fmt.Sprintf("Failed to create parent directories: %s", err)), nil
130 return NewTextErrorResponse(fmt.Sprintf("Failed to create parent directories: %s", err)), nil
101 }
131 }
102
132
103− // Get old content for diff if file exists
104 oldContent := ""
133 oldContent := ""
105 if fileInfo != nil && !fileInfo.IsDir() {
134 if fileInfo != nil && !fileInfo.IsDir() {
106 oldBytes, readErr := os.ReadFile(filePath)
135 oldBytes, readErr := os.ReadFile(filePath)
@@ -108,8 +137,8 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
108 oldContent = string(oldBytes)
137 oldContent = string(oldBytes)
109 }
138 }
110 }
139 }
111−
140+
112− p := permission.Default.Request(
141+ p := w.permissions.Request(
113 permission.CreatePermissionRequest{
142 permission.CreatePermissionRequest{
114 Path: filePath,
143 Path: filePath,
115 ToolName: WriteToolName,
144 ToolName: WriteToolName,
@@ -125,16 +154,13 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
125 return NewTextErrorResponse(fmt.Sprintf("Permission denied to create file: %s", filePath)), nil
154 return NewTextErrorResponse(fmt.Sprintf("Permission denied to create file: %s", filePath)), nil
126 }
155 }
127
156
128− // Write the file
129 err = os.WriteFile(filePath, []byte(params.Content), 0o644)
157 err = os.WriteFile(filePath, []byte(params.Content), 0o644)
130 if err != nil {
158 if err != nil {
131 return NewTextErrorResponse(fmt.Sprintf("Failed to write file: %s", err)), nil
159 return NewTextErrorResponse(fmt.Sprintf("Failed to write file: %s", err)), nil
132 }
160 }
133
161
134− // Record the file write
135 recordFileWrite(filePath)
162 recordFileWrite(filePath)
136 recordFileRead(filePath)
163 recordFileRead(filePath)
137− // Wait for LSP diagnostics after writing the file
138 waitForLspDiagnostics(ctx, filePath, w.lspClients)
164 waitForLspDiagnostics(ctx, filePath, w.lspClients)
139
165
140 result := fmt.Sprintf("File successfully written: %s", filePath)
166 result := fmt.Sprintf("File successfully written: %s", filePath)
@@ -142,40 +168,3 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
142 result += appendDiagnostics(filePath, w.lspClients)
168 result += appendDiagnostics(filePath, w.lspClients)
143 return NewTextResponse(result), nil
169 return NewTextResponse(result), nil
144}
170}
145−
146−func writeDescription() string {
147− return `File writing tool that creates or updates files in the filesystem, allowing you to save or modify text content.
148−
149−WHEN TO USE THIS TOOL:
150−- Use when you need to create a new file
151−- Helpful for updating existing files with modified content
152−- Perfect for saving generated code, configurations, or text data
153−
154−HOW TO USE:
155−- Provide the path to the file you want to write
156−- Include the content to be written to the file
157−- The tool will create any necessary parent directories
158−
159−FEATURES:
160−- Can create new files or overwrite existing ones
161−- Creates parent directories automatically if they don't exist
162−- Checks if the file has been modified since last read for safety
163−- Avoids unnecessary writes when content hasn't changed
164−
165−LIMITATIONS:
166−- You should read a file before writing to it to avoid conflicts
167−- Cannot append to files (rewrites the entire file)
168−
169−
170−TIPS:
171−- Use the View tool first to examine existing files before modifying them
172−- Use the LS tool to verify the correct location when creating new files
173−- Combine with Glob and Grep tools to find and modify multiple files
174−- Always include descriptive comments when making changes to existing code`
175−}
176−
177−func NewWriteTool(lspClients map[string]*lsp.Client) BaseTool {
178− return &writeTool{
179− lspClients,
180− }
181−}
internal/llm/tools/write_test.go+11−30
@@ -9,13 +9,12 @@ import (
9 "time"
9 "time"
10
10
11 "github.com/kujtimiihoxha/termai/internal/lsp"
11 "github.com/kujtimiihoxha/termai/internal/lsp"
12− "github.com/kujtimiihoxha/termai/internal/permission"
13 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"
13 "github.com/stretchr/testify/require"
15)
14)
16
15
17func TestWriteTool_Info(t *testing.T) {
16func TestWriteTool_Info(t *testing.T) {
18− tool := NewWriteTool(make(map[string]*lsp.Client))
17+ tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
19 info := tool.Info()
18 info := tool.Info()
20
19
21 assert.Equal(t, WriteToolName, info.Name)
20 assert.Equal(t, WriteToolName, info.Name)
@@ -27,21 +26,13 @@ func TestWriteTool_Info(t *testing.T) {
27}
26}
28
27
29func TestWriteTool_Run(t *testing.T) {
28func TestWriteTool_Run(t *testing.T) {
30− // Setup a mock permission handler that always allows
31− origPermission := permission.Default
32− defer func() {
33− permission.Default = origPermission
34− }()
35− permission.Default = newMockPermissionService(true)
36−
37 // Create a temporary directory for testing
29 // Create a temporary directory for testing
38 tempDir, err := os.MkdirTemp("", "write_tool_test")
30 tempDir, err := os.MkdirTemp("", "write_tool_test")
39 require.NoError(t, err)
31 require.NoError(t, err)
40 defer os.RemoveAll(tempDir)
32 defer os.RemoveAll(tempDir)
41
33
42 t.Run("creates a new file successfully", func(t *testing.T) {
34 t.Run("creates a new file successfully", func(t *testing.T) {
43− permission.Default = newMockPermissionService(true)
44− tool := NewWriteTool(make(map[string]*lsp.Client))
35+ tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
45
36
46 filePath := filepath.Join(tempDir, "new_file.txt")
37 filePath := filepath.Join(tempDir, "new_file.txt")
47 content := "This is a test content"
38 content := "This is a test content"
@@ -70,8 +61,7 @@ func TestWriteTool_Run(t *testing.T) {
70 })
61 })
71
62
72 t.Run("creates file with nested directories", func(t *testing.T) {
63 t.Run("creates file with nested directories", func(t *testing.T) {
73− permission.Default = newMockPermissionService(true)
74− tool := NewWriteTool(make(map[string]*lsp.Client))
64+ tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
75
65
76 filePath := filepath.Join(tempDir, "nested/dirs/new_file.txt")
66 filePath := filepath.Join(tempDir, "nested/dirs/new_file.txt")
77 content := "Content in nested directory"
67 content := "Content in nested directory"
@@ -100,8 +90,7 @@ func TestWriteTool_Run(t *testing.T) {
100 })
90 })
101
91
102 t.Run("updates existing file", func(t *testing.T) {
92 t.Run("updates existing file", func(t *testing.T) {
103− permission.Default = newMockPermissionService(true)
104− tool := NewWriteTool(make(map[string]*lsp.Client))
93+ tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
105
94
106 // Create a file first
95 // Create a file first
107 filePath := filepath.Join(tempDir, "existing_file.txt")
96 filePath := filepath.Join(tempDir, "existing_file.txt")
@@ -138,8 +127,7 @@ func TestWriteTool_Run(t *testing.T) {
138 })
127 })
139
128
140 t.Run("handles invalid parameters", func(t *testing.T) {
129 t.Run("handles invalid parameters", func(t *testing.T) {
141− permission.Default = newMockPermissionService(true)
142− tool := NewWriteTool(make(map[string]*lsp.Client))
130+ tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
143
131
144 call := ToolCall{
132 call := ToolCall{
145 Name: WriteToolName,
133 Name: WriteToolName,
@@ -152,8 +140,7 @@ func TestWriteTool_Run(t *testing.T) {
152 })
140 })
153
141
154 t.Run("handles missing file_path", func(t *testing.T) {
142 t.Run("handles missing file_path", func(t *testing.T) {
155− permission.Default = newMockPermissionService(true)
156− tool := NewWriteTool(make(map[string]*lsp.Client))
143+ tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
157
144
158 params := WriteParams{
145 params := WriteParams{
159 FilePath: "",
146 FilePath: "",
@@ -174,8 +161,7 @@ func TestWriteTool_Run(t *testing.T) {
174 })
161 })
175
162
176 t.Run("handles missing content", func(t *testing.T) {
163 t.Run("handles missing content", func(t *testing.T) {
177− permission.Default = newMockPermissionService(true)
178− tool := NewWriteTool(make(map[string]*lsp.Client))
164+ tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
179
165
180 params := WriteParams{
166 params := WriteParams{
181 FilePath: filepath.Join(tempDir, "file.txt"),
167 FilePath: filepath.Join(tempDir, "file.txt"),
@@ -196,8 +182,7 @@ func TestWriteTool_Run(t *testing.T) {
196 })
182 })
197
183
198 t.Run("handles writing to a directory path", func(t *testing.T) {
184 t.Run("handles writing to a directory path", func(t *testing.T) {
199− permission.Default = newMockPermissionService(true)
200− tool := NewWriteTool(make(map[string]*lsp.Client))
185+ tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
201
186
202 // Create a directory
187 // Create a directory
203 dirPath := filepath.Join(tempDir, "test_dir")
188 dirPath := filepath.Join(tempDir, "test_dir")
@@ -223,8 +208,7 @@ func TestWriteTool_Run(t *testing.T) {
223 })
208 })
224
209
225 t.Run("handles permission denied", func(t *testing.T) {
210 t.Run("handles permission denied", func(t *testing.T) {
226− permission.Default = newMockPermissionService(false)
227− tool := NewWriteTool(make(map[string]*lsp.Client))
211+ tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(false))
228
212
229 filePath := filepath.Join(tempDir, "permission_denied.txt")
213 filePath := filepath.Join(tempDir, "permission_denied.txt")
230 params := WriteParams{
214 params := WriteParams{
@@ -250,8 +234,7 @@ func TestWriteTool_Run(t *testing.T) {
250 })
234 })
251
235
252 t.Run("detects file modified since last read", func(t *testing.T) {
236 t.Run("detects file modified since last read", func(t *testing.T) {
253− permission.Default = newMockPermissionService(true)
254− tool := NewWriteTool(make(map[string]*lsp.Client))
237+ tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
255
238
256 // Create a file
239 // Create a file
257 filePath := filepath.Join(tempDir, "modified_file.txt")
240 filePath := filepath.Join(tempDir, "modified_file.txt")
@@ -292,8 +275,7 @@ func TestWriteTool_Run(t *testing.T) {
292 })
275 })
293
276
294 t.Run("skips writing when content is identical", func(t *testing.T) {
277 t.Run("skips writing when content is identical", func(t *testing.T) {
295− permission.Default = newMockPermissionService(true)
296− tool := NewWriteTool(make(map[string]*lsp.Client))
278+ tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
297
279
298 // Create a file
280 // Create a file
299 filePath := filepath.Join(tempDir, "identical_content.txt")
281 filePath := filepath.Join(tempDir, "identical_content.txt")
@@ -323,4 +305,3 @@ func TestWriteTool_Run(t *testing.T) {
323 assert.Contains(t, response.Content, "already contains the exact content")
305 assert.Contains(t, response.Content, "already contains the exact content")
324 })
306 })
325}
307}
326−
internal/permission/permission.go−2
@@ -100,5 +100,3 @@ func NewPermissionService() Service {
100 sessionPermissions: make([]PermissionRequest, 0),
100 sessionPermissions: make([]PermissionRequest, 0),
101 }
101 }
102}
102}
103−
104−var Default Service = NewPermissionService()
internal/tui/tui.go+3−3
@@ -80,11 +80,11 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
80 case dialog.PermissionResponseMsg:
80 case dialog.PermissionResponseMsg:
81 switch msg.Action {
81 switch msg.Action {
82 case dialog.PermissionAllow:
82 case dialog.PermissionAllow:
83− permission.Default.Grant(msg.Permission)
83+ a.app.Permissions.Grant(msg.Permission)
84 case dialog.PermissionAllowForSession:
84 case dialog.PermissionAllowForSession:
85− permission.Default.GrantPersistant(msg.Permission)
85+ a.app.Permissions.GrantPersistant(msg.Permission)
86 case dialog.PermissionDeny:
86 case dialog.PermissionDeny:
87− permission.Default.Deny(msg.Permission)
87+ a.app.Permissions.Deny(msg.Permission)
88 }
88 }
89 case vimtea.EditorModeMsg:
89 case vimtea.EditorModeMsg:
90 a.editorMode = msg.Mode
90 a.editorMode = msg.Mode