shelley: add git state tracking and clickable gitinfo messages

Philip Zeyliger and Claude created

When we're in a git repo, and the state has changed, let's tell the
user, to indicate that they can go look at the diff if they'd like.

Prompt: add git state tracking so agent commits show up in the UI

Track git state changes during agent loops and display them in the UI
as clickable messages that open a diff viewer.

Changes:
- Add gitstate package for tracking git repo state (branch, commit, subject)
- Add migration 008 to include 'gitinfo' in message type constraint
- Record gitinfo messages in db when commits are detected
- Publish gitinfo messages to SSE subscribers for real-time updates
- Display gitinfo as single-line "branch now at sha subject [diff]"
- Style diff as clickable link that opens DiffViewer
- Fix AgentWorking and isEndOfTurn to handle gitinfo messages correctly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Change summary

claudetool/changedir.go                    |  12 +
cmd/go2ts.go                               |   1 
db/db.go                                   |  11 
db/schema/008-add-gitinfo-message-type.sql |  32 +++
gitstate/gitstate.go                       | 112 +++++++++++
gitstate/gitstate_test.go                  | 225 ++++++++++++++++++++++++
loop/loop.go                               | 113 +++++++++--
loop/loop_test.go                          | 200 +++++++++++++++++++++
server/convo.go                            |  80 ++++++++
server/server.go                           |   4 
test/server_test.go                        | 115 ++++++++++++
ui/src/components/ChatInterface.tsx        |  20 +
ui/src/components/DiffViewer.tsx           |  17 +
ui/src/components/Message.tsx              |  78 ++++++++
ui/src/generated-types.ts                  |   2 
15 files changed, 985 insertions(+), 37 deletions(-)

Detailed changes

claudetool/changedir.go 🔗

@@ -7,6 +7,7 @@ import (
 	"os"
 	"path/filepath"
 
+	"shelley.exe.dev/gitstate"
 	"shelley.exe.dev/llm"
 )
 
@@ -96,7 +97,16 @@ func (c *ChangeDirTool) Run(ctx context.Context, m json.RawMessage) llm.ToolOut
 		c.OnChange(targetPath)
 	}
 
+	// Check git status for the new directory
+	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)
+	} else {
+		resultText = fmt.Sprintf("Changed working directory to: %s\n\nNot in a git repository.", targetPath)
+	}
+
 	return llm.ToolOut{
-		LLMContent: llm.TextContent(fmt.Sprintf("Changed working directory to: %s", targetPath)),
+		LLMContent: llm.TextContent(resultText),
 	}
 }

cmd/go2ts.go 🔗

@@ -53,6 +53,7 @@ func TS() *go2ts.Go2TS {
 			db.MessageTypeTool,
 			db.MessageTypeError,
 			db.MessageTypeSystem,
+			db.MessageTypeGitInfo,
 		},
 	)
 

db/db.go 🔗

@@ -333,11 +333,12 @@ func (db *DB) UpdateConversationCwd(ctx context.Context, conversationID, cwd str
 type MessageType string
 
 const (
-	MessageTypeUser   MessageType = "user"
-	MessageTypeAgent  MessageType = "agent"
-	MessageTypeTool   MessageType = "tool"
-	MessageTypeSystem MessageType = "system"
-	MessageTypeError  MessageType = "error"
+	MessageTypeUser    MessageType = "user"
+	MessageTypeAgent   MessageType = "agent"
+	MessageTypeTool    MessageType = "tool"
+	MessageTypeSystem  MessageType = "system"
+	MessageTypeError   MessageType = "error"
+	MessageTypeGitInfo MessageType = "gitinfo" // user-visible only, not sent to LLM
 )
 
 // CreateMessageParams contains parameters for creating a message

db/schema/008-add-gitinfo-message-type.sql 🔗

@@ -0,0 +1,32 @@
+-- Add 'gitinfo' to the message type check constraint
+-- This requires dropping and recreating the messages table with the new constraint
+-- SQLite doesn't support ALTER TABLE to modify CHECK constraints
+
+-- Step 1: Create a new messages table with the updated constraint
+CREATE TABLE messages_new (
+    message_id TEXT PRIMARY KEY,
+    conversation_id TEXT NOT NULL,
+    sequence_id INTEGER NOT NULL,
+    type TEXT NOT NULL CHECK (type IN ('user', 'agent', 'tool', 'system', 'error', 'gitinfo')),
+    llm_data TEXT, -- JSON data sent to/from LLM
+    user_data TEXT, -- JSON data for UI display
+    usage_data TEXT, -- JSON data about token usage, etc.
+    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    display_data TEXT, -- JSON data for display purposes
+    FOREIGN KEY (conversation_id) REFERENCES conversations(conversation_id) ON DELETE CASCADE
+);
+
+-- Step 2: Copy data from old table to new table
+INSERT INTO messages_new (message_id, conversation_id, sequence_id, type, llm_data, user_data, usage_data, created_at, display_data)
+SELECT message_id, conversation_id, sequence_id, type, llm_data, user_data, usage_data, created_at, display_data FROM messages;
+
+-- Step 3: Drop the old table
+DROP TABLE messages;
+
+-- Step 4: Rename the new table
+ALTER TABLE messages_new RENAME TO messages;
+
+-- Step 5: Recreate indexes
+CREATE INDEX idx_messages_conversation_id ON messages(conversation_id);
+CREATE INDEX idx_messages_conversation_sequence ON messages(conversation_id, sequence_id);
+CREATE INDEX idx_messages_type ON messages(type);

gitstate/gitstate.go 🔗

@@ -0,0 +1,112 @@
+// Package gitstate provides utilities for tracking git repository state.
+package gitstate
+
+import (
+	"os/exec"
+	"path/filepath"
+	"strings"
+)
+
+// GitState represents the current state of a git repository.
+type GitState struct {
+	// Worktree is the absolute path to the worktree root.
+	// For regular repos, this is the same as the git root.
+	// For worktrees, this is the worktree directory.
+	Worktree string
+
+	// Branch is the current branch name, or empty if detached HEAD.
+	Branch string
+
+	// Commit is the current commit hash (short form).
+	Commit string
+
+	// Subject is the commit message subject line.
+	Subject string
+
+	// IsRepo is true if the directory is inside a git repository.
+	IsRepo bool
+}
+
+// GetGitState returns the git state for the given directory.
+// If dir is empty, uses the current working directory.
+func GetGitState(dir string) *GitState {
+	state := &GitState{}
+
+	// Get the worktree root (this works for both regular repos and worktrees)
+	cmd := exec.Command("git", "rev-parse", "--show-toplevel")
+	if dir != "" {
+		cmd.Dir = dir
+	}
+	output, err := cmd.Output()
+	if err != nil {
+		// Not in a git repository
+		return state
+	}
+	state.IsRepo = true
+	state.Worktree = strings.TrimSpace(string(output))
+
+	// Get the current commit hash (short form)
+	cmd = exec.Command("git", "rev-parse", "--short", "HEAD")
+	if dir != "" {
+		cmd.Dir = dir
+	}
+	output, err = cmd.Output()
+	if err == nil {
+		state.Commit = strings.TrimSpace(string(output))
+	}
+
+	// Get the commit subject line
+	cmd = exec.Command("git", "log", "-1", "--format=%s")
+	if dir != "" {
+		cmd.Dir = dir
+	}
+	output, err = cmd.Output()
+	if err == nil {
+		state.Subject = strings.TrimSpace(string(output))
+	}
+
+	// Get the current branch name
+	// First try symbolic-ref for normal branches
+	cmd = exec.Command("git", "symbolic-ref", "--short", "HEAD")
+	if dir != "" {
+		cmd.Dir = dir
+	}
+	output, err = cmd.Output()
+	if err == nil {
+		state.Branch = strings.TrimSpace(string(output))
+	}
+	// If symbolic-ref fails, we're in detached HEAD state - branch stays empty
+
+	return state
+}
+
+// Equal returns true if two git states are equal.
+func (g *GitState) Equal(other *GitState) bool {
+	if g == nil && other == nil {
+		return true
+	}
+	if g == nil || other == nil {
+		return false
+	}
+	return g.Worktree == other.Worktree &&
+		g.Branch == other.Branch &&
+		g.Commit == other.Commit &&
+		g.Subject == other.Subject &&
+		g.IsRepo == other.IsRepo
+}
+
+// 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 {
+	if g == nil || !g.IsRepo {
+		return ""
+	}
+
+	// Get just the worktree name (last path component)
+	worktreeName := filepath.Base(g.Worktree)
+
+	if g.Branch != "" {
+		return worktreeName + "/" + g.Branch + " now at " + g.Commit
+	}
+	return worktreeName + " (detached) now at " + g.Commit
+}

gitstate/gitstate_test.go 🔗

@@ -0,0 +1,225 @@
+package gitstate
+
+import (
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func TestGetGitState_NotARepo(t *testing.T) {
+	tmpDir := t.TempDir()
+
+	state := GetGitState(tmpDir)
+
+	if state.IsRepo {
+		t.Error("expected IsRepo to be false for non-repo directory")
+	}
+	if state.Worktree != "" {
+		t.Errorf("expected empty Worktree, got %q", state.Worktree)
+	}
+	if state.Branch != "" {
+		t.Errorf("expected empty Branch, got %q", state.Branch)
+	}
+	if state.Commit != "" {
+		t.Errorf("expected empty Commit, got %q", state.Commit)
+	}
+}
+
+func TestGetGitState_RegularRepo(t *testing.T) {
+	tmpDir := t.TempDir()
+
+	// Initialize a git repo
+	runGit(t, tmpDir, "init")
+	runGit(t, tmpDir, "config", "user.email", "test@test.com")
+	runGit(t, tmpDir, "config", "user.name", "Test")
+
+	// Create a commit
+	testFile := filepath.Join(tmpDir, "test.txt")
+	if err := os.WriteFile(testFile, []byte("hello"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	runGit(t, tmpDir, "add", ".")
+	runGit(t, tmpDir, "commit", "-m", "initial")
+
+	state := GetGitState(tmpDir)
+
+	if !state.IsRepo {
+		t.Error("expected IsRepo to be true")
+	}
+	if state.Worktree != tmpDir {
+		t.Errorf("expected Worktree %q, got %q", tmpDir, state.Worktree)
+	}
+	// Default branch might be master or main depending on git config
+	if state.Branch != "master" && state.Branch != "main" {
+		t.Errorf("expected Branch 'master' or 'main', got %q", state.Branch)
+	}
+	if state.Commit == "" {
+		t.Error("expected non-empty Commit")
+	}
+	if len(state.Commit) < 7 {
+		t.Errorf("expected short commit hash, got %q", state.Commit)
+	}
+}
+
+func TestGetGitState_Worktree(t *testing.T) {
+	tmpDir := t.TempDir()
+	mainRepo := filepath.Join(tmpDir, "main")
+	worktreeDir := filepath.Join(tmpDir, "worktree")
+
+	// Create main repo
+	if err := os.MkdirAll(mainRepo, 0o755); err != nil {
+		t.Fatal(err)
+	}
+	runGit(t, mainRepo, "init")
+	runGit(t, mainRepo, "config", "user.email", "test@test.com")
+	runGit(t, mainRepo, "config", "user.name", "Test")
+
+	// Create initial commit
+	testFile := filepath.Join(mainRepo, "test.txt")
+	if err := os.WriteFile(testFile, []byte("hello"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	runGit(t, mainRepo, "add", ".")
+	runGit(t, mainRepo, "commit", "-m", "initial")
+
+	// Create a worktree on a new branch
+	runGit(t, mainRepo, "worktree", "add", "-b", "feature", worktreeDir)
+
+	// Check state in main repo
+	mainState := GetGitState(mainRepo)
+	if !mainState.IsRepo {
+		t.Error("expected main repo IsRepo to be true")
+	}
+	if mainState.Worktree != mainRepo {
+		t.Errorf("expected main Worktree %q, got %q", mainRepo, mainState.Worktree)
+	}
+
+	// Check state in worktree
+	worktreeState := GetGitState(worktreeDir)
+	if !worktreeState.IsRepo {
+		t.Error("expected worktree IsRepo to be true")
+	}
+	if worktreeState.Worktree != worktreeDir {
+		t.Errorf("expected worktree Worktree %q, got %q", worktreeDir, worktreeState.Worktree)
+	}
+	if worktreeState.Branch != "feature" {
+		t.Errorf("expected worktree Branch 'feature', got %q", worktreeState.Branch)
+	}
+
+	// Both should have the same commit (initially)
+	if mainState.Commit != worktreeState.Commit {
+		t.Errorf("expected same commit, got main=%q worktree=%q", mainState.Commit, worktreeState.Commit)
+	}
+}
+
+func TestGetGitState_DetachedHead(t *testing.T) {
+	tmpDir := t.TempDir()
+
+	// Initialize and create commits
+	runGit(t, tmpDir, "init")
+	runGit(t, tmpDir, "config", "user.email", "test@test.com")
+	runGit(t, tmpDir, "config", "user.name", "Test")
+
+	testFile := filepath.Join(tmpDir, "test.txt")
+	if err := os.WriteFile(testFile, []byte("hello"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	runGit(t, tmpDir, "add", ".")
+	runGit(t, tmpDir, "commit", "-m", "initial")
+
+	// Get the commit hash
+	commit := strings.TrimSpace(runGitOutput(t, tmpDir, "rev-parse", "HEAD"))
+
+	// Checkout to detached HEAD
+	runGit(t, tmpDir, "checkout", commit)
+
+	state := GetGitState(tmpDir)
+
+	if !state.IsRepo {
+		t.Error("expected IsRepo to be true")
+	}
+	if state.Branch != "" {
+		t.Errorf("expected empty Branch for detached HEAD, got %q", state.Branch)
+	}
+	if state.Commit == "" {
+		t.Error("expected non-empty Commit")
+	}
+}
+
+func TestGitState_Equal(t *testing.T) {
+	tests := []struct {
+		name     string
+		a        *GitState
+		b        *GitState
+		expected bool
+	}{
+		{"both nil", nil, nil, true},
+		{"one nil", &GitState{}, nil, false},
+		{"other nil", nil, &GitState{}, false},
+		{"both empty", &GitState{}, &GitState{}, true},
+		{"same values", &GitState{Worktree: "/foo", Branch: "main", Commit: "abc123", IsRepo: true}, &GitState{Worktree: "/foo", Branch: "main", Commit: "abc123", IsRepo: true}, true},
+		{"different worktree", &GitState{Worktree: "/foo", Branch: "main", Commit: "abc123", IsRepo: true}, &GitState{Worktree: "/bar", Branch: "main", Commit: "abc123", IsRepo: true}, false},
+		{"different branch", &GitState{Worktree: "/foo", Branch: "main", Commit: "abc123", IsRepo: true}, &GitState{Worktree: "/foo", Branch: "dev", Commit: "abc123", IsRepo: true}, false},
+		{"different commit", &GitState{Worktree: "/foo", Branch: "main", Commit: "abc123", IsRepo: true}, &GitState{Worktree: "/foo", Branch: "main", Commit: "def456", IsRepo: true}, false},
+		{"different IsRepo", &GitState{Worktree: "/foo", Branch: "main", Commit: "abc123", IsRepo: true}, &GitState{Worktree: "/foo", Branch: "main", Commit: "abc123", IsRepo: false}, false},
+		{"different subject", &GitState{Worktree: "/foo", Branch: "main", Commit: "abc123", Subject: "fix bug", IsRepo: true}, &GitState{Worktree: "/foo", Branch: "main", Commit: "abc123", Subject: "add feature", IsRepo: true}, false},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := tt.a.Equal(tt.b); got != tt.expected {
+				t.Errorf("Equal() = %v, want %v", got, tt.expected)
+			}
+		})
+	}
+}
+
+func TestGitState_String(t *testing.T) {
+	tests := []struct {
+		name     string
+		state    *GitState
+		expected string
+	}{
+		{"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"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := tt.state.String(); got != tt.expected {
+				t.Errorf("String() = %q, want %q", got, tt.expected)
+			}
+		})
+	}
+}
+
+func runGit(t *testing.T, dir string, args ...string) {
+	t.Helper()
+	// For commits, use --no-verify to skip hooks
+	if len(args) > 0 && args[0] == "commit" {
+		newArgs := []string{"commit", "--no-verify"}
+		newArgs = append(newArgs, args[1:]...)
+		args = newArgs
+	}
+	cmd := exec.Command("git", args...)
+	cmd.Dir = dir
+	output, err := cmd.CombinedOutput()
+	if err != nil {
+		t.Fatalf("git %v failed: %v\n%s", args, err, output)
+	}
+}
+
+func runGitOutput(t *testing.T, dir string, args ...string) string {
+	t.Helper()
+	cmd := exec.Command("git", args...)
+	cmd.Dir = dir
+	output, err := cmd.Output()
+	if err != nil {
+		t.Fatalf("git %v failed: %v", args, err)
+	}
+	return string(output)
+}

loop/loop.go 🔗

@@ -8,36 +8,49 @@ import (
 	"time"
 
 	"shelley.exe.dev/claudetool"
+	"shelley.exe.dev/gitstate"
 	"shelley.exe.dev/llm"
 )
 
 // MessageRecordFunc is called to record new messages to persistent storage
 type MessageRecordFunc func(ctx context.Context, message llm.Message, usage llm.Usage) error
 
+// GitStateChangeFunc is called when the git state changes at the end of a turn.
+// This is used to record user-visible notifications about git changes.
+type GitStateChangeFunc func(ctx context.Context, state *gitstate.GitState)
+
 // Config contains all configuration needed to create a Loop
 type Config struct {
-	LLM           llm.Service
-	History       []llm.Message
-	Tools         []*llm.Tool
-	RecordMessage MessageRecordFunc
-	Logger        *slog.Logger
-	System        []llm.SystemContent
-	WorkingDir    string // working directory for tools
+	LLM              llm.Service
+	History          []llm.Message
+	Tools            []*llm.Tool
+	RecordMessage    MessageRecordFunc
+	Logger           *slog.Logger
+	System           []llm.SystemContent
+	WorkingDir       string // working directory for tools
+	OnGitStateChange GitStateChangeFunc
+	// GetWorkingDir returns the current working directory for tools.
+	// If set, this is called at end of turn to check for git state changes.
+	// If nil, Config.WorkingDir is used as a static value.
+	GetWorkingDir func() string
 }
 
 // Loop manages a conversation turn with an LLM including tool execution and message recording.
 // Notably, when the turn ends, the "Loop" is over. TODO: maybe rename to Turn?
 type Loop struct {
-	llm           llm.Service
-	tools         []*llm.Tool
-	recordMessage MessageRecordFunc
-	history       []llm.Message
-	messageQueue  []llm.Message
-	totalUsage    llm.Usage
-	mu            sync.Mutex
-	logger        *slog.Logger
-	system        []llm.SystemContent
-	workingDir    string
+	llm              llm.Service
+	tools            []*llm.Tool
+	recordMessage    MessageRecordFunc
+	history          []llm.Message
+	messageQueue     []llm.Message
+	totalUsage       llm.Usage
+	mu               sync.Mutex
+	logger           *slog.Logger
+	system           []llm.SystemContent
+	workingDir       string
+	onGitStateChange GitStateChangeFunc
+	getWorkingDir    func() string
+	lastGitState     *gitstate.GitState
 }
 
 // NewLoop creates a new Loop instance with the provided configuration
@@ -47,15 +60,25 @@ func NewLoop(config Config) *Loop {
 		logger = slog.Default()
 	}
 
+	// Get initial git state
+	workingDir := config.WorkingDir
+	if config.GetWorkingDir != nil {
+		workingDir = config.GetWorkingDir()
+	}
+	initialGitState := gitstate.GetGitState(workingDir)
+
 	return &Loop{
-		llm:           config.LLM,
-		history:       config.History,
-		tools:         config.Tools,
-		recordMessage: config.RecordMessage,
-		messageQueue:  make([]llm.Message, 0),
-		logger:        logger,
-		system:        config.System,
-		workingDir:    config.WorkingDir,
+		llm:              config.LLM,
+		history:          config.History,
+		tools:            config.Tools,
+		recordMessage:    config.RecordMessage,
+		messageQueue:     make([]llm.Message, 0),
+		logger:           logger,
+		system:           config.System,
+		workingDir:       config.WorkingDir,
+		onGitStateChange: config.OnGitStateChange,
+		getWorkingDir:    config.GetWorkingDir,
+		lastGitState:     initialGitState,
 	}
 }
 
@@ -265,9 +288,49 @@ func (l *Loop) processLLMRequest(ctx context.Context) error {
 		return l.handleToolCalls(ctx, resp.Content)
 	}
 
+	// End of turn - check for git state changes
+	l.checkGitStateChange(ctx)
+
 	return nil
 }
 
+// checkGitStateChange checks if the git state has changed and calls the callback if so.
+// This is called at the end of each turn.
+func (l *Loop) checkGitStateChange(ctx context.Context) {
+	if l.onGitStateChange == nil {
+		return
+	}
+
+	// Get current working directory
+	workingDir := l.workingDir
+	if l.getWorkingDir != nil {
+		workingDir = l.getWorkingDir()
+	}
+
+	// Get current git state
+	currentState := gitstate.GetGitState(workingDir)
+
+	// Compare with last known state
+	l.mu.Lock()
+	lastState := l.lastGitState
+	l.mu.Unlock()
+
+	// Check if state changed
+	if !currentState.Equal(lastState) {
+		l.mu.Lock()
+		l.lastGitState = currentState
+		l.mu.Unlock()
+
+		if currentState.IsRepo {
+			l.logger.Debug("git state changed",
+				"worktree", currentState.Worktree,
+				"branch", currentState.Branch,
+				"commit", currentState.Commit)
+			l.onGitStateChange(ctx, currentState)
+		}
+	}
+}
+
 // handleToolCalls processes tool calls from the LLM response
 func (l *Loop) handleToolCalls(ctx context.Context, content []llm.Content) error {
 	var toolResults []llm.Content

loop/loop_test.go 🔗

@@ -5,11 +5,14 @@ import (
 	"encoding/json"
 	"fmt"
 	"os"
+	"os/exec"
 	"path/filepath"
+	"sync"
 	"testing"
 	"time"
 
 	"shelley.exe.dev/claudetool"
+	"shelley.exe.dev/gitstate"
 	"shelley.exe.dev/llm"
 )
 
@@ -964,3 +967,200 @@ func TestInsertMissingToolResults_EmptyAssistantContent(t *testing.T) {
 		}
 	})
 }
+
+func TestGitStateTracking(t *testing.T) {
+	// Create a test repo
+	tmpDir := t.TempDir()
+
+	// Initialize git repo
+	runGit(t, tmpDir, "init")
+	runGit(t, tmpDir, "config", "user.email", "test@test.com")
+	runGit(t, tmpDir, "config", "user.name", "Test")
+
+	// Create initial commit
+	testFile := filepath.Join(tmpDir, "test.txt")
+	if err := os.WriteFile(testFile, []byte("hello"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	runGit(t, tmpDir, "add", ".")
+	runGit(t, tmpDir, "commit", "-m", "initial")
+
+	// Track git state changes
+	var mu sync.Mutex
+	var gitStateChanges []*gitstate.GitState
+
+	loop := NewLoop(Config{
+		LLM:           NewPredictableService(),
+		History:       []llm.Message{},
+		WorkingDir:    tmpDir,
+		GetWorkingDir: func() string { return tmpDir },
+		OnGitStateChange: func(ctx context.Context, state *gitstate.GitState) {
+			mu.Lock()
+			gitStateChanges = append(gitStateChanges, state)
+			mu.Unlock()
+		},
+		RecordMessage: func(ctx context.Context, message llm.Message, usage llm.Usage) error {
+			return nil
+		},
+	})
+
+	// Verify initial state was captured
+	if loop.lastGitState == nil {
+		t.Fatal("expected initial git state to be captured")
+	}
+	if !loop.lastGitState.IsRepo {
+		t.Error("expected IsRepo to be true")
+	}
+
+	// Process a turn (no state change should occur)
+	loop.QueueUserMessage(llm.Message{
+		Role:    llm.MessageRoleUser,
+		Content: []llm.Content{{Type: llm.ContentTypeText, Text: "hello"}},
+	})
+
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	err := loop.ProcessOneTurn(ctx)
+	if err != nil {
+		t.Fatalf("ProcessOneTurn failed: %v", err)
+	}
+
+	// No state change should have occurred
+	mu.Lock()
+	numChanges := len(gitStateChanges)
+	mu.Unlock()
+	if numChanges != 0 {
+		t.Errorf("expected no git state changes, got %d", numChanges)
+	}
+
+	// Now make a commit
+	if err := os.WriteFile(testFile, []byte("updated"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	runGit(t, tmpDir, "add", ".")
+	runGit(t, tmpDir, "commit", "-m", "update")
+
+	// Process another turn - this should detect the commit change
+	loop.QueueUserMessage(llm.Message{
+		Role:    llm.MessageRoleUser,
+		Content: []llm.Content{{Type: llm.ContentTypeText, Text: "hello again"}},
+	})
+
+	err = loop.ProcessOneTurn(ctx)
+	if err != nil {
+		t.Fatalf("ProcessOneTurn failed: %v", err)
+	}
+
+	// Now a state change should have been detected
+	mu.Lock()
+	numChanges = len(gitStateChanges)
+	mu.Unlock()
+	if numChanges != 1 {
+		t.Errorf("expected 1 git state change, got %d", numChanges)
+	}
+}
+
+func TestGitStateTrackingWorktree(t *testing.T) {
+	tmpDir, err := filepath.EvalSymlinks(t.TempDir())
+	if err != nil {
+		t.Fatal(err)
+	}
+	mainRepo := filepath.Join(tmpDir, "main")
+	worktreeDir := filepath.Join(tmpDir, "worktree")
+
+	// Create main repo
+	if err := os.MkdirAll(mainRepo, 0o755); err != nil {
+		t.Fatal(err)
+	}
+	runGit(t, mainRepo, "init")
+	runGit(t, mainRepo, "config", "user.email", "test@test.com")
+	runGit(t, mainRepo, "config", "user.name", "Test")
+
+	// Create initial commit
+	testFile := filepath.Join(mainRepo, "test.txt")
+	if err := os.WriteFile(testFile, []byte("hello"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	runGit(t, mainRepo, "add", ".")
+	runGit(t, mainRepo, "commit", "-m", "initial")
+
+	// Create a worktree
+	runGit(t, mainRepo, "worktree", "add", "-b", "feature", worktreeDir)
+
+	// Track git state changes in the worktree
+	var mu sync.Mutex
+	var gitStateChanges []*gitstate.GitState
+
+	loop := NewLoop(Config{
+		LLM:           NewPredictableService(),
+		History:       []llm.Message{},
+		WorkingDir:    worktreeDir,
+		GetWorkingDir: func() string { return worktreeDir },
+		OnGitStateChange: func(ctx context.Context, state *gitstate.GitState) {
+			mu.Lock()
+			gitStateChanges = append(gitStateChanges, state)
+			mu.Unlock()
+		},
+		RecordMessage: func(ctx context.Context, message llm.Message, usage llm.Usage) error {
+			return nil
+		},
+	})
+
+	// Verify initial state
+	if loop.lastGitState == nil {
+		t.Fatal("expected initial git state to be captured")
+	}
+	if loop.lastGitState.Branch != "feature" {
+		t.Errorf("expected branch 'feature', got %q", loop.lastGitState.Branch)
+	}
+	if loop.lastGitState.Worktree != worktreeDir {
+		t.Errorf("expected worktree %q, got %q", worktreeDir, loop.lastGitState.Worktree)
+	}
+
+	// Make a commit in the worktree
+	worktreeFile := filepath.Join(worktreeDir, "feature.txt")
+	if err := os.WriteFile(worktreeFile, []byte("feature content"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	runGit(t, worktreeDir, "add", ".")
+	runGit(t, worktreeDir, "commit", "-m", "feature commit")
+
+	// Process a turn to detect the change
+	loop.QueueUserMessage(llm.Message{
+		Role:    llm.MessageRoleUser,
+		Content: []llm.Content{{Type: llm.ContentTypeText, Text: "hello"}},
+	})
+
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	err = loop.ProcessOneTurn(ctx)
+	if err != nil {
+		t.Fatalf("ProcessOneTurn failed: %v", err)
+	}
+
+	mu.Lock()
+	numChanges := len(gitStateChanges)
+	mu.Unlock()
+
+	if numChanges != 1 {
+		t.Errorf("expected 1 git state change in worktree, got %d", numChanges)
+	}
+}
+
+func runGit(t *testing.T, dir string, args ...string) {
+	t.Helper()
+	// For commits, use --no-verify to skip hooks
+	if len(args) > 0 && args[0] == "commit" {
+		newArgs := []string{"commit", "--no-verify"}
+		newArgs = append(newArgs, args[1:]...)
+		args = newArgs
+	}
+	cmd := exec.Command("git", args...)
+	cmd.Dir = dir
+	output, err := cmd.CombinedOutput()
+	if err != nil {
+		t.Fatalf("git %v failed: %v\n%s", args, err, output)
+	}
+}

server/convo.go 🔗

@@ -11,6 +11,7 @@ import (
 	"shelley.exe.dev/claudetool"
 	"shelley.exe.dev/db"
 	"shelley.exe.dev/db/generated"
+	"shelley.exe.dev/gitstate"
 	"shelley.exe.dev/llm"
 	"shelley.exe.dev/loop"
 	"shelley.exe.dev/subpub"
@@ -217,6 +218,11 @@ func (cm *ConversationManager) partitionMessages(messages []generated.Message) (
 	var system []llm.SystemContent
 
 	for _, msg := range messages {
+		// Skip gitinfo messages - they are user-visible only, not sent to LLM
+		if msg.Type == string(db.MessageTypeGitInfo) {
+			continue
+		}
+
 		llmMsg, err := convertToLLMMessage(msg)
 		if err != nil {
 			cm.logger.Warn("Failed to convert message to LLM format", "messageID", msg.MessageID, "error", err)
@@ -293,6 +299,10 @@ func (cm *ConversationManager) ensureLoop(service llm.Service, modelID string) e
 		Logger:        logger,
 		System:        system,
 		WorkingDir:    cwd,
+		GetWorkingDir: toolSet.WorkingDir().Get,
+		OnGitStateChange: func(ctx context.Context, state *gitstate.GitState) {
+			cm.recordGitStateChange(ctx, state)
+		},
 	})
 
 	cm.mu.Lock()
@@ -481,3 +491,73 @@ func (cm *ConversationManager) CancelConversation(ctx context.Context) error {
 
 	return nil
 }
+
+// GitInfoUserData is the structured data stored in user_data for gitinfo messages.
+type GitInfoUserData struct {
+	Worktree string `json:"worktree"`
+	Branch   string `json:"branch"`
+	Commit   string `json:"commit"`
+	Subject  string `json:"subject"`
+	Text     string `json:"text"` // Human-readable description
+}
+
+// recordGitStateChange creates a gitinfo message when git state changes.
+// This message is visible to users in the UI but is not sent to the LLM.
+func (cm *ConversationManager) recordGitStateChange(ctx context.Context, state *gitstate.GitState) {
+	if state == nil || !state.IsRepo {
+		return
+	}
+
+	// Create a gitinfo message with the state description
+	message := llm.Message{
+		Role:    llm.MessageRoleAssistant,
+		Content: []llm.Content{{Type: llm.ContentTypeText, Text: state.String()}},
+	}
+
+	userData := GitInfoUserData{
+		Worktree: state.Worktree,
+		Branch:   state.Branch,
+		Commit:   state.Commit,
+		Subject:  state.Subject,
+		Text:     state.String(),
+	}
+
+	createdMsg, err := cm.db.CreateMessage(ctx, db.CreateMessageParams{
+		ConversationID: cm.conversationID,
+		Type:           db.MessageTypeGitInfo,
+		LLMData:        message,
+		UserData:       userData,
+		UsageData:      llm.Usage{},
+	})
+	if err != nil {
+		cm.logger.Error("Failed to record git state change", "error", err)
+		return
+	}
+
+	cm.logger.Debug("Recorded git state change", "state", state.String())
+
+	// Notify subscribers so the UI updates
+	go cm.notifyGitStateChange(context.WithoutCancel(ctx), createdMsg)
+}
+
+// notifyGitStateChange publishes a gitinfo message to subscribers.
+func (cm *ConversationManager) notifyGitStateChange(ctx context.Context, msg *generated.Message) {
+	var conversation generated.Conversation
+	err := cm.db.Queries(ctx, func(q *generated.Queries) error {
+		var err error
+		conversation, err = q.GetConversation(ctx, cm.conversationID)
+		return err
+	})
+	if err != nil {
+		cm.logger.Error("Failed to get conversation for git state notification", "error", err)
+		return
+	}
+
+	apiMessages := toAPIMessages([]generated.Message{*msg})
+	streamData := StreamResponse{
+		Messages:     apiMessages,
+		Conversation: conversation,
+		AgentWorking: false, // Gitinfo is recorded at end of turn, agent is done
+	}
+	cm.subpub.Publish(msg.SequenceID, streamData)
+}

server/server.go 🔗

@@ -189,6 +189,10 @@ func isEndOfTurn(msg *generated.Message) bool {
 	if msg.Type == string(db.MessageTypeError) {
 		return true
 	}
+	// Gitinfo messages are metadata, not part of the agent turn - ignore them
+	if msg.Type == string(db.MessageTypeGitInfo) {
+		return false
+	}
 	// Only agent messages can have end_of_turn
 	if msg.Type != string(db.MessageTypeAgent) {
 		return false

test/server_test.go 🔗

@@ -11,6 +11,7 @@ import (
 	"net/http/httptest"
 	"net/url"
 	"os"
+	"os/exec"
 	"path/filepath"
 	"strings"
 	"testing"
@@ -1009,3 +1010,117 @@ func TestScreenshotRouteServesImage(t *testing.T) {
 		t.Fatalf("expected Cache-Control header to be set")
 	}
 }
+
+// TestGitStateChangeCreatesGitInfoMessage verifies that when the agent makes a git commit,
+// a gitinfo message is created in the database.
+func TestGitStateChangeCreatesGitInfoMessage(t *testing.T) {
+	ctx := context.Background()
+
+	// Create a temp directory with a git repo
+	workDir := t.TempDir()
+
+	// Initialize git repo
+	runCmd := func(name string, args ...string) {
+		cmd := exec.Command(name, args...)
+		cmd.Dir = workDir
+		out, err := cmd.CombinedOutput()
+		if err != nil {
+			t.Fatalf("Command %s %v failed: %v\n%s", name, args, err, out)
+		}
+	}
+	runCmd("git", "init")
+	runCmd("git", "config", "user.email", "test@example.com")
+	runCmd("git", "config", "user.name", "Test User")
+
+	// Create initial commit
+	initialFile := filepath.Join(workDir, "initial.txt")
+	if err := os.WriteFile(initialFile, []byte("initial content"), 0o644); err != nil {
+		t.Fatalf("Failed to write initial file: %v", err)
+	}
+	runCmd("git", "add", ".")
+	runCmd("git", "commit", "-m", "Initial commit")
+
+	// Create database
+	tempDB := t.TempDir() + "/gitstate_test.db"
+	database, err := db.New(db.Config{DSN: tempDB})
+	if err != nil {
+		t.Fatalf("Failed to create database: %v", err)
+	}
+	defer database.Close()
+	if err := database.Migrate(ctx); err != nil {
+		t.Fatalf("Failed to migrate database: %v", err)
+	}
+
+	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
+
+	// Create LLM manager that returns predictable service
+	predictableService := loop.NewPredictableService()
+	customLLMManager := &inspectableLLMManager{
+		predictableService: predictableService,
+		logger:             logger,
+	}
+
+	// Create server with git repo as working directory
+	toolConfig := claudetool.ToolSetConfig{
+		WorkingDir:    workDir,
+		EnableBrowser: false,
+	}
+	svr := server.NewServer(database, customLLMManager, toolConfig, logger, false, "", "", "", nil)
+
+	mux := http.NewServeMux()
+	svr.RegisterRoutes(mux)
+	ts := httptest.NewServer(mux)
+	defer ts.Close()
+
+	// The test command creates a file and commits it. We use explicit paths to avoid bash safety checks.
+	// NOTE: We must set cwd when creating the conversation so the tools run in our git repo.
+	chatReq := map[string]interface{}{
+		"message": "bash: echo 'new content' > newfile.txt && git add newfile.txt && git commit -m 'Add new file'",
+		"model":   "predictable",
+		"cwd":     workDir,
+	}
+	body, _ := json.Marshal(chatReq)
+	resp, err := http.Post(ts.URL+"/api/conversations/new", "application/json", bytes.NewBuffer(body))
+	if err != nil {
+		t.Fatalf("Failed to send message: %v", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusCreated {
+		body, _ := io.ReadAll(resp.Body)
+		t.Fatalf("Expected status 201, got %d: %s", resp.StatusCode, body)
+	}
+
+	var createResp struct {
+		ConversationID string `json:"conversation_id"`
+	}
+	if err := json.NewDecoder(resp.Body).Decode(&createResp); err != nil {
+		t.Fatalf("Failed to decode response: %v", err)
+	}
+
+	// Poll for the gitinfo message to appear
+	var foundGitInfo bool
+	for i := 0; i < 50; i++ {
+		time.Sleep(100 * time.Millisecond)
+
+		messages, err := database.ListMessagesByConversationPaginated(ctx, createResp.ConversationID, 100, 0)
+		if err != nil {
+			continue
+		}
+
+		for _, msg := range messages {
+			if msg.Type == string(db.MessageTypeGitInfo) {
+				foundGitInfo = true
+				t.Logf("Found gitinfo message: %v", msg.UserData)
+				break
+			}
+		}
+		if foundGitInfo {
+			break
+		}
+	}
+
+	if !foundGitInfo {
+		t.Fatal("Expected a gitinfo message to be created after git commit, but none was found")
+	}
+}

ui/src/components/ChatInterface.tsx 🔗

@@ -429,6 +429,9 @@ function ChatInterface({
   // Settings modal removed - configuration moved to status bar for empty conversations
   const [showOverflowMenu, setShowOverflowMenu] = useState(false);
   const [showDiffViewer, setShowDiffViewer] = useState(false);
+  const [diffViewerInitialCommit, setDiffViewerInitialCommit] = useState<string | undefined>(
+    undefined,
+  );
   const [diffCommentText, setDiffCommentText] = useState("");
   const [agentWorking, setAgentWorking] = useState(false);
   const [cancelling, setCancelling] = useState(false);
@@ -918,7 +921,16 @@ function ChatInterface({
 
     const rendered = coalescedItems.map((item, index) => {
       if (item.type === "message" && item.message) {
-        return <MessageComponent key={item.message.message_id} message={item.message} />;
+        return (
+          <MessageComponent
+            key={item.message.message_id}
+            message={item.message}
+            onOpenDiffViewer={(commit) => {
+              setDiffViewerInitialCommit(commit);
+              setShowDiffViewer(true);
+            }}
+          />
+        );
       } else if (item.type === "tool") {
         return (
           <CoalescedToolCall
@@ -1273,8 +1285,12 @@ function ChatInterface({
       <DiffViewer
         cwd={currentConversation?.cwd || selectedCwd}
         isOpen={showDiffViewer}
-        onClose={() => setShowDiffViewer(false)}
+        onClose={() => {
+          setShowDiffViewer(false);
+          setDiffViewerInitialCommit(undefined);
+        }}
         onCommentTextChange={setDiffCommentText}
+        initialCommit={diffViewerInitialCommit}
       />
     </div>
   );

ui/src/components/DiffViewer.tsx 🔗

@@ -8,6 +8,7 @@ interface DiffViewerProps {
   isOpen: boolean;
   onClose: () => void;
   onCommentTextChange: (text: string) => void;
+  initialCommit?: string; // If set, select this commit when opening
 }
 
 // Icon components for cleaner JSX
@@ -77,7 +78,7 @@ function loadMonaco(): Promise<typeof Monaco> {
 
 type ViewMode = "comment" | "edit";
 
-function DiffViewer({ cwd, isOpen, onClose, onCommentTextChange }: DiffViewerProps) {
+function DiffViewer({ cwd, isOpen, onClose, onCommentTextChange, initialCommit }: DiffViewerProps) {
   const [diffs, setDiffs] = useState<GitDiffInfo[]>([]);
   const [gitRoot, setGitRoot] = useState<string | null>(null);
   const [selectedDiff, setSelectedDiff] = useState<string | null>(null);
@@ -174,7 +175,7 @@ function DiffViewer({ cwd, isOpen, onClose, onCommentTextChange }: DiffViewerPro
         editorRef.current = null;
       }
     }
-  }, [isOpen, cwd]);
+  }, [isOpen, cwd, initialCommit]);
 
   // Load files when diff is selected
   useEffect(() => {
@@ -342,6 +343,18 @@ function DiffViewer({ cwd, isOpen, onClose, onCommentTextChange }: DiffViewerPro
       const response = await api.getGitDiffs(cwd);
       setDiffs(response.diffs);
       setGitRoot(response.gitRoot);
+
+      // If initialCommit is set, try to select that commit
+      if (initialCommit) {
+        const matchingDiff = response.diffs.find(
+          (d) => d.id === initialCommit || d.id.startsWith(initialCommit),
+        );
+        if (matchingDiff) {
+          setSelectedDiff(matchingDiff.id);
+          return;
+        }
+      }
+
       // Auto-select working changes if non-empty
       if (response.diffs.length > 0) {
         const working = response.diffs.find((d) => d.id === "working");

ui/src/components/Message.tsx 🔗

@@ -24,14 +24,90 @@ interface ToolDisplay {
 
 interface MessageProps {
   message: MessageType;
+  onOpenDiffViewer?: (commit: string) => void;
 }
 
-function Message({ message }: MessageProps) {
+function Message({ message, onOpenDiffViewer }: MessageProps) {
   // Hide system messages from the UI
   if (message.type === "system") {
     return null;
   }
 
+  // Render gitinfo messages as compact status updates
+  if (message.type === "gitinfo") {
+    // Parse user_data which contains structured git state info
+    let commitHash: string | null = null;
+    let subject: string | null = null;
+    let branch: string | null = null;
+
+    if (message.user_data) {
+      try {
+        const userData =
+          typeof message.user_data === "string" ? JSON.parse(message.user_data) : message.user_data;
+        if (userData.commit) {
+          commitHash = userData.commit;
+        }
+        if (userData.subject) {
+          subject = userData.subject;
+        }
+        if (userData.branch) {
+          branch = userData.branch;
+        }
+      } catch (err) {
+        console.error("Failed to parse gitinfo user_data:", err);
+      }
+    }
+
+    if (!commitHash) {
+      return null;
+    }
+
+    const canShowDiff = commitHash && onOpenDiffViewer;
+
+    const handleDiffClick = () => {
+      if (commitHash && onOpenDiffViewer) {
+        onOpenDiffViewer(commitHash);
+      }
+    };
+
+    return (
+      <div
+        className="message message-gitinfo"
+        data-testid="message-gitinfo"
+        style={{
+          padding: "0.4rem 1rem",
+          fontSize: "0.8rem",
+          color: "var(--text-secondary)",
+          textAlign: "center",
+          fontStyle: "italic",
+        }}
+      >
+        <span>
+          {branch} now at {commitHash}
+          {subject && ` "${subject}"`}
+          {canShowDiff && (
+            <>
+              {" "}
+              <a
+                href="#"
+                onClick={(e) => {
+                  e.preventDefault();
+                  handleDiffClick();
+                }}
+                style={{
+                  color: "var(--link-color, #0066cc)",
+                  textDecoration: "underline",
+                }}
+              >
+                diff
+              </a>
+            </>
+          )}
+        </span>
+      </div>
+    );
+  }
+
   // Context menu state
   const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
   const [showUsageModal, setShowUsageModal] = useState(false);

ui/src/generated-types.ts 🔗

@@ -43,4 +43,4 @@ export interface StreamResponseForTS {
   agent_working: boolean;
 }
 
-export type MessageType = "user" | "agent" | "tool" | "error" | "system";
+export type MessageType = "user" | "agent" | "tool" | "error" | "system" | "gitinfo";