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:
dj-oyu 2026-02-22 06:34:29 +09:00
parent 5aa7473e14
commit 0b21bc6196
2 changed files with 131 additions and 9 deletions

View file

@ -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()

View file

@ -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 {