fix: show project directory name instead of workspace basename in status
The workspace path (e.g. ~/.picoclaw/workspace) showed "workspace" as the directory name. Now extracts the actual project directory from the exec cd prefix (e.g. cd workspace/projects/my-app && ...) and displays "my-app" instead. Falls back to workspace basename if no cd prefix seen. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5aa7473e14
commit
0b21bc6196
2 changed files with 131 additions and 9 deletions
|
|
@ -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 <path> && ..." 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 <workspace> && ".
|
||||
// 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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue