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
4523d76b7d
commit
d6733de91e
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
|
interrupt chan string // buffered 1, for user message injection
|
||||||
toolLog []toolLogEntry
|
toolLog []toolLogEntry
|
||||||
lastError *toolLogEntry // sticky: most recent error, persists across iterations
|
lastError *toolLogEntry // sticky: most recent error, persists across iterations
|
||||||
|
projectDir string // detected project directory name (from cd prefix)
|
||||||
mu sync.Mutex
|
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.
|
// 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.
|
// optFlagPattern matches option flags like --verbose, -v, --timeout=60, -q.
|
||||||
// Only standalone flags are removed; flags whose value is the next positional
|
// 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.
|
// argument (e.g. "-A 20") are kept because removing them would lose context.
|
||||||
var optFlagPattern = regexp.MustCompile(`\s+--?\w[\w-]*(=\S*)?`)
|
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.
|
// buildArgsSnippet produces a human-friendly snippet for the tool log.
|
||||||
// For exec: extracts the command and strips the leading "cd <workspace> && ".
|
// For exec: extracts the command and strips the leading "cd <workspace> && ".
|
||||||
// For file tools: extracts the path and strips the workspace prefix.
|
// 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.WriteByte('/')
|
||||||
sb.WriteString(strconv.Itoa(task.MaxIter))
|
sb.WriteString(strconv.Itoa(task.MaxIter))
|
||||||
sb.WriteString(")\n")
|
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 ")
|
sb.WriteString("\U0001F4C1 ")
|
||||||
if workspace != "" {
|
if task.projectDir != "" {
|
||||||
|
sb.WriteString(task.projectDir)
|
||||||
|
} else if workspace != "" {
|
||||||
project := strings.TrimRight(workspace, "/\\")
|
project := strings.TrimRight(workspace, "/\\")
|
||||||
if idx := strings.LastIndex(project, "/"); idx >= 0 {
|
if idx := strings.LastIndex(project, "/"); idx >= 0 {
|
||||||
project = project[idx+1:]
|
project = project[idx+1:]
|
||||||
|
|
@ -1489,6 +1534,12 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
ArgsSnip: buildArgsSnippet(tc.Name, tc.Arguments, agent.Workspace),
|
ArgsSnip: buildArgsSnippet(tc.Name, tc.Arguments, agent.Workspace),
|
||||||
Result: "\u23F3",
|
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()
|
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{
|
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,
|
Iteration: 1,
|
||||||
MaxIter: 10,
|
MaxIter: 10,
|
||||||
toolLog: []toolLogEntry{
|
toolLog: []toolLogEntry{
|
||||||
{Name: "exec", ArgsSnip: "ls", Result: "✓ 0.1s"},
|
{Name: "exec", ArgsSnip: "ls", Result: "✓ 0.1s"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
// Trailing slash should not break project name extraction
|
for _, ws := range []string{"/home/user/my-project/", "/home/user/my-project"} {
|
||||||
for _, ws := range []string{"/home/user/terra-py-form/", "/home/user/terra-py-form", "C:\\Users\\dev\\terra-py-form\\"} {
|
got := buildRichStatus(task2, false, ws)
|
||||||
got := buildRichStatus(task, false, ws)
|
if !strings.Contains(got, "my-project") {
|
||||||
if !strings.Contains(got, "terra-py-form") {
|
t.Errorf("workspace %q: expected 'my-project' in output, got:\n%s", ws, got)
|
||||||
t.Errorf("workspace %q: expected 'terra-py-form' 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) {
|
func TestBuildRichStatus_FixedHeight(t *testing.T) {
|
||||||
// Test that output has the same number of lines regardless of entry count
|
// Test that output has the same number of lines regardless of entry count
|
||||||
countLines := func(s string) int {
|
countLines := func(s string) int {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue