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 <noreply@anthropic.com>
This commit is contained in:
parent
e6e8482613
commit
5ae2ece515
2 changed files with 192 additions and 140 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue