From 6297d3679ea09ccf77cff7fb63e44cc8701d4ae1 Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Sun, 22 Feb 2026 06:37:36 +0900 Subject: [PATCH] feat: extract project dir from file tool paths too, not just exec cd Refactored extractProjectDir to accept any tool call. For read_file, edit_file etc., the file path is used to detect the project directory. Extracted shared logic into projectDirFromPath. Co-Authored-By: Claude Opus 4.6 --- pkg/agent/loop.go | 69 +++++++++++++++++++++++++------------- pkg/agent/loop_test.go | 75 +++++++++++++++++++++++++++++------------- 2 files changed, 99 insertions(+), 45 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index a37b9f8cb..8035d939e 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -956,37 +956,35 @@ var cdPrefixPattern = regexp.MustCompile(`^cd\s+(\S+)\s*&&\s*`) // argument (e.g. "-A 20") are kept because removing them would lose context. var optFlagPattern = regexp.MustCompile(`\s+--?\w[\w-]*(=\S*)?`) -// extractProjectDir extracts the project directory name from an exec command's -// "cd && ..." prefix by stripping the workspace prefix and taking the -// first remaining path component. -// Returns "" if no cd prefix or no deeper directory exists. -func extractProjectDir(cmd, workspace string) string { - m := cdPrefixPattern.FindStringSubmatch(cmd) - if len(m) < 2 { +// projectDirFromPath extracts the project directory name from an absolute path +// by stripping the workspace prefix and returning the first meaningful component. +// Generic directory names like "projects" or "repos" are skipped. +// Returns "" if the path doesn't extend beyond workspace. +func projectDirFromPath(absPath, workspace string) string { + absPath = strings.TrimRight(absPath, "/\\") + if absPath == "" { return "" } - cdPath := strings.TrimRight(m[1], "/\\") if workspace == "" { - if idx := strings.LastIndex(cdPath, "/"); idx >= 0 { - return cdPath[idx+1:] + if idx := strings.LastIndex(absPath, "/"); idx >= 0 { + return absPath[idx+1:] } - return cdPath + return absPath } ws := strings.TrimRight(workspace, "/\\") - rest := strings.TrimPrefix(cdPath, ws) - if rest == cdPath { + rest := strings.TrimPrefix(absPath, ws) + if rest == absPath { // workspace not a prefix — fall back to last component - if idx := strings.LastIndex(cdPath, "/"); idx >= 0 { - return cdPath[idx+1:] + if idx := strings.LastIndex(absPath, "/"); idx >= 0 { + return absPath[idx+1:] } - return cdPath + return absPath } rest = strings.TrimLeft(rest, "/\\") if rest == "" { return "" } - // Take first path component (e.g. "projects/my-app" → "projects") - // But if it looks like a generic dir (projects, src, workspace), go deeper + // Take first path component, skipping generic directory names parts := strings.SplitN(rest, "/", 3) if len(parts) >= 2 { first := strings.ToLower(parts[0]) @@ -997,6 +995,33 @@ func extractProjectDir(cmd, workspace string) string { return parts[0] } +// extractProjectDir extracts the project directory name from a tool call. +// For exec: parses the "cd && ..." prefix. +// For file tools: uses the file path directly. +// Returns "" if no project directory can be determined. +func extractProjectDir(toolName string, args map[string]interface{}, workspace string) string { + switch toolName { + case "exec": + cmd, _ := args["command"].(string) + if cmd == "" { + return "" + } + m := cdPrefixPattern.FindStringSubmatch(cmd) + if len(m) < 2 { + return "" + } + return projectDirFromPath(m[1], workspace) + + case "read_file", "write_file", "edit_file", "append_file", "list_dir": + path, _ := args["path"].(string) + if path == "" { + return "" + } + return projectDirFromPath(path, workspace) + } + return "" +} + // buildArgsSnippet produces a human-friendly snippet for the tool log. // For exec: extracts the command and strips the leading "cd && ". // For file tools: extracts the path and strips the workspace prefix. @@ -1534,11 +1559,9 @@ func (al *AgentLoop) runLLMIteration( ArgsSnip: buildArgsSnippet(tc.Name, tc.Arguments, agent.Workspace), Result: "\u23F3", }) - // Detect project directory from exec cd prefix (once) - if task.projectDir == "" && tc.Name == "exec" { - if cmd, _ := tc.Arguments["command"].(string); cmd != "" { - task.projectDir = extractProjectDir(cmd, agent.Workspace) - } + // Detect project directory from tool call args (once) + if task.projectDir == "" { + task.projectDir = extractProjectDir(tc.Name, tc.Arguments, agent.Workspace) } } task.mu.Unlock() diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 32677a102..3f66f7740 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -1604,54 +1604,85 @@ func TestBuildRichStatus_ProjectDir(t *testing.T) { } func TestExtractProjectDir(t *testing.T) { + ws := "/home/user/.picoclaw/workspace" tests := []struct { name string - cmd string + toolName string + args map[string]interface{} workspace string want string }{ + // exec: cd prefix { - name: "cd into projects subdir", - cmd: "cd /home/user/.picoclaw/workspace/projects/terra-py-form && pytest", - workspace: "/home/user/.picoclaw/workspace", + name: "exec cd into projects subdir", + toolName: "exec", + args: map[string]interface{}{"command": "cd /home/user/.picoclaw/workspace/projects/terra-py-form && pytest"}, + workspace: ws, want: "terra-py-form", }, { - name: "cd into projects subdir with trailing slash", - cmd: "cd /home/user/.picoclaw/workspace/projects/terra-py-form && ls", - workspace: "/home/user/.picoclaw/workspace/", + name: "exec cd with trailing slash workspace", + toolName: "exec", + args: map[string]interface{}{"command": "cd /home/user/.picoclaw/workspace/projects/terra-py-form && ls"}, + workspace: ws + "/", want: "terra-py-form", }, { - name: "cd into direct subdir", - cmd: "cd /home/user/.picoclaw/workspace/my-app && make build", - workspace: "/home/user/.picoclaw/workspace", + name: "exec cd into direct subdir", + toolName: "exec", + args: map[string]interface{}{"command": "cd /home/user/.picoclaw/workspace/my-app && make build"}, + workspace: ws, want: "my-app", }, { - name: "cd to workspace itself", - cmd: "cd /home/user/.picoclaw/workspace && ls", - workspace: "/home/user/.picoclaw/workspace", + name: "exec cd to workspace itself", + toolName: "exec", + args: map[string]interface{}{"command": "cd /home/user/.picoclaw/workspace && ls"}, + workspace: ws, want: "", }, { - name: "no cd prefix", - cmd: "pytest tests/", - workspace: "/home/user/.picoclaw/workspace", + name: "exec no cd prefix", + toolName: "exec", + args: map[string]interface{}{"command": "pytest tests/"}, + workspace: ws, want: "", }, + // file tools { - name: "cd to unrelated path", - cmd: "cd /tmp/build && make", - workspace: "/home/user/.picoclaw/workspace", - want: "build", + name: "read_file in projects subdir", + toolName: "read_file", + args: map[string]interface{}{"path": "/home/user/.picoclaw/workspace/projects/terra-py-form/src/main.py"}, + workspace: ws, + want: "terra-py-form", + }, + { + name: "edit_file in direct subdir", + toolName: "edit_file", + args: map[string]interface{}{"path": "/home/user/.picoclaw/workspace/my-app/README.md"}, + workspace: ws, + want: "my-app", + }, + { + name: "write_file at workspace root", + toolName: "write_file", + args: map[string]interface{}{"path": "/home/user/.picoclaw/workspace/notes.txt"}, + workspace: ws, + want: "notes.txt", + }, + { + name: "unknown tool returns empty", + toolName: "web_search", + args: map[string]interface{}{"query": "test"}, + workspace: ws, + want: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := extractProjectDir(tt.cmd, tt.workspace) + got := extractProjectDir(tt.toolName, tt.args, tt.workspace) if got != tt.want { - t.Errorf("extractProjectDir(%q, %q) = %q, want %q", tt.cmd, tt.workspace, got, tt.want) + t.Errorf("extractProjectDir(%q, args, %q) = %q, want %q", tt.toolName, tt.workspace, got, tt.want) } }) }