diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 7f42a62fd..a37b9f8cb 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -44,6 +44,7 @@ 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 } @@ -947,13 +948,55 @@ func buildPlanReminder(planStatus string) (providers.Message, bool) { } // cdPrefixPattern matches "cd /some/path && " at the start of a shell command. -var cdPrefixPattern = regexp.MustCompile(`^cd\s+\S+\s*&&\s*`) +// Group 1 captures the target directory path. +var cdPrefixPattern = regexp.MustCompile(`^cd\s+(\S+)\s*&&\s*`) // optFlagPattern matches option flags like --verbose, -v, --timeout=60, -q. // Only standalone flags are removed; flags whose value is the next positional // 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 { + return "" + } + cdPath := strings.TrimRight(m[1], "/\\") + if workspace == "" { + if idx := strings.LastIndex(cdPath, "/"); idx >= 0 { + return cdPath[idx+1:] + } + return cdPath + } + ws := strings.TrimRight(workspace, "/\\") + rest := strings.TrimPrefix(cdPath, ws) + if rest == cdPath { + // workspace not a prefix — fall back to last component + if idx := strings.LastIndex(cdPath, "/"); idx >= 0 { + return cdPath[idx+1:] + } + return cdPath + } + 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 + parts := strings.SplitN(rest, "/", 3) + if len(parts) >= 2 { + first := strings.ToLower(parts[0]) + if first == "projects" || first == "repos" || first == "src" { + return parts[1] + } + } + return parts[0] +} + // 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. @@ -1149,9 +1192,11 @@ func buildRichStatus(task *activeTask, isBackground bool, workspace string) stri sb.WriteByte('/') sb.WriteString(strconv.Itoa(task.MaxIter)) sb.WriteString(")\n") - // Workspace: always emit for fixed height (show project name only) + // Workspace: prefer detected project dir, fall back to workspace basename sb.WriteString("\U0001F4C1 ") - if workspace != "" { + if task.projectDir != "" { + sb.WriteString(task.projectDir) + } else if workspace != "" { project := strings.TrimRight(workspace, "/\\") if idx := strings.LastIndex(project, "/"); idx >= 0 { project = project[idx+1:] @@ -1489,6 +1534,12 @@ 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) + } + } } task.mu.Unlock() diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 5200ad7ad..32677a102 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -1569,23 +1569,94 @@ func TestBuildRichStatus(t *testing.T) { } } -func TestBuildRichStatus_TrailingSlash(t *testing.T) { +func TestBuildRichStatus_ProjectDir(t *testing.T) { + // projectDir takes priority over workspace basename task := &activeTask{ + Iteration: 1, + MaxIter: 10, + projectDir: "terra-py-form", + toolLog: []toolLogEntry{ + {Name: "exec", ArgsSnip: "ls", Result: "✓ 0.1s"}, + }, + } + 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") + } + + // Fallback: no projectDir, trailing slash should still work + task2 := &activeTask{ Iteration: 1, MaxIter: 10, toolLog: []toolLogEntry{ {Name: "exec", ArgsSnip: "ls", Result: "✓ 0.1s"}, }, } - // Trailing slash should not break project name extraction - for _, ws := range []string{"/home/user/terra-py-form/", "/home/user/terra-py-form", "C:\\Users\\dev\\terra-py-form\\"} { - got := buildRichStatus(task, false, ws) - if !strings.Contains(got, "terra-py-form") { - t.Errorf("workspace %q: expected 'terra-py-form' in output, got:\n%s", ws, got) + for _, ws := range []string{"/home/user/my-project/", "/home/user/my-project"} { + got := buildRichStatus(task2, 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) { + tests := []struct { + name string + cmd string + workspace string + want string + }{ + { + name: "cd into projects subdir", + cmd: "cd /home/user/.picoclaw/workspace/projects/terra-py-form && pytest", + workspace: "/home/user/.picoclaw/workspace", + 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/", + want: "terra-py-form", + }, + { + name: "cd into direct subdir", + cmd: "cd /home/user/.picoclaw/workspace/my-app && make build", + workspace: "/home/user/.picoclaw/workspace", + want: "my-app", + }, + { + name: "cd to workspace itself", + cmd: "cd /home/user/.picoclaw/workspace && ls", + workspace: "/home/user/.picoclaw/workspace", + want: "", + }, + { + name: "no cd prefix", + cmd: "pytest tests/", + workspace: "/home/user/.picoclaw/workspace", + want: "", + }, + { + name: "cd to unrelated path", + cmd: "cd /tmp/build && make", + workspace: "/home/user/.picoclaw/workspace", + want: "build", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractProjectDir(tt.cmd, tt.workspace) + if got != tt.want { + t.Errorf("extractProjectDir(%q, %q) = %q, want %q", tt.cmd, tt.workspace, got, tt.want) + } + }) + } +} + func TestBuildRichStatus_FixedHeight(t *testing.T) { // Test that output has the same number of lines regardless of entry count countLines := func(s string) int {