From bd738fb6ff483aea47383d07c3988fb4c2bb9837 Mon Sep 17 00:00:00 2001 From: Philip Zeyliger Date: Fri, 2 Jan 2026 20:41:56 -0800 Subject: [PATCH] shelley: add git state tracking and clickable gitinfo messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- 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(-) create mode 100644 db/schema/008-add-gitinfo-message-type.sql create mode 100644 gitstate/gitstate.go create mode 100644 gitstate/gitstate_test.go diff --git a/claudetool/changedir.go b/claudetool/changedir.go index 0c730f64b52f30157d595377d571691db1e1b3eb..8299cd6fa8faa8fef9cbd8b09fa447d9b65ed2db 100644 --- a/claudetool/changedir.go +++ b/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), } } diff --git a/cmd/go2ts.go b/cmd/go2ts.go index c615de287e67cef22046dbd93b3f28a9acda3f90..593a81693a7aeb0569899272bc37c9f5fb52ef3f 100644 --- a/cmd/go2ts.go +++ b/cmd/go2ts.go @@ -53,6 +53,7 @@ func TS() *go2ts.Go2TS { db.MessageTypeTool, db.MessageTypeError, db.MessageTypeSystem, + db.MessageTypeGitInfo, }, ) diff --git a/db/db.go b/db/db.go index ae5808ce25e04ca430d5cfe64d6aeda679871c35..36ec8e75e6bb7c38b405bb71a60e89d4b9eacc84 100644 --- a/db/db.go +++ b/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 diff --git a/db/schema/008-add-gitinfo-message-type.sql b/db/schema/008-add-gitinfo-message-type.sql new file mode 100644 index 0000000000000000000000000000000000000000..6aaece79af9470f61cd43c5e9eba19beae682a14 --- /dev/null +++ b/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); diff --git a/gitstate/gitstate.go b/gitstate/gitstate.go new file mode 100644 index 0000000000000000000000000000000000000000..edbaab0593671e669dda4d4f37e7635ccd0342c1 --- /dev/null +++ b/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 +} diff --git a/gitstate/gitstate_test.go b/gitstate/gitstate_test.go new file mode 100644 index 0000000000000000000000000000000000000000..61b48c8be99705f87031d3eb045d3b3c4ea23637 --- /dev/null +++ b/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) +} diff --git a/loop/loop.go b/loop/loop.go index 26fabbb7b7a5da072c844aa78e2e8db04d350990..06e0eecb33e86111477affc1128f09ecccdf71af 100644 --- a/loop/loop.go +++ b/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 diff --git a/loop/loop_test.go b/loop/loop_test.go index 311d63309514d53d201b31df731ce596ce8cf81f..6518af77c3dc5c1f2019078f00d77db231ceb8d6 100644 --- a/loop/loop_test.go +++ b/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) + } +} diff --git a/server/convo.go b/server/convo.go index d26a48164c1fca1b0684447520b2b4111b70118c..b0a1493fb8e8dd7378b530b383c2d801fdd00519 100644 --- a/server/convo.go +++ b/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) +} diff --git a/server/server.go b/server/server.go index 2ba1fa243e59dae5a53a574db799caf3b100b57f..ae2c65921c589824331284b5f8167017951c4924 100644 --- a/server/server.go +++ b/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 diff --git a/test/server_test.go b/test/server_test.go index 93d32d42e581e15aa8a1ef27f953463185a033fd..dfc908a446d459ac2e6f19deb70f0b0bc2817099 100644 --- a/test/server_test.go +++ b/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") + } +} diff --git a/ui/src/components/ChatInterface.tsx b/ui/src/components/ChatInterface.tsx index b643cdea89c8d58465bc4a9c97c911fad5e20830..d0c75365eb348cccf70e1e3209cdf192cc30936e 100644 --- a/ui/src/components/ChatInterface.tsx +++ b/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( + 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 ; + return ( + { + setDiffViewerInitialCommit(commit); + setShowDiffViewer(true); + }} + /> + ); } else if (item.type === "tool") { return ( setShowDiffViewer(false)} + onClose={() => { + setShowDiffViewer(false); + setDiffViewerInitialCommit(undefined); + }} onCommentTextChange={setDiffCommentText} + initialCommit={diffViewerInitialCommit} /> ); diff --git a/ui/src/components/DiffViewer.tsx b/ui/src/components/DiffViewer.tsx index d560b02363cd6a28f89cee2c410fc13665892f11..70822cf8fdcfa380a496064e3bbb4f98d839a014 100644 --- a/ui/src/components/DiffViewer.tsx +++ b/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 { type ViewMode = "comment" | "edit"; -function DiffViewer({ cwd, isOpen, onClose, onCommentTextChange }: DiffViewerProps) { +function DiffViewer({ cwd, isOpen, onClose, onCommentTextChange, initialCommit }: DiffViewerProps) { const [diffs, setDiffs] = useState([]); const [gitRoot, setGitRoot] = useState(null); const [selectedDiff, setSelectedDiff] = useState(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"); diff --git a/ui/src/components/Message.tsx b/ui/src/components/Message.tsx index 97537b1a0614fc84fa33e256f8a863bf2b881e06..48d607b4ade7309f6d42a79656e675df2389f44a 100644 --- a/ui/src/components/Message.tsx +++ b/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 ( +
+ + {branch} now at {commitHash} + {subject && ` "${subject}"`} + {canShowDiff && ( + <> + {" "} + { + e.preventDefault(); + handleDiffClick(); + }} + style={{ + color: "var(--link-color, #0066cc)", + textDecoration: "underline", + }} + > + diff + + + )} + +
+ ); + } + // Context menu state const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const [showUsageModal, setShowUsageModal] = useState(false); diff --git a/ui/src/generated-types.ts b/ui/src/generated-types.ts index d14e7cf9e6ce4ba97394cedfe4ed49c82d5f1dd4..d438ab67eee810a7671cd7840ea98802c426ebfb 100644 --- a/ui/src/generated-types.ts +++ b/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";