shelley: improve cd behavior and git status messages

Philip Zeyliger and Shelley created

Prompt: I find that the model tends to "cd" a lot despite the fact that
we have a change dir function. Remove the current directory from the
prompt an explain in the bash tool that there's a change directory tool.
Have the change directory tool report whether it's in a git repo and
where the git repo base is. Also update the message about the diff
status changing to include the git dir it's in, with tilde replacement.

- Remove <pwd> from bash tool description to reduce LLM cd usage
- Add branch info to change_dir output (root with ~, branch name)
- Update git status messages to show full path with ~ replacement
- Include commit subject in git status (truncated to 50 chars)

Example change_dir output:
  Git repository detected (root: ~/exe, branch: main)

Example git status message:
  ~/exe (main) now at abc1234 "commit subject"

Co-authored-by: Shelley <shelley@exe.dev>

Change summary

claudetool/bash.go        |  7 ++-----
claudetool/changedir.go   | 14 +++++++++++++-
gitstate/gitstate.go      | 21 ++++++++++++++++-----
gitstate/gitstate_test.go | 31 +++++++++++++++++++++++++++++--
4 files changed, 60 insertions(+), 13 deletions(-)

Detailed changes

claudetool/bash.go 🔗

@@ -82,7 +82,7 @@ func (t *Timeouts) background() time.Duration {
 func (b *BashTool) Tool() *llm.Tool {
 	return &llm.Tool{
 		Name:        bashName,
-		Description: fmt.Sprintf(strings.TrimSpace(bashDescription), b.getWorkingDir()),
+		Description: strings.TrimSpace(bashDescription),
 		InputSchema: llm.MustSchema(bashInputSchema),
 		Run:         b.Run,
 	}
@@ -95,8 +95,7 @@ func (b *BashTool) getWorkingDir() string {
 
 const (
 	bashName        = "bash"
-	bashDescription = `
-Executes shell commands via bash -c, returning combined stdout/stderr.
+	bashDescription = `Executes shell commands via bash -c, returning combined stdout/stderr.
 Bash state changes (working dir, variables, aliases) don't persist between calls.
 
 With background=true, returns immediately, with output redirected to a file.
@@ -109,8 +108,6 @@ To change the working directory persistently, use the change_dir tool.
 
 IMPORTANT: Keep commands concise. The command input must be less than 60k tokens.
 For complex scripts, write them to a file first and then execute the file.
-
-<pwd>%s</pwd>
 `
 	// If you modify this, update the termui template for prettier rendering.
 	bashInputSchema = `

claudetool/changedir.go 🔗

@@ -6,11 +6,20 @@ import (
 	"fmt"
 	"os"
 	"path/filepath"
+	"strings"
 
 	"shelley.exe.dev/gitstate"
 	"shelley.exe.dev/llm"
 )
 
+// tildeReplace replaces the home directory prefix with ~ for display.
+func tildeReplace(path string) string {
+	if home, err := os.UserHomeDir(); err == nil && strings.HasPrefix(path, home) {
+		return "~" + path[len(home):]
+	}
+	return path
+}
+
 // ChangeDirTool changes the working directory for bash commands.
 type ChangeDirTool struct {
 	// WorkingDir is the shared mutable working directory.
@@ -101,7 +110,10 @@ func (c *ChangeDirTool) Run(ctx context.Context, m json.RawMessage) llm.ToolOut
 	state := gitstate.GetGitState(targetPath)
 	var resultText string
 	if state.IsRepo {
-		resultText = fmt.Sprintf("Changed working directory to: %s\n\nGit repository detected (root: %s)", targetPath, state.Worktree)
+		resultText = fmt.Sprintf("Changed working directory to: %s\n\nGit repository detected (root: %s, branch: %s)", targetPath, tildeReplace(state.Worktree), state.Branch)
+		if state.Branch == "" {
+			resultText = fmt.Sprintf("Changed working directory to: %s\n\nGit repository detected (root: %s, detached HEAD)", targetPath, tildeReplace(state.Worktree))
+		}
 	} else {
 		resultText = fmt.Sprintf("Changed working directory to: %s\n\nNot in a git repository.", targetPath)
 	}

gitstate/gitstate.go 🔗

@@ -2,8 +2,8 @@
 package gitstate
 
 import (
+	"os"
 	"os/exec"
-	"path/filepath"
 	"strings"
 )
 
@@ -95,6 +95,14 @@ func (g *GitState) Equal(other *GitState) bool {
 		g.IsRepo == other.IsRepo
 }
 
+// tildeReplace replaces the home directory prefix with ~ for display.
+func tildeReplace(path string) string {
+	if home, err := os.UserHomeDir(); err == nil && strings.HasPrefix(path, home) {
+		return "~" + path[len(home):]
+	}
+	return path
+}
+
 // String returns a human-readable description of the git state change.
 // It's designed to be shown to users, not the LLM.
 func (g *GitState) String() string {
@@ -102,11 +110,14 @@ func (g *GitState) String() string {
 		return ""
 	}
 
-	// Get just the worktree name (last path component)
-	worktreeName := filepath.Base(g.Worktree)
+	worktreePath := tildeReplace(g.Worktree)
+	subject := g.Subject
+	if len(subject) > 50 {
+		subject = subject[:47] + "..."
+	}
 
 	if g.Branch != "" {
-		return worktreeName + "/" + g.Branch + " now at " + g.Commit
+		return worktreePath + " (" + g.Branch + ") now at " + g.Commit + " \"" + subject + "\""
 	}
-	return worktreeName + " (detached) now at " + g.Commit
+	return worktreePath + " (detached) now at " + g.Commit + " \"" + subject + "\""
 }

gitstate/gitstate_test.go 🔗

@@ -184,8 +184,9 @@ func TestGitState_String(t *testing.T) {
 	}{
 		{"nil state", nil, ""},
 		{"not a repo", &GitState{IsRepo: false}, ""},
-		{"with branch", &GitState{Worktree: "/home/user/myrepo", Branch: "main", Commit: "abc1234", IsRepo: true}, "myrepo/main now at abc1234"},
-		{"detached head", &GitState{Worktree: "/home/user/myrepo", Branch: "", Commit: "abc1234", IsRepo: true}, "myrepo (detached) now at abc1234"},
+		{"with branch", &GitState{Worktree: "/srv/myrepo", Branch: "main", Commit: "abc1234", Subject: "fix bug", IsRepo: true}, `/srv/myrepo (main) now at abc1234 "fix bug"`},
+		{"detached head", &GitState{Worktree: "/srv/myrepo", Branch: "", Commit: "abc1234", Subject: "add feature", IsRepo: true}, `/srv/myrepo (detached) now at abc1234 "add feature"`},
+		{"long subject truncated", &GitState{Worktree: "/srv/myrepo", Branch: "main", Commit: "abc1234", Subject: "this is a very long commit message that should be truncated", IsRepo: true}, `/srv/myrepo (main) now at abc1234 "this is a very long commit message that should ..."`},
 	}
 
 	for _, tt := range tests {
@@ -197,6 +198,32 @@ func TestGitState_String(t *testing.T) {
 	}
 }
 
+func TestTildeReplace(t *testing.T) {
+	home, err := os.UserHomeDir()
+	if err != nil {
+		t.Skip("no home directory")
+	}
+
+	tests := []struct {
+		name     string
+		path     string
+		expected string
+	}{
+		{"home dir", home, "~"},
+		{"subdir of home", home + "/projects/foo", "~/projects/foo"},
+		{"not in home", "/var/log", "/var/log"},
+		{"root", "/", "/"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := tildeReplace(tt.path); got != tt.expected {
+				t.Errorf("tildeReplace(%q) = %q, want %q", tt.path, got, tt.expected)
+			}
+		})
+	}
+}
+
 func runGit(t *testing.T, dir string, args ...string) {
 	t.Helper()
 	// For commits, use --no-verify to skip hooks