From 5ae2ece5151a94a5ccc2c3cebb6a06fc1d1bff69 Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Sun, 22 Feb 2026 06:50:03 +0900 Subject: [PATCH] feat: detect project dir via file path LCP convergence For file tools (read_file, edit_file, etc.), track the longest common directory prefix across all accessed file paths. As more files are touched, the LCP converges to the actual project root. Exec cd target remains authoritative; file LCP is used as fallback. Co-Authored-By: Claude Opus 4.6 --- pkg/agent/loop.go | 145 ++++++++++++++++++++------------ pkg/agent/loop_test.go | 187 ++++++++++++++++++++++------------------- 2 files changed, 192 insertions(+), 140 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index c340111ab..db12a97f5 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -44,8 +44,9 @@ type activeTask struct { interrupt chan string // buffered 1, for user message injection toolLog []toolLogEntry lastError *toolLogEntry // sticky: most recent error, persists across iterations - projectDir string // detected project directory name (from cd prefix) - mu sync.Mutex + projectDir string // detected from exec cd target (authoritative) + fileCommonDir string // LCP of file paths relative to workspace (fallback) + mu sync.Mutex } // toolLogEntry records a single tool call for the live terminal view. @@ -956,53 +957,81 @@ 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 a working directory name from a tool call. -// For exec: uses the basename of the cd target (the directory AI actually works in). -// For file tools: strips the workspace prefix and takes the first path component. -// No assumptions are made about directory naming conventions. -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 "" - } - // basename of cd target — the directory the AI actually cd's into - cdPath := strings.TrimRight(m[1], "/\\") - if idx := strings.LastIndex(cdPath, "/"); idx >= 0 { - return cdPath[idx+1:] - } - if idx := strings.LastIndex(cdPath, "\\"); idx >= 0 { - return cdPath[idx+1:] - } - return cdPath +// extractExecProjectDir extracts the basename of an exec cd target. +// Returns "" if the command has no cd prefix. +func extractExecProjectDir(args map[string]interface{}) string { + cmd, _ := args["command"].(string) + if cmd == "" { + return "" + } + m := cdPrefixPattern.FindStringSubmatch(cmd) + if len(m) < 2 { + return "" + } + cdPath := strings.TrimRight(m[1], "/\\") + if idx := strings.LastIndex(cdPath, "/"); idx >= 0 { + return cdPath[idx+1:] + } + if idx := strings.LastIndex(cdPath, "\\"); idx >= 0 { + return cdPath[idx+1:] + } + return cdPath +} - case "read_file", "write_file", "edit_file", "append_file", "list_dir": - path, _ := args["path"].(string) - if path == "" { - return "" +// fileParentRelDir returns the parent directory of a file path, relative to +// workspace. Returns "" if the path is not under workspace or has no parent. +func fileParentRelDir(filePath, workspace string) string { + ws := strings.TrimRight(workspace, "/\\") + if ws == "" { + return "" + } + rest := strings.TrimPrefix(filePath, ws) + if rest == filePath { + return "" // not under workspace + } + rest = strings.TrimLeft(rest, "/\\") + // Remove the filename — keep only the directory part + if idx := strings.LastIndexAny(rest, "/\\"); idx >= 0 { + return rest[:idx] + } + return "" // file is directly under workspace, no meaningful dir +} + +// commonDirPrefix computes the longest common directory prefix of two +// slash-separated paths. Returns "" if there is no common component. +func commonDirPrefix(a, b string) string { + partsA := strings.Split(a, "/") + partsB := strings.Split(b, "/") + n := len(partsA) + if len(partsB) < n { + n = len(partsB) + } + common := 0 + for i := 0; i < n; i++ { + if partsA[i] != partsB[i] { + break } - ws := strings.TrimRight(workspace, "/\\") - if ws == "" { - return "" + common = i + 1 + } + if common == 0 { + return "" + } + return strings.Join(partsA[:common], "/") +} + +// displayProjectDir returns the project directory name for status display. +// Prefers the authoritative exec-based projectDir; falls back to the +// basename of the file-based common directory. +func displayProjectDir(task *activeTask) string { + if task.projectDir != "" { + return task.projectDir + } + if task.fileCommonDir != "" { + dir := task.fileCommonDir + if idx := strings.LastIndex(dir, "/"); idx >= 0 { + return dir[idx+1:] } - rest := strings.TrimPrefix(path, ws) - if rest == path { - return "" // path not under workspace - } - rest = strings.TrimLeft(rest, "/\\") - if rest == "" { - return "" - } - // first component after workspace - if idx := strings.IndexAny(rest, "/\\"); idx >= 0 { - return rest[:idx] - } - return rest + return dir } return "" } @@ -1202,10 +1231,10 @@ func buildRichStatus(task *activeTask, isBackground bool, workspace string) stri sb.WriteByte('/') sb.WriteString(strconv.Itoa(task.MaxIter)) sb.WriteString(")\n") - // Workspace: prefer detected project dir, fall back to workspace basename + // Project directory: exec cd (authoritative) → file LCP → workspace basename sb.WriteString("\U0001F4C1 ") - if task.projectDir != "" { - sb.WriteString(task.projectDir) + if dir := displayProjectDir(task); dir != "" { + sb.WriteString(dir) } else if workspace != "" { project := strings.TrimRight(workspace, "/\\") if idx := strings.LastIndex(project, "/"); idx >= 0 { @@ -1544,9 +1573,21 @@ func (al *AgentLoop) runLLMIteration( ArgsSnip: buildArgsSnippet(tc.Name, tc.Arguments, agent.Workspace), Result: "\u23F3", }) - // Detect project directory from tool call args (once) - if task.projectDir == "" { - task.projectDir = extractProjectDir(tc.Name, tc.Arguments, agent.Workspace) + // Detect project directory + if task.projectDir == "" && tc.Name == "exec" { + task.projectDir = extractExecProjectDir(tc.Arguments) + } + switch tc.Name { + case "read_file", "write_file", "edit_file", "append_file", "list_dir": + if p, _ := tc.Arguments["path"].(string); p != "" { + if rel := fileParentRelDir(p, agent.Workspace); rel != "" { + if task.fileCommonDir == "" { + task.fileCommonDir = rel + } else { + task.fileCommonDir = commonDirPrefix(task.fileCommonDir, rel) + } + } + } } } task.mu.Unlock() diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index de8f47135..e27fa7d5f 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -1570,7 +1570,7 @@ func TestBuildRichStatus(t *testing.T) { } func TestBuildRichStatus_ProjectDir(t *testing.T) { - // projectDir takes priority over workspace basename + // exec-based projectDir takes priority task := &activeTask{ Iteration: 1, MaxIter: 10, @@ -1581,14 +1581,25 @@ func TestBuildRichStatus_ProjectDir(t *testing.T) { } got := buildRichStatus(task, false, "/home/user/.picoclaw/workspace") if !strings.Contains(got, "terra-py-form") { - t.Errorf("expected projectDir 'terra-py-form' in output, got:\n%s", got) - } - if strings.Contains(got, "\U0001F4C1 workspace") { - t.Error("should not show workspace basename when projectDir is set") + t.Errorf("expected projectDir in output, got:\n%s", got) } - // Fallback: no projectDir, trailing slash should still work + // fileCommonDir fallback task2 := &activeTask{ + Iteration: 1, + MaxIter: 10, + fileCommonDir: "projects/terra-py-form", + toolLog: []toolLogEntry{ + {Name: "read_file", ArgsSnip: "src/main.py", Result: "✓ 0.1s"}, + }, + } + got2 := buildRichStatus(task2, false, "/home/user/.picoclaw/workspace") + if !strings.Contains(got2, "terra-py-form") { + t.Errorf("expected fileCommonDir basename in output, got:\n%s", got2) + } + + // workspace basename fallback with trailing slash + task3 := &activeTask{ Iteration: 1, MaxIter: 10, toolLog: []toolLogEntry{ @@ -1596,105 +1607,105 @@ func TestBuildRichStatus_ProjectDir(t *testing.T) { }, } for _, ws := range []string{"/home/user/my-project/", "/home/user/my-project"} { - got := buildRichStatus(task2, false, ws) + got := buildRichStatus(task3, false, ws) if !strings.Contains(got, "my-project") { t.Errorf("workspace %q: expected 'my-project' in output, got:\n%s", ws, got) } } } -func TestExtractProjectDir(t *testing.T) { - ws := "/home/user/.picoclaw/workspace" +func TestExtractExecProjectDir(t *testing.T) { tests := []struct { - name string - toolName string - args map[string]interface{} - workspace string - want string + name string + cmd string + want string }{ - // exec: basename of cd target - { - name: "exec cd deep path", - toolName: "exec", - args: map[string]interface{}{"command": "cd /home/user/.picoclaw/workspace/projects/terra-py-form && pytest"}, - workspace: ws, - want: "terra-py-form", - }, - { - name: "exec cd direct subdir", - toolName: "exec", - args: map[string]interface{}{"command": "cd /home/user/.picoclaw/workspace/my-app && make build"}, - workspace: ws, - want: "my-app", - }, - { - name: "exec cd trailing slash target", - toolName: "exec", - args: map[string]interface{}{"command": "cd /home/user/.picoclaw/workspace/my-app/ && ls"}, - workspace: ws, - want: "my-app", - }, - { - name: "exec cd to workspace itself", - toolName: "exec", - args: map[string]interface{}{"command": "cd /home/user/.picoclaw/workspace && ls"}, - workspace: ws, - want: "workspace", - }, - { - name: "exec no cd prefix", - toolName: "exec", - args: map[string]interface{}{"command": "pytest tests/"}, - workspace: ws, - want: "", - }, - // file tools: first component after workspace - { - name: "read_file first component is projects", - toolName: "read_file", - args: map[string]interface{}{"path": "/home/user/.picoclaw/workspace/projects/terra-py-form/src/main.py"}, - workspace: ws, - want: "projects", - }, - { - name: "edit_file 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: "file outside workspace", - toolName: "read_file", - args: map[string]interface{}{"path": "/tmp/foo.txt"}, - workspace: ws, - want: "", - }, - { - name: "unknown tool", - toolName: "web_search", - args: map[string]interface{}{"query": "test"}, - workspace: ws, - want: "", - }, + {"cd deep path", "cd /ws/projects/terra-py-form && pytest", "terra-py-form"}, + {"cd direct subdir", "cd /ws/my-app && make build", "my-app"}, + {"cd trailing slash", "cd /ws/my-app/ && ls", "my-app"}, + {"cd to workspace", "cd /ws && ls", "ws"}, + {"no cd prefix", "pytest tests/", ""}, + {"empty command", "", ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := extractProjectDir(tt.toolName, tt.args, tt.workspace) + args := map[string]interface{}{"command": tt.cmd} + got := extractExecProjectDir(args) if got != tt.want { - t.Errorf("extractProjectDir(%q, args, %q) = %q, want %q", tt.toolName, tt.workspace, got, tt.want) + t.Errorf("extractExecProjectDir(%q) = %q, want %q", tt.cmd, got, tt.want) } }) } } +func TestFileParentRelDir(t *testing.T) { + ws := "/home/user/.picoclaw/workspace" + tests := []struct { + name string + path string + want string + }{ + {"deep path", ws + "/projects/terra/src/main.py", "projects/terra/src"}, + {"direct subdir", ws + "/my-app/README.md", "my-app"}, + {"workspace root file", ws + "/notes.txt", ""}, + {"outside workspace", "/tmp/foo.txt", ""}, + {"trailing slash ws", ws + "/projects/terra/src/main.py", "projects/terra/src"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := fileParentRelDir(tt.path, ws) + if got != tt.want { + t.Errorf("fileParentRelDir(%q, ws) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} + +func TestCommonDirPrefix(t *testing.T) { + tests := []struct { + name string + a, b string + want string + }{ + {"same dir", "projects/terra/src", "projects/terra/src", "projects/terra/src"}, + {"converge to project", "projects/terra/src", "projects/terra/tests", "projects/terra"}, + {"converge to top", "projects/terra/src", "projects/other/tests", "projects"}, + {"no common", "aaa/bbb", "ccc/ddd", ""}, + {"one is prefix", "projects/terra", "projects/terra/src", "projects/terra"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := commonDirPrefix(tt.a, tt.b) + if got != tt.want { + t.Errorf("commonDirPrefix(%q, %q) = %q, want %q", tt.a, tt.b, got, tt.want) + } + }) + } +} + +func TestDisplayProjectDir(t *testing.T) { + // exec projectDir wins + task1 := &activeTask{projectDir: "my-app", fileCommonDir: "projects/other"} + if got := displayProjectDir(task1); got != "my-app" { + t.Errorf("expected 'my-app', got %q", got) + } + // fileCommonDir fallback: basename + task2 := &activeTask{fileCommonDir: "projects/terra-py-form"} + if got := displayProjectDir(task2); got != "terra-py-form" { + t.Errorf("expected 'terra-py-form', got %q", got) + } + // single component + task3 := &activeTask{fileCommonDir: "my-app"} + if got := displayProjectDir(task3); got != "my-app" { + t.Errorf("expected 'my-app', got %q", got) + } + // empty + task4 := &activeTask{} + if got := displayProjectDir(task4); got != "" { + t.Errorf("expected empty, got %q", got) + } +} + func TestBuildRichStatus_FixedHeight(t *testing.T) { // Test that output has the same number of lines regardless of entry count countLines := func(s string) int {