on main help sign in
Merge pull request #24 from kujtimiihoxha/cleanup-tools

Cleanup tools

commit124bd57c507fdcbb56ab27137cbe892f12e1b48f authorKujtim Hoxha <kujtimii.h@gmail.com> dateTue 8 Apr 2025, 19:42 (2 years ago) parent5acf0cbc571283
20 files changed+1057−757
cmd/root.go+1−1 internal/app/services.go+1−1 internal/llm/agent/coder.go+5−5 internal/llm/agent/mcp-tools.go+15−13 internal/llm/tools/bash.go+147−173 internal/llm/tools/bash_test.go+16−45 internal/llm/tools/diagnostics.go+30−43 internal/llm/tools/edit.go+73−80 internal/llm/tools/edit_test.go+509 internal/llm/tools/fetch.go+20−19 internal/llm/tools/glob.go+42−62 internal/llm/tools/grep.go+45−61 internal/llm/tools/ls.go+40−55 internal/llm/tools/sourcegraph.go+11−33 internal/llm/tools/sourcegraph_test.go−29 internal/llm/tools/view.go+41−44 internal/llm/tools/write.go+47−58 internal/llm/tools/write_test.go+11−30 internal/permission/permission.go−2 internal/tui/tui.go+3−3
cmd/root.go+1−1
@@ -50,7 +50,7 @@ var rootCmd = &cobra.Command{
5050
5151 go func() {
5252 // Set this up once
53− agent.GetMcpTools(ctx)
53+ agent.GetMcpTools(ctx, app.Permissions)
5454 for msg := range ch {
5555 tui.Send(msg)
5656 }
internal/app/services.go+1−1
@@ -41,7 +41,7 @@ func New(ctx context.Context, conn *sql.DB) *App {
4141 Context: ctx,
4242 Sessions: sessions,
4343 Messages: messages,
44− Permissions: permission.Default,
44+ Permissions: permission.NewPermissionService(),
4545 Logger: log,
4646 LSPClients: make(map[string]*lsp.Client),
4747 }
internal/llm/agent/coder.go+5−5
@@ -44,7 +44,7 @@ func NewCoderAgent(app *app.App) (Agent, error) {
4444 return nil, err
4545 }
4646
47− otherTools := GetMcpTools(app.Context)
47+ otherTools := GetMcpTools(app.Context, app.Permissions)
4848 if len(app.LSPClients) > 0 {
4949 otherTools = append(otherTools, tools.NewDiagnosticsTool(app.LSPClients))
5050 }
@@ -53,15 +53,15 @@ func NewCoderAgent(app *app.App) (Agent, error) {
5353 App: app,
5454 tools: append(
5555 []tools.BaseTool{
56− tools.NewBashTool(),
57− tools.NewEditTool(app.LSPClients),
58− tools.NewFetchTool(),
56+ tools.NewBashTool(app.Permissions),
57+ tools.NewEditTool(app.LSPClients, app.Permissions),
58+ tools.NewFetchTool(app.Permissions),
5959 tools.NewGlobTool(),
6060 tools.NewGrepTool(),
6161 tools.NewLsTool(),
6262 tools.NewSourcegraphTool(),
6363 tools.NewViewTool(app.LSPClients),
64− tools.NewWriteTool(app.LSPClients),
64+ tools.NewWriteTool(app.LSPClients, app.Permissions),
6565 }, otherTools...,
6666 ),
6767 model: model,
internal/llm/agent/mcp-tools.go+15−13
@@ -16,9 +16,10 @@ import (
1616)
1717
1818type mcpTool struct {
19− mcpName string
20− tool mcp.Tool
21− mcpConfig config.MCPServer
19+ mcpName string
20+ tool mcp.Tool
21+ mcpConfig config.MCPServer
22+ permissions permission.Service
2223}
2324
2425type MCPClient interface {
@@ -80,7 +81,7 @@ func runTool(ctx context.Context, c MCPClient, toolName string, input string) (t
8081
8182func (b *mcpTool) Run(ctx context.Context, params tools.ToolCall) (tools.ToolResponse, error) {
8283 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(
8485 permission.CreatePermissionRequest{
8586 Path: config.WorkingDirectory(),
8687 ToolName: b.Info().Name,
@@ -118,17 +119,18 @@ func (b *mcpTool) Run(ctx context.Context, params tools.ToolCall) (tools.ToolRes
118119 return tools.NewTextErrorResponse("invalid mcp type"), nil
119120}
120121
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 {
122123 return &mcpTool{
123− mcpName: name,
124− tool: tool,
125− mcpConfig: mcpConfig,
124+ mcpName: name,
125+ tool: tool,
126+ mcpConfig: mcpConfig,
127+ permissions: permissions,
126128 }
127129}
128130
129131var mcpTools []tools.BaseTool
130132
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 {
132134 var stdioTools []tools.BaseTool
133135 initRequest := mcp.InitializeRequest{}
134136 initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
@@ -149,13 +151,13 @@ func getTools(ctx context.Context, name string, m config.MCPServer, c MCPClient)
149151 return stdioTools
150152 }
151153 for _, t := range tools.Tools {
152− stdioTools = append(stdioTools, NewMcpTool(name, t, m))
154+ stdioTools = append(stdioTools, NewMcpTool(name, t, permissions, m))
153155 }
154156 defer c.Close()
155157 return stdioTools
156158}
157159
158−func GetMcpTools(ctx context.Context) []tools.BaseTool {
160+func GetMcpTools(ctx context.Context, permissions permission.Service) []tools.BaseTool {
159161 if len(mcpTools) > 0 {
160162 return mcpTools
161163 }
@@ -172,7 +174,7 @@ func GetMcpTools(ctx context.Context) []tools.BaseTool {
172174 continue
173175 }
174176
175− mcpTools = append(mcpTools, getTools(ctx, name, m, c)...)
177+ mcpTools = append(mcpTools, getTools(ctx, name, m, permissions, c)...)
176178 case config.MCPSse:
177179 c, err := client.NewSSEMCPClient(
178180 m.URL,
@@ -182,7 +184,7 @@ func GetMcpTools(ctx context.Context) []tools.BaseTool {
182184 log.Printf("error creating mcp client: %s", err)
183185 continue
184186 }
185− mcpTools = append(mcpTools, getTools(ctx, name, m, c)...)
187+ mcpTools = append(mcpTools, getTools(ctx, name, m, permissions, c)...)
186188 }
187189 }
188190
internal/llm/tools/bash.go+147−173
@@ -11,16 +11,6 @@ import (
1111 "github.com/kujtimiihoxha/termai/internal/permission"
1212)
1313
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−
2414type BashParams struct {
2515 Command string `json:"command"`
2616 Timeout int `json:"timeout"`
@@ -31,180 +21,36 @@ type BashPermissionsParams struct {
3121 Timeout int `json:"timeout"`
3222}
3323
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{
3537 "alias", "curl", "curlie", "wget", "axel", "aria2c",
3638 "nc", "telnet", "lynx", "w3m", "links", "httpie", "xh",
3739 "http-prompt", "chrome", "firefox", "safari",
3840}
3941
40−var SafeReadOnlyCommands = []string{
41− // Basic shell commands
42+var safeReadOnlyCommands = []string{
4243 "ls", "echo", "pwd", "date", "cal", "uptime", "whoami", "id", "groups", "env", "printenv", "set", "unset", "which", "type", "whereis",
4344 "whatis", "uname", "hostname", "df", "du", "free", "top", "ps", "kill", "killall", "nice", "nohup", "time", "timeout",
44−
45− // Git read-only commands
45+
4646 "git status", "git log", "git diff", "git show", "git branch", "git tag", "git remote", "git ls-files", "git ls-remote",
4747 "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−}
19848
199−func countLines(s string) int {
200− if s == "" {
201− return 0
202− }
203− return len(strings.Split(s, "\n"))
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",
20450}
20551
20652func bashDescription() string {
207− bannedCommandsStr := strings.Join(BannedCommands, ", ")
53+ bannedCommandsStr := strings.Join(bannedCommands, ", ")
20854 return fmt.Sprintf(`Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
20955
21056Before executing the command, please follow these steps:
@@ -352,6 +198,134 @@ Important:
352198- Never update git config`, bannedCommandsStr, MaxOutputLength)
353199}
354200
355−func NewBashTool() BaseTool {
356− return &bashTool{}
201+func NewBashTool(permission permission.Service) BaseTool {
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+ "description": "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"))
357331}
internal/llm/tools/bash_test.go+16−45
@@ -15,7 +15,7 @@ import (
1515)
1616
1717func TestBashTool_Info(t *testing.T) {
18− tool := NewBashTool()
18+ tool := NewBashTool(newMockPermissionService(true))
1919 info := tool.Info()
2020
2121 assert.Equal(t, BashToolName, info.Name)
@@ -26,13 +26,6 @@ func TestBashTool_Info(t *testing.T) {
2626}
2727
2828func 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−
3629 // Save original working directory
3730 origWd, err := os.Getwd()
3831 require.NoError(t, err)
@@ -41,8 +34,7 @@ func TestBashTool_Run(t *testing.T) {
4134 }()
4235
4336 t.Run("executes command successfully", func(t *testing.T) {
44− permission.Default = newMockPermissionService(true)
45− tool := NewBashTool()
37+ tool := NewBashTool(newMockPermissionService(true))
4638 params := BashParams{
4739 Command: "echo 'Hello World'",
4840 }
@@ -61,9 +53,7 @@ func TestBashTool_Run(t *testing.T) {
6153 })
6254
6355 t.Run("handles invalid parameters", func(t *testing.T) {
64− permission.Default = newMockPermissionService(true)
65−
66− tool := NewBashTool()
56+ tool := NewBashTool(newMockPermissionService(true))
6757 call := ToolCall{
6858 Name: BashToolName,
6959 Input: "invalid json",
@@ -75,9 +65,7 @@ func TestBashTool_Run(t *testing.T) {
7565 })
7666
7767 t.Run("handles missing command", func(t *testing.T) {
78− permission.Default = newMockPermissionService(true)
79−
80− tool := NewBashTool()
68+ tool := NewBashTool(newMockPermissionService(true))
8169 params := BashParams{
8270 Command: "",
8371 }
@@ -96,11 +84,9 @@ func TestBashTool_Run(t *testing.T) {
9684 })
9785
9886 t.Run("handles banned commands", func(t *testing.T) {
99− permission.Default = newMockPermissionService(true)
87+ tool := NewBashTool(newMockPermissionService(true))
10088
101− tool := NewBashTool()
102−
103− for _, bannedCmd := range BannedCommands {
89+ for _, bannedCmd := range bannedCommands {
10490 params := BashParams{
10591 Command: bannedCmd + " arg1 arg2",
10692 }
@@ -120,17 +106,11 @@ func TestBashTool_Run(t *testing.T) {
120106 })
121107
122108 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))
126110
127111 // Test with multi-word safe commands
128112 multiWordCommands := []string{
129− "git status",
130− "git log -n 5",
131− "docker ps",
132− "go test ./...",
133− "kubectl get pods",
113+ "go env",
134114 }
135115
136116 for _, cmd := range multiWordCommands {
@@ -148,15 +128,13 @@ func TestBashTool_Run(t *testing.T) {
148128
149129 response, err := tool.Run(context.Background(), call)
150130 require.NoError(t, err)
151− assert.NotContains(t, response.Content, "permission denied",
131+ assert.NotContains(t, response.Content, "permission denied",
152132 "Command %s should be allowed without permission", cmd)
153133 }
154134 })
155135
156136 t.Run("handles permission denied", func(t *testing.T) {
157− permission.Default = newMockPermissionService(false)
158−
159− tool := NewBashTool()
137+ tool := NewBashTool(newMockPermissionService(false))
160138
161139 // Test with a command that requires permission
162140 params := BashParams{
@@ -177,8 +155,7 @@ func TestBashTool_Run(t *testing.T) {
177155 })
178156
179157 t.Run("handles command timeout", func(t *testing.T) {
180− permission.Default = newMockPermissionService(true)
181− tool := NewBashTool()
158+ tool := NewBashTool(newMockPermissionService(true))
182159 params := BashParams{
183160 Command: "sleep 2",
184161 Timeout: 100, // 100ms timeout
@@ -198,8 +175,7 @@ func TestBashTool_Run(t *testing.T) {
198175 })
199176
200177 t.Run("handles command with stderr output", func(t *testing.T) {
201− permission.Default = newMockPermissionService(true)
202− tool := NewBashTool()
178+ tool := NewBashTool(newMockPermissionService(true))
203179 params := BashParams{
204180 Command: "echo 'error message' >&2",
205181 }
@@ -218,8 +194,7 @@ func TestBashTool_Run(t *testing.T) {
218194 })
219195
220196 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))
223198 params := BashParams{
224199 Command: "echo 'stdout message' && echo 'stderr message' >&2",
225200 }
@@ -239,8 +214,7 @@ func TestBashTool_Run(t *testing.T) {
239214 })
240215
241216 t.Run("handles context cancellation", func(t *testing.T) {
242− permission.Default = newMockPermissionService(true)
243− tool := NewBashTool()
217+ tool := NewBashTool(newMockPermissionService(true))
244218 params := BashParams{
245219 Command: "sleep 5",
246220 }
@@ -267,8 +241,7 @@ func TestBashTool_Run(t *testing.T) {
267241 })
268242
269243 t.Run("respects max timeout", func(t *testing.T) {
270− permission.Default = newMockPermissionService(true)
271− tool := NewBashTool()
244+ tool := NewBashTool(newMockPermissionService(true))
272245 params := BashParams{
273246 Command: "echo 'test'",
274247 Timeout: MaxTimeout + 1000, // Exceeds max timeout
@@ -288,8 +261,7 @@ func TestBashTool_Run(t *testing.T) {
288261 })
289262
290263 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))
293265 params := BashParams{
294266 Command: "echo 'test'",
295267 Timeout: -100, // Negative timeout
@@ -397,4 +369,3 @@ func newMockPermissionService(allow bool) permission.Service {
397369 allow: allow,
398370 }
399371}
400−
internal/llm/tools/diagnostics.go+30−43
@@ -13,22 +13,48 @@ import (
1313 "github.com/kujtimiihoxha/termai/internal/lsp/protocol"
1414)
1515
16+type DiagnosticsParams struct {
17+ FilePath string `json:"file_path"`
18+}
1619type diagnosticsTool struct {
1720 lspClients map[string]*lsp.Client
1821}
1922
2023const (
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+`
2246)
2347
24−type DiagnosticsParams struct {
25− FilePath string `json:"file_path"`
48+func NewDiagnosticsTool(lspClients map[string]*lsp.Client) BaseTool {
49+ return &diagnosticsTool{
50+ lspClients,
51+ }
2652}
2753
2854func (b *diagnosticsTool) Info() ToolInfo {
2955 return ToolInfo{
3056 Name: DiagnosticsToolName,
31− Description: "Get diagnostics for a file and/or project.",
57+ Description: diagnosticsDescription,
3258 Parameters: map[string]any{
3359 "file_path": map[string]any{
3460 "type": "string",
@@ -63,31 +89,24 @@ func (b *diagnosticsTool) Run(ctx context.Context, call ToolCall) (ToolResponse,
6389
6490func notifyLspOpenFile(ctx context.Context, filePath string, lsps map[string]*lsp.Client) {
6591 for _, client := range lsps {
66− // Open the file
6792 err := client.OpenFile(ctx, filePath)
6893 if err != nil {
69− // If there's an error opening the file, continue to the next client
7094 continue
7195 }
7296 }
7397}
7498
75−// waitForLspDiagnostics opens a file in LSP clients and waits for diagnostics to be published
7699func waitForLspDiagnostics(ctx context.Context, filePath string, lsps map[string]*lsp.Client) {
77100 if len(lsps) == 0 {
78101 return
79102 }
80103
81− // Create a channel to receive diagnostic notifications
82104 diagChan := make(chan struct{}, 1)
83105
84− // Register a temporary diagnostic handler for each client
85106 for _, client := range lsps {
86− // Store the original diagnostics map to detect changes
87107 originalDiags := make(map[protocol.DocumentUri][]protocol.Diagnostic)
88108 maps.Copy(originalDiags, client.GetDiagnostics())
89109
90− // Create a notification handler that will signal when diagnostics are received
91110 handler := func(params json.RawMessage) {
92111 lsp.HandleDiagnostics(client, params)
93112 var diagParams protocol.PublishDiagnosticsParams
@@ -95,28 +114,22 @@ func waitForLspDiagnostics(ctx context.Context, filePath string, lsps map[string
95114 return
96115 }
97116
98− // If this is for our file or we've received any new diagnostics, signal completion
99117 if diagParams.URI.Path() == filePath || hasDiagnosticsChanged(client.GetDiagnostics(), originalDiags) {
100118 select {
101119 case diagChan <- struct{}{}:
102− // Signal sent
103120 default:
104− // Channel already has a value, no need to send again
105121 }
106122 }
107123 }
108124
109− // Register our temporary handler
110125 client.RegisterNotificationHandler("textDocument/publishDiagnostics", handler)
111126
112− // Notify change if the file is already open
113127 if client.IsFileOpen(filePath) {
114128 err := client.NotifyChange(ctx, filePath)
115129 if err != nil {
116130 continue
117131 }
118132 } else {
119− // Open the file if it's not already open
120133 err := client.OpenFile(ctx, filePath)
121134 if err != nil {
122135 continue
@@ -124,22 +137,13 @@ func waitForLspDiagnostics(ctx context.Context, filePath string, lsps map[string
124137 }
125138 }
126139
127− // Wait for diagnostics with a reasonable timeout
128140 select {
129141 case <-diagChan:
130− // Diagnostics received
131142 case <-time.After(5 * time.Second):
132− // Timeout after 5 seconds - this is a fallback in case no diagnostics are published
133143 case <-ctx.Done():
134− // Context cancelled
135144 }
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.
140145}
141146
142−// hasDiagnosticsChanged checks if there are any new diagnostics compared to the original set
143147func hasDiagnosticsChanged(current, original map[protocol.DocumentUri][]protocol.Diagnostic) bool {
144148 for uri, diags := range current {
145149 origDiags, exists := original[uri]
@@ -154,9 +158,7 @@ func appendDiagnostics(filePath string, lsps map[string]*lsp.Client) string {
154158 fileDiagnostics := []string{}
155159 projectDiagnostics := []string{}
156160
157− // Enhanced format function that includes more diagnostic information
158161 formatDiagnostic := func(pth string, diagnostic protocol.Diagnostic, source string) string {
159− // Base components
160162 severity := "Info"
161163 switch diagnostic.Severity {
162164 case protocol.SeverityError:
@@ -167,10 +169,8 @@ func appendDiagnostics(filePath string, lsps map[string]*lsp.Client) string {
167169 severity = "Hint"
168170 }
169171
170− // Location information
171172 location := fmt.Sprintf("%s:%d:%d", pth, diagnostic.Range.Start.Line+1, diagnostic.Range.Start.Character+1)
172173
173− // Source information (LSP name)
174174 sourceInfo := ""
175175 if diagnostic.Source != "" {
176176 sourceInfo = diagnostic.Source
@@ -178,13 +178,11 @@ func appendDiagnostics(filePath string, lsps map[string]*lsp.Client) string {
178178 sourceInfo = source
179179 }
180180
181− // Code information
182181 codeInfo := ""
183182 if diagnostic.Code != nil {
184183 codeInfo = fmt.Sprintf("[%v]", diagnostic.Code)
185184 }
186185
187− // Tags information
188186 tagsInfo := ""
189187 if len(diagnostic.Tags) > 0 {
190188 tags := []string{}
@@ -201,7 +199,6 @@ func appendDiagnostics(filePath string, lsps map[string]*lsp.Client) string {
201199 }
202200 }
203201
204− // Assemble the full diagnostic message
205202 return fmt.Sprintf("%s: %s [%s]%s%s %s",
206203 severity,
207204 location,
@@ -217,7 +214,6 @@ func appendDiagnostics(filePath string, lsps map[string]*lsp.Client) string {
217214 for location, diags := range diagnostics {
218215 isCurrentFile := location.Path() == filePath
219216
220− // Group diagnostics by severity for better organization
221217 for _, diag := range diags {
222218 formattedDiag := formatDiagnostic(location.Path(), diag, lspName)
223219
@@ -231,7 +227,6 @@ func appendDiagnostics(filePath string, lsps map[string]*lsp.Client) string {
231227 }
232228 }
233229
234− // Sort diagnostics by severity (errors first) and then by location
235230 sort.Slice(fileDiagnostics, func(i, j int) bool {
236231 iIsError := strings.HasPrefix(fileDiagnostics[i], "Error")
237232 jIsError := strings.HasPrefix(fileDiagnostics[j], "Error")
@@ -274,7 +269,6 @@ func appendDiagnostics(filePath string, lsps map[string]*lsp.Client) string {
274269 output += "\n</project_diagnostics>\n"
275270 }
276271
277− // Add summary counts
278272 if len(fileDiagnostics) > 0 || len(projectDiagnostics) > 0 {
279273 fileErrors := countSeverity(fileDiagnostics, "Error")
280274 fileWarnings := countSeverity(fileDiagnostics, "Warn")
@@ -290,7 +284,6 @@ func appendDiagnostics(filePath string, lsps map[string]*lsp.Client) string {
290284 return output
291285}
292286
293−// Helper function to count diagnostics by severity
294287func countSeverity(diagnostics []string, severity string) int {
295288 count := 0
296289 for _, diag := range diagnostics {
@@ -300,9 +293,3 @@ func countSeverity(diagnostics []string, severity string) int {
300293 }
301294 return count
302295}
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 (
1515 "github.com/sergi/go-diff/diffmatchpatch"
1616)
1717
18−type editTool struct {
19− lspClients map[string]*lsp.Client
20−}
21−
22−const (
23− EditToolName = "edit"
24−)
25−
2618type EditParams struct {
2719 FilePath string `json:"file_path"`
2820 OldString string `json:"old_string"`
@@ -36,10 +28,73 @@ type EditPermissionsParams struct {
3628 Diff string `json:"diff"`
3729}
3830
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+
3994func (e *editTool) Info() ToolInfo {
4095 return ToolInfo{
4196 Name: EditToolName,
42− Description: editDescription(),
97+ Description: editDescription,
4398 Parameters: map[string]any{
4499 "file_path": map[string]any{
45100 "type": "string",
@@ -58,7 +113,6 @@ func (e *editTool) Info() ToolInfo {
58113 }
59114}
60115
61−// Run implements Tool.
62116func (e *editTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
63117 var params EditParams
64118 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)
75129 }
76130
77131 if params.OldString == "" {
78− result, err := createNewFile(params.FilePath, params.NewString)
132+ result, err := e.createNewFile(params.FilePath, params.NewString)
79133 if err != nil {
80134 return NewTextErrorResponse(fmt.Sprintf("error creating file: %s", err)), nil
81135 }
@@ -83,26 +137,25 @@ func (e *editTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
83137 }
84138
85139 if params.NewString == "" {
86− result, err := deleteContent(params.FilePath, params.OldString)
140+ result, err := e.deleteContent(params.FilePath, params.OldString)
87141 if err != nil {
88142 return NewTextErrorResponse(fmt.Sprintf("error deleting content: %s", err)), nil
89143 }
90144 return NewTextResponse(result), nil
91145 }
92146
93− result, err := replaceContent(params.FilePath, params.OldString, params.NewString)
147+ result, err := e.replaceContent(params.FilePath, params.OldString, params.NewString)
94148 if err != nil {
95149 return NewTextErrorResponse(fmt.Sprintf("error replacing content: %s", err)), nil
96150 }
97151
98− // Wait for LSP diagnostics after editing the file
99152 waitForLspDiagnostics(ctx, params.FilePath, e.lspClients)
100153 result = fmt.Sprintf("<result>\n%s\n</result>\n", result)
101154 result += appendDiagnostics(params.FilePath, e.lspClients)
102155 return NewTextResponse(result), nil
103156}
104157
105−func createNewFile(filePath, content string) (string, error) {
158+func (e *editTool) createNewFile(filePath, content string) (string, error) {
106159 fileInfo, err := os.Stat(filePath)
107160 if err == nil {
108161 if fileInfo.IsDir() {
@@ -118,7 +171,7 @@ func createNewFile(filePath, content string) (string, error) {
118171 return "", fmt.Errorf("failed to create parent directories: %w", err)
119172 }
120173
121− p := permission.Default.Request(
174+ p := e.permissions.Request(
122175 permission.CreatePermissionRequest{
123176 Path: filepath.Dir(filePath),
124177 ToolName: EditToolName,
@@ -147,7 +200,7 @@ func createNewFile(filePath, content string) (string, error) {
147200 return "File created: " + filePath, nil
148201}
149202
150−func deleteContent(filePath, oldString string) (string, error) {
203+func (e *editTool) deleteContent(filePath, oldString string) (string, error) {
151204 fileInfo, err := os.Stat(filePath)
152205 if err != nil {
153206 if os.IsNotExist(err) {
@@ -190,7 +243,7 @@ func deleteContent(filePath, oldString string) (string, error) {
190243
191244 newContent := oldContent[:index] + oldContent[index+len(oldString):]
192245
193− p := permission.Default.Request(
246+ p := e.permissions.Request(
194247 permission.CreatePermissionRequest{
195248 Path: filepath.Dir(filePath),
196249 ToolName: EditToolName,
@@ -219,7 +272,7 @@ func deleteContent(filePath, oldString string) (string, error) {
219272 return "Content deleted from file: " + filePath, nil
220273}
221274
222−func replaceContent(filePath, oldString, newString string) (string, error) {
275+func (e *editTool) replaceContent(filePath, oldString, newString string) (string, error) {
223276 fileInfo, err := os.Stat(filePath)
224277 if err != nil {
225278 if os.IsNotExist(err) {
@@ -268,7 +321,7 @@ func replaceContent(filePath, oldString, newString string) (string, error) {
268321
269322 diff := GenerateDiff(oldContent[startIndex:oldEndIndex], newContent[startIndex:newEndIndex])
270323
271− p := permission.Default.Request(
324+ p := e.permissions.Request(
272325 permission.CreatePermissionRequest{
273326 Path: filepath.Dir(filePath),
274327 ToolName: EditToolName,
@@ -305,7 +358,6 @@ func GenerateDiff(oldContent, newContent string) string {
305358 diffs = dmp.DiffCleanupSemantic(diffs)
306359 buff := strings.Builder{}
307360
308− // Add a header to make the diff more readable
309361 buff.WriteString("Changes:\n")
310362
311363 for _, diff := range diffs {
@@ -327,10 +379,8 @@ func GenerateDiff(oldContent, newContent string) string {
327379 _, _ = buff.WriteString("- " + line + "\n")
328380 }
329381 case diffmatchpatch.DiffEqual:
330− // Only show a small context for unchanged text
331382 lines := strings.Split(text, "\n")
332383 if len(lines) > 3 {
333− // Show only first and last line of context with a separator
334384 if lines[0] != "" {
335385 _, _ = buff.WriteString(" " + lines[0] + "\n")
336386 }
@@ -339,7 +389,6 @@ func GenerateDiff(oldContent, newContent string) string {
339389 _, _ = buff.WriteString(" " + lines[len(lines)-1] + "\n")
340390 }
341391 } else {
342− // Show all lines for small contexts
343392 for _, line := range lines {
344393 if line == "" {
345394 continue
@@ -351,59 +400,3 @@ func GenerateDiff(oldContent, newContent string) string {
351400 }
352401 return buff.String()
353402}
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 (
1515 "github.com/kujtimiihoxha/termai/internal/permission"
1616)
1717
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+
1835const (
1936 FetchToolName = "fetch"
2037 fetchToolDescription = `Fetches content from a URL and returns it in the specified format.
@@ -48,27 +65,12 @@ TIPS:
4865- Set appropriate timeouts for potentially slow websites`
4966)
5067
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 {
6869 return &fetchTool{
6970 client: &http.Client{
7071 Timeout: 30 * time.Second,
7172 },
73+ permissions: permissions,
7274 }
7375}
7476
@@ -113,7 +115,7 @@ func (t *fetchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
113115 return NewTextErrorResponse("URL must start with http:// or https://"), nil
114116 }
115117
116− p := permission.Default.Request(
118+ p := t.permissions.Request(
117119 permission.CreatePermissionRequest{
118120 Path: config.WorkingDirectory(),
119121 ToolName: FetchToolName,
@@ -220,4 +222,3 @@ func convertHTMLToMarkdown(html string) (string, error) {
220222
221223 return markdown, nil
222224}
223−
internal/llm/tools/glob.go+42−62
@@ -15,10 +15,42 @@ import (
1515 "github.com/kujtimiihoxha/termai/internal/config"
1616)
1717
18−type globTool struct{}
19−
2018const (
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`
2254)
2355
2456type fileInfo struct {
@@ -31,10 +63,16 @@ type GlobParams struct {
3163 Path string `json:"path"`
3264}
3365
66+type globTool struct{}
67+
68+func NewGlobTool() BaseTool {
69+ return &globTool{}
70+}
71+
3472func (g *globTool) Info() ToolInfo {
3573 return ToolInfo{
3674 Name: GlobToolName,
37− Description: globDescription(),
75+ Description: globDescription,
3876 Parameters: map[string]any{
3977 "pattern": map[string]any{
4078 "type": "string",
@@ -49,7 +87,6 @@ func (g *globTool) Info() ToolInfo {
4987 }
5088}
5189
52−// Run implements Tool.
5390func (g *globTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
5491 var params GlobParams
5592 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)
6097 return NewTextErrorResponse("pattern is required"), nil
6198 }
6299
63− // If path is empty, use current working directory
64100 searchPath := params.Path
65101 if searchPath == "" {
66102 searchPath = config.WorkingDirectory()
@@ -71,7 +107,6 @@ func (g *globTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
71107 return NewTextErrorResponse(fmt.Sprintf("error performing glob search: %s", err)), nil
72108 }
73109
74− // Format the output for the assistant
75110 var output string
76111 if len(files) == 0 {
77112 output = "No files found"
@@ -86,28 +121,20 @@ func (g *globTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
86121}
87122
88123func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) {
89− // Make sure pattern starts with the search path if not absolute
90124 if !strings.HasPrefix(pattern, "/") && !strings.HasPrefix(pattern, searchPath) {
91− // If searchPath doesn't end with a slash, add one before appending the pattern
92125 if !strings.HasSuffix(searchPath, "/") {
93126 searchPath += "/"
94127 }
95128 pattern = searchPath + pattern
96129 }
97130
98− // Open the filesystem for walking
99131 fsys := os.DirFS("/")
100132
101− // Convert the absolute pattern to a relative one for the DirFS
102− // DirFS uses the root directory ("/") so we should strip leading "/"
103133 relPattern := strings.TrimPrefix(pattern, "/")
104134
105− // Collect matching files
106135 var matches []fileInfo
107136
108− // Use doublestar to walk the filesystem and find matches
109137 err := doublestar.GlobWalk(fsys, relPattern, func(path string, d fs.DirEntry) error {
110− // Skip directories from results
111138 if d.IsDir() {
112139 return nil
113140 }
@@ -115,20 +142,17 @@ func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) {
115142 return nil
116143 }
117144
118− // Get file info for modification time
119145 info, err := d.Info()
120146 if err != nil {
121147 return nil // Skip files we can't access
122148 }
123149
124− // Add to matches
125150 absPath := "/" + path // Restore absolute path
126151 matches = append(matches, fileInfo{
127152 path: absPath,
128153 modTime: info.ModTime(),
129154 })
130155
131− // Check limit
132156 if len(matches) >= limit*2 { // Collect more than needed for sorting
133157 return fs.SkipAll
134158 }
@@ -139,18 +163,15 @@ func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) {
139163 return nil, false, fmt.Errorf("glob walk error: %w", err)
140164 }
141165
142− // Sort files by modification time (newest first)
143166 sort.Slice(matches, func(i, j int) bool {
144167 return matches[i].modTime.After(matches[j].modTime)
145168 })
146169
147− // Check if we need to truncate the results
148170 truncated := len(matches) > limit
149171 if truncated {
150172 matches = matches[:limit]
151173 }
152174
153− // Extract just the paths
154175 results := make([]string, len(matches))
155176 for i, m := range matches {
156177 results[i] = m.path
@@ -163,44 +184,3 @@ func skipHidden(path string) bool {
163184 base := filepath.Base(path)
164185 return base != "." && strings.HasPrefix(base, ".")
165186}
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 (
1616 "github.com/kujtimiihoxha/termai/internal/config"
1717)
1818
19−type grepTool struct{}
20−
21−const (
22− GrepToolName = "grep"
23−)
24−
2519type GrepParams struct {
2620 Pattern string `json:"pattern"`
2721 Path string `json:"path"`
@@ -33,10 +27,54 @@ type grepMatch struct {
3327 modTime time.Time
3428}
3529
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+
3674func (g *grepTool) Info() ToolInfo {
3775 return ToolInfo{
3876 Name: GrepToolName,
39− Description: grepDescription(),
77+ Description: grepDescription,
4078 Parameters: map[string]any{
4179 "pattern": map[string]any{
4280 "type": "string",
@@ -55,7 +93,6 @@ func (g *grepTool) Info() ToolInfo {
5593 }
5694}
5795
58−// Run implements Tool.
5996func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
6097 var params GrepParams
6198 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)
66103 return NewTextErrorResponse("pattern is required"), nil
67104 }
68105
69− // If path is empty, use current working directory
70106 searchPath := params.Path
71107 if searchPath == "" {
72108 searchPath = config.WorkingDirectory()
@@ -77,7 +113,6 @@ func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
77113 return NewTextErrorResponse(fmt.Sprintf("error searching files: %s", err)), nil
78114 }
79115
80− // Format the output for the assistant
81116 var output string
82117 if len(matches) == 0 {
83118 output = "No files found"
@@ -103,28 +138,23 @@ func pluralize(count int) string {
103138}
104139
105140func searchFiles(pattern, rootPath, include string, limit int) ([]string, bool, error) {
106− // First try using ripgrep if available for better performance
107141 matches, err := searchWithRipgrep(pattern, rootPath, include)
108142 if err != nil {
109− // Fall back to manual regex search if ripgrep is not available
110143 matches, err = searchFilesWithRegex(pattern, rootPath, include)
111144 if err != nil {
112145 return nil, false, err
113146 }
114147 }
115148
116− // Sort files by modification time (newest first)
117149 sort.Slice(matches, func(i, j int) bool {
118150 return matches[i].modTime.After(matches[j].modTime)
119151 })
120152
121− // Check if we need to truncate the results
122153 truncated := len(matches) > limit
123154 if truncated {
124155 matches = matches[:limit]
125156 }
126157
127− // Extract just the paths
128158 results := make([]string, len(matches))
129159 for i, m := range matches {
130160 results[i] = m.path
@@ -149,7 +179,6 @@ func searchWithRipgrep(pattern, path, include string) ([]grepMatch, error) {
149179 output, err := cmd.Output()
150180 if err != nil {
151181 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
153182 return []grepMatch{}, nil
154183 }
155184 return nil, err
@@ -203,17 +232,14 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
203232 return nil // Skip directories
204233 }
205234
206− // Skip hidden files
207235 if skipHidden(path) {
208236 return nil
209237 }
210238
211− // Check include pattern if provided
212239 if includePattern != nil && !includePattern.MatchString(path) {
213240 return nil
214241 }
215242
216− // Check file contents for the pattern
217243 match, err := fileContainsPattern(path, regex)
218244 if err != nil {
219245 return nil // Skip files we can't read
@@ -225,7 +251,6 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
225251 modTime: info.ModTime(),
226252 })
227253
228− // Check if we've hit the limit (collect double for sorting)
229254 if len(matches) >= 200 {
230255 return filepath.SkipAll
231256 }
@@ -270,44 +295,3 @@ func globToRegex(glob string) string {
270295
271296 return regexPattern
272297}
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 (
1111 "github.com/kujtimiihoxha/termai/internal/config"
1212)
1313
14−type lsTool struct{}
15−
16−const (
17− LSToolName = "ls"
18− MaxLSFiles = 1000
19−)
20−
2114type LSParams struct {
2215 Path string `json:"path"`
2316 Ignore []string `json:"ignore"`
@@ -30,10 +23,49 @@ type TreeNode struct {
3023 Children []*TreeNode `json:"children,omitempty"`
3124}
3225
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+
3365func (l *lsTool) Info() ToolInfo {
3466 return ToolInfo{
3567 Name: LSToolName,
36− Description: lsDescription(),
68+ Description: lsDescription,
3769 Parameters: map[string]any{
3870 "path": map[string]any{
3971 "type": "string",
@@ -51,25 +83,21 @@ func (l *lsTool) Info() ToolInfo {
5183 }
5284}
5385
54−// Run implements Tool.
5586func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
5687 var params LSParams
5788 if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
5889 return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
5990 }
6091
61− // If path is empty, use current working directory
6292 searchPath := params.Path
6393 if searchPath == "" {
6494 searchPath = config.WorkingDirectory()
6595 }
6696
67− // Ensure the path is absolute
6897 if !filepath.IsAbs(searchPath) {
6998 searchPath = filepath.Join(config.WorkingDirectory(), searchPath)
7099 }
71100
72− // Check if the path exists
73101 if _, err := os.Stat(searchPath); os.IsNotExist(err) {
74102 return NewTextErrorResponse(fmt.Sprintf("path does not exist: %s", searchPath)), nil
75103 }
@@ -129,12 +157,10 @@ func listDirectory(initialPath string, ignorePatterns []string, limit int) ([]st
129157func shouldSkip(path string, ignorePatterns []string) bool {
130158 base := filepath.Base(path)
131159
132− // Skip hidden files and directories
133160 if base != "." && strings.HasPrefix(base, ".") {
134161 return true
135162 }
136163
137− // Skip common directories and files
138164 commonIgnored := []string{
139165 "__pycache__",
140166 "node_modules",
@@ -156,32 +182,26 @@ func shouldSkip(path string, ignorePatterns []string) bool {
156182 "*.exe",
157183 }
158184
159− // Skip __pycache__ directories
160185 if strings.Contains(path, filepath.Join("__pycache__", "")) {
161186 return true
162187 }
163188
164− // Check against common ignored patterns
165189 for _, ignored := range commonIgnored {
166190 if strings.HasSuffix(ignored, "/") {
167− // Directory pattern
168191 if strings.Contains(path, filepath.Join(ignored[:len(ignored)-1], "")) {
169192 return true
170193 }
171194 } else if strings.HasPrefix(ignored, "*.") {
172− // File extension pattern
173195 if strings.HasSuffix(base, ignored[1:]) {
174196 return true
175197 }
176198 } else {
177− // Exact match
178199 if base == ignored {
179200 return true
180201 }
181202 }
182203 }
183204
184− // Check against ignore patterns
185205 for _, pattern := range ignorePatterns {
186206 matched, err := filepath.Match(pattern, base)
187207 if err == nil && matched {
@@ -283,38 +303,3 @@ func printNode(builder *strings.Builder, node *TreeNode, level int) {
283303 }
284304 }
285305}
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 (
1111 "time"
1212)
1313
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+
1425const (
1526 SourcegraphToolName = "sourcegraph"
1627 sourcegraphToolDescription = `Search code across public repositories using Sourcegraph's GraphQL API.
@@ -110,17 +121,6 @@ TIPS:
110121- For more details on query syntax, visit: https://docs.sourcegraph.com/code_search/queries`
111122)
112123
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−
124124func NewSourcegraphTool() BaseTool {
125125 return &sourcegraphTool{
126126 client: &http.Client{
@@ -165,7 +165,6 @@ func (t *sourcegraphTool) Run(ctx context.Context, call ToolCall) (ToolResponse,
165165 return NewTextErrorResponse("Query parameter is required"), nil
166166 }
167167
168− // Set default count if not specified
169168 if params.Count <= 0 {
170169 params.Count = 10
171170 } else if params.Count > 20 {
@@ -186,8 +185,6 @@ func (t *sourcegraphTool) Run(ctx context.Context, call ToolCall) (ToolResponse,
186185 }
187186 }
188187
189− // GraphQL query for Sourcegraph search
190− // Create a properly escaped JSON structure
191188 type graphqlRequest struct {
192189 Query string `json:"query"`
193190 Variables struct {
@@ -200,14 +197,12 @@ func (t *sourcegraphTool) Run(ctx context.Context, call ToolCall) (ToolResponse,
200197 }
201198 request.Variables.Query = params.Query
202199
203− // Marshal to JSON to ensure proper escaping
204200 graphqlQueryBytes, err := json.Marshal(request)
205201 if err != nil {
206202 return NewTextErrorResponse("Failed to create GraphQL request: " + err.Error()), nil
207203 }
208204 graphqlQuery := string(graphqlQueryBytes)
209205
210− // Create request to Sourcegraph API
211206 req, err := http.NewRequestWithContext(
212207 ctx,
213208 "POST",
@@ -228,7 +223,6 @@ func (t *sourcegraphTool) Run(ctx context.Context, call ToolCall) (ToolResponse,
228223 defer resp.Body.Close()
229224
230225 if resp.StatusCode != http.StatusOK {
231− // log the error response
232226 body, _ := io.ReadAll(resp.Body)
233227 if len(body) > 0 {
234228 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,
241235 return NewTextErrorResponse("Failed to read response body: " + err.Error()), nil
242236 }
243237
244− // Parse the GraphQL response
245238 var result map[string]any
246239 if err = json.Unmarshal(body, &result); err != nil {
247240 return NewTextErrorResponse("Failed to parse response: " + err.Error()), nil
248241 }
249242
250− // Format the results in a readable way
251243 formattedResults, err := formatSourcegraphResults(result, params.ContextWindow)
252244 if err != nil {
253245 return NewTextErrorResponse("Failed to format results: " + err.Error()), nil
@@ -259,7 +251,6 @@ func (t *sourcegraphTool) Run(ctx context.Context, call ToolCall) (ToolResponse,
259251func formatSourcegraphResults(result map[string]any, contextWindow int) (string, error) {
260252 var buffer strings.Builder
261253
262− // Check for errors in the GraphQL response
263254 if errors, ok := result["errors"].([]any); ok && len(errors) > 0 {
264255 buffer.WriteString("## Sourcegraph API Error\n\n")
265256 for _, err := range errors {
@@ -272,7 +263,6 @@ func formatSourcegraphResults(result map[string]any, contextWindow int) (string,
272263 return buffer.String(), nil
273264 }
274265
275− // Extract data from the response
276266 data, ok := result["data"].(map[string]any)
277267 if !ok {
278268 return "", fmt.Errorf("invalid response format: missing data field")
@@ -288,7 +278,6 @@ func formatSourcegraphResults(result map[string]any, contextWindow int) (string,
288278 return "", fmt.Errorf("invalid response format: missing results field")
289279 }
290280
291− // Write search metadata
292281 matchCount, _ := searchResults["matchCount"].(float64)
293282 resultCount, _ := searchResults["resultCount"].(float64)
294283 limitHit, _ := searchResults["limitHit"].(bool)
@@ -302,33 +291,28 @@ func formatSourcegraphResults(result map[string]any, contextWindow int) (string,
302291
303292 buffer.WriteString("\n")
304293
305− // Process results
306294 results, ok := searchResults["results"].([]any)
307295 if !ok || len(results) == 0 {
308296 buffer.WriteString("No results found. Try a different query.\n")
309297 return buffer.String(), nil
310298 }
311299
312− // Limit to 10 results
313300 maxResults := 10
314301 if len(results) > maxResults {
315302 results = results[:maxResults]
316303 }
317304
318− // Process each result
319305 for i, res := range results {
320306 fileMatch, ok := res.(map[string]any)
321307 if !ok {
322308 continue
323309 }
324310
325− // Skip non-FileMatch results
326311 typeName, _ := fileMatch["__typename"].(string)
327312 if typeName != "FileMatch" {
328313 continue
329314 }
330315
331− // Extract repository and file information
332316 repo, _ := fileMatch["repository"].(map[string]any)
333317 file, _ := fileMatch["file"].(map[string]any)
334318 lineMatches, _ := fileMatch["lineMatches"].([]any)
@@ -348,7 +332,6 @@ func formatSourcegraphResults(result map[string]any, contextWindow int) (string,
348332 buffer.WriteString(fmt.Sprintf("URL: %s\n\n", fileURL))
349333 }
350334
351− // Show line matches with context
352335 if len(lineMatches) > 0 {
353336 for _, lm := range lineMatches {
354337 lineMatch, ok := lm.(map[string]any)
@@ -359,13 +342,11 @@ func formatSourcegraphResults(result map[string]any, contextWindow int) (string,
359342 lineNumber, _ := lineMatch["lineNumber"].(float64)
360343 preview, _ := lineMatch["preview"].(string)
361344
362− // Extract context from file content if available
363345 if fileContent != "" {
364346 lines := strings.Split(fileContent, "\n")
365347
366348 buffer.WriteString("```\n")
367349
368− // Display context before the match (up to 10 lines)
369350 startLine := max(1, int(lineNumber)-contextWindow)
370351
371352 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,
374355 }
375356 }
376357
377− // Display the matching line (highlighted)
378358 buffer.WriteString(fmt.Sprintf("%d| %s\n", int(lineNumber), preview))
379359
380− // Display context after the match (up to 10 lines)
381360 endLine := int(lineNumber) + contextWindow
382361
383362 for j := int(lineNumber); j < endLine && j < len(lines); j++ {
@@ -388,7 +367,6 @@ func formatSourcegraphResults(result map[string]any, contextWindow int) (string,
388367
389368 buffer.WriteString("```\n\n")
390369 } else {
391− // If file content is not available, just show the preview
392370 buffer.WriteString("```\n")
393371 buffer.WriteString(fmt.Sprintf("%d| %s\n", int(lineNumber), preview))
394372 buffer.WriteString("```\n\n")
internal/llm/tools/sourcegraph_test.go−29
@@ -5,7 +5,6 @@ import (
55 "encoding/json"
66 "testing"
77
8− "github.com/kujtimiihoxha/termai/internal/permission"
98 "github.com/stretchr/testify/assert"
109 "github.com/stretchr/testify/require"
1110)
@@ -23,13 +22,6 @@ func TestSourcegraphTool_Info(t *testing.T) {
2322}
2423
2524func 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−
3325 t.Run("handles missing query parameter", func(t *testing.T) {
3426 tool := NewSourcegraphTool()
3527 params := SourcegraphParams{
@@ -61,27 +53,6 @@ func TestSourcegraphTool_Run(t *testing.T) {
6153 assert.Contains(t, response.Content, "Failed to parse sourcegraph parameters")
6254 })
6355
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−
8556 t.Run("normalizes count parameter", func(t *testing.T) {
8657 // Test cases for count normalization
8758 testCases := []struct {
internal/llm/tools/view.go+41−44
@@ -14,6 +14,12 @@ import (
1414 "github.com/kujtimiihoxha/termai/internal/lsp"
1515)
1616
17+type ViewParams struct {
18+ FilePath string `json:"file_path"`
19+ Offset int `json:"offset"`
20+ Limit int `json:"limit"`
21+}
22+
1723type viewTool struct {
1824 lspClients map[string]*lsp.Client
1925}
@@ -23,18 +29,48 @@ const (
2329 MaxReadSize = 250 * 1024
2430 DefaultReadLimit = 2000
2531 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`
2662)
2763
28−type ViewParams struct {
29− FilePath string `json:"file_path"`
30− Offset int `json:"offset"`
31− Limit int `json:"limit"`
64+func NewViewTool(lspClients map[string]*lsp.Client) BaseTool {
65+ return &viewTool{
66+ lspClients,
67+ }
3268}
3369
3470func (v *viewTool) Info() ToolInfo {
3571 return ToolInfo{
3672 Name: ViewToolName,
37− Description: viewDescription(),
73+ Description: viewDescription,
3874 Parameters: map[string]any{
3975 "file_path": map[string]any{
4076 "type": "string",
@@ -262,42 +298,3 @@ func (s *LineScanner) Text() string {
262298func (s *LineScanner) Err() error {
263299 return s.scanner.Err()
264300}
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 (
1313 "github.com/kujtimiihoxha/termai/internal/permission"
1414)
1515
16−type writeTool struct {
17− lspClients map[string]*lsp.Client
18−}
19−
20−const (
21− WriteToolName = "write"
22−)
23−
2416type WriteParams struct {
2517 FilePath string `json:"file_path"`
2618 Content string `json:"content"`
@@ -31,10 +23,54 @@ type WritePermissionsParams struct {
3123 Content string `json:"content"`
3224}
3325
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+
3470func (w *writeTool) Info() ToolInfo {
3571 return ToolInfo{
3672 Name: WriteToolName,
37− Description: writeDescription(),
73+ Description: writeDescription,
3874 Parameters: map[string]any{
3975 "file_path": map[string]any{
4076 "type": "string",
@@ -49,7 +85,6 @@ func (w *writeTool) Info() ToolInfo {
4985 }
5086}
5187
52−// Run implements Tool.
5388func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
5489 var params WriteParams
5590 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
6499 return NewTextErrorResponse("content is required"), nil
65100 }
66101
67− // Handle relative paths
68102 filePath := params.FilePath
69103 if !filepath.IsAbs(filePath) {
70104 filePath = filepath.Join(config.WorkingDirectory(), filePath)
71105 }
72106
73− // Check if file exists and is a directory
74107 fileInfo, err := os.Stat(filePath)
75108 if err == nil {
76109 if fileInfo.IsDir() {
77110 return NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
78111 }
79112
80− // Check if file was modified since last read
81113 modTime := fileInfo.ModTime()
82114 lastRead := getLastReadTime(filePath)
83115 if modTime.After(lastRead) {
@@ -85,7 +117,6 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
85117 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))), nil
86118 }
87119
88− // Optional: Get old content for diff
89120 oldContent, readErr := os.ReadFile(filePath)
90121 if readErr == nil && string(oldContent) == params.Content {
91122 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
94125 return NewTextErrorResponse(fmt.Sprintf("Failed to access file: %s", err)), nil
95126 }
96127
97− // Create parent directories if needed
98128 dir := filepath.Dir(filePath)
99129 if err = os.MkdirAll(dir, 0o755); err != nil {
100130 return NewTextErrorResponse(fmt.Sprintf("Failed to create parent directories: %s", err)), nil
101131 }
102132
103− // Get old content for diff if file exists
104133 oldContent := ""
105134 if fileInfo != nil && !fileInfo.IsDir() {
106135 oldBytes, readErr := os.ReadFile(filePath)
@@ -108,8 +137,8 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
108137 oldContent = string(oldBytes)
109138 }
110139 }
111−
112− p := permission.Default.Request(
140+
141+ p := w.permissions.Request(
113142 permission.CreatePermissionRequest{
114143 Path: filePath,
115144 ToolName: WriteToolName,
@@ -125,16 +154,13 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
125154 return NewTextErrorResponse(fmt.Sprintf("Permission denied to create file: %s", filePath)), nil
126155 }
127156
128− // Write the file
129157 err = os.WriteFile(filePath, []byte(params.Content), 0o644)
130158 if err != nil {
131159 return NewTextErrorResponse(fmt.Sprintf("Failed to write file: %s", err)), nil
132160 }
133161
134− // Record the file write
135162 recordFileWrite(filePath)
136163 recordFileRead(filePath)
137− // Wait for LSP diagnostics after writing the file
138164 waitForLspDiagnostics(ctx, filePath, w.lspClients)
139165
140166 result := fmt.Sprintf("File successfully written: %s", filePath)
@@ -142,40 +168,3 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
142168 result += appendDiagnostics(filePath, w.lspClients)
143169 return NewTextResponse(result), nil
144170}
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 (
99 "time"
1010
1111 "github.com/kujtimiihoxha/termai/internal/lsp"
12− "github.com/kujtimiihoxha/termai/internal/permission"
1312 "github.com/stretchr/testify/assert"
1413 "github.com/stretchr/testify/require"
1514)
1615
1716func TestWriteTool_Info(t *testing.T) {
18− tool := NewWriteTool(make(map[string]*lsp.Client))
17+ tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true))
1918 info := tool.Info()
2019
2120 assert.Equal(t, WriteToolName, info.Name)
@@ -27,21 +26,13 @@ func TestWriteTool_Info(t *testing.T) {
2726}
2827
2928func 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−
3729 // Create a temporary directory for testing
3830 tempDir, err := os.MkdirTemp("", "write_tool_test")
3931 require.NoError(t, err)
4032 defer os.RemoveAll(tempDir)
4133
4234 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))
4536
4637 filePath := filepath.Join(tempDir, "new_file.txt")
4738 content := "This is a test content"
@@ -70,8 +61,7 @@ func TestWriteTool_Run(t *testing.T) {
7061 })
7162
7263 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))
7565
7666 filePath := filepath.Join(tempDir, "nested/dirs/new_file.txt")
7767 content := "Content in nested directory"
@@ -100,8 +90,7 @@ func TestWriteTool_Run(t *testing.T) {
10090 })
10191
10292 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))
10594
10695 // Create a file first
10796 filePath := filepath.Join(tempDir, "existing_file.txt")
@@ -138,8 +127,7 @@ func TestWriteTool_Run(t *testing.T) {
138127 })
139128
140129 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))
143131
144132 call := ToolCall{
145133 Name: WriteToolName,
@@ -152,8 +140,7 @@ func TestWriteTool_Run(t *testing.T) {
152140 })
153141
154142 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))
157144
158145 params := WriteParams{
159146 FilePath: "",
@@ -174,8 +161,7 @@ func TestWriteTool_Run(t *testing.T) {
174161 })
175162
176163 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))
179165
180166 params := WriteParams{
181167 FilePath: filepath.Join(tempDir, "file.txt"),
@@ -196,8 +182,7 @@ func TestWriteTool_Run(t *testing.T) {
196182 })
197183
198184 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))
201186
202187 // Create a directory
203188 dirPath := filepath.Join(tempDir, "test_dir")
@@ -223,8 +208,7 @@ func TestWriteTool_Run(t *testing.T) {
223208 })
224209
225210 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))
228212
229213 filePath := filepath.Join(tempDir, "permission_denied.txt")
230214 params := WriteParams{
@@ -250,8 +234,7 @@ func TestWriteTool_Run(t *testing.T) {
250234 })
251235
252236 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))
255238
256239 // Create a file
257240 filePath := filepath.Join(tempDir, "modified_file.txt")
@@ -292,8 +275,7 @@ func TestWriteTool_Run(t *testing.T) {
292275 })
293276
294277 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))
297279
298280 // Create a file
299281 filePath := filepath.Join(tempDir, "identical_content.txt")
@@ -323,4 +305,3 @@ func TestWriteTool_Run(t *testing.T) {
323305 assert.Contains(t, response.Content, "already contains the exact content")
324306 })
325307}
326−
internal/permission/permission.go−2
@@ -100,5 +100,3 @@ func NewPermissionService() Service {
100100 sessionPermissions: make([]PermissionRequest, 0),
101101 }
102102}
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) {
8080 case dialog.PermissionResponseMsg:
8181 switch msg.Action {
8282 case dialog.PermissionAllow:
83− permission.Default.Grant(msg.Permission)
83+ a.app.Permissions.Grant(msg.Permission)
8484 case dialog.PermissionAllowForSession:
85− permission.Default.GrantPersistant(msg.Permission)
85+ a.app.Permissions.GrantPersistant(msg.Permission)
8686 case dialog.PermissionDeny:
87− permission.Default.Deny(msg.Permission)
87+ a.app.Permissions.Deny(msg.Permission)
8888 }
8989 case vimtea.EditorModeMsg:
9090 a.editorMode = msg.Mode