From 1b2fce9c506e36c1ef4b157982893f57b72c9281 Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Sun, 22 Feb 2026 15:52:25 +0900 Subject: [PATCH 1/2] feat: add Git tab to MiniApp for commit history and uncommitted changes Show branch name, recent 20 commits, and working tree changes (modified/untracked files) in a new Git tab. Data is fetched on-demand via HTTP API with 30s TTL cache to keep load low on SBC hardware. Co-Authored-By: Claude Opus 4.6 --- cmd/picoclaw/cmd_gateway.go | 73 ++++++++++++++++++++++++++++++- pkg/miniapp/miniapp.go | 27 ++++++++++++ pkg/miniapp/miniapp_test.go | 6 +++ pkg/miniapp/static/index.html | 82 ++++++++++++++++++++++++++++++++++- 4 files changed, 185 insertions(+), 3 deletions(-) diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/cmd_gateway.go index 6a8e68e0d..bf85cb9fc 100644 --- a/cmd/picoclaw/cmd_gateway.go +++ b/cmd/picoclaw/cmd_gateway.go @@ -8,6 +8,7 @@ import ( "fmt" "net/http" "os" + "os/exec" "os/signal" "path/filepath" "strings" @@ -231,7 +232,7 @@ func gatewayCmd() { } if webAppURL != "" { - provider := &agentLoopDataProvider{loop: agentLoop} + provider := &agentLoopDataProvider{loop: agentLoop, workspace: cfg.WorkspacePath()} sender := &telegramCommandSender{bus: msgBus} miniappNotifier = miniapp.NewStateNotifier() handler := miniapp.NewHandler(provider, sender, cfg.Channels.Telegram.Token, miniappNotifier) @@ -308,9 +309,15 @@ func setupCronTool( // agentLoopDataProvider adapts AgentLoop to the miniapp.DataProvider interface. type agentLoopDataProvider struct { - loop *agent.AgentLoop + loop *agent.AgentLoop + workspace string + + gitCache miniapp.GitInfo + gitCacheAt time.Time } +const gitCacheTTL = 30 * time.Second + func (p *agentLoopDataProvider) ListSkills() []skills.SkillInfo { return p.loop.ListSkills() } @@ -368,6 +375,68 @@ func (p *agentLoopDataProvider) GetActiveSessions() []miniapp.SessionInfo { return result } +func (p *agentLoopDataProvider) GetGitInfo() miniapp.GitInfo { + if time.Since(p.gitCacheAt) < gitCacheTTL { + return p.gitCache + } + + info := miniapp.GitInfo{} + if p.workspace == "" { + return info + } + + // Detect git root from workspace + topOut, err := exec.Command("git", "-C", p.workspace, "rev-parse", "--show-toplevel").Output() + if err != nil { + return info // not a git repo + } + gitRoot := strings.TrimSpace(string(topOut)) + + // Exclude ~/.picoclaw/workspace* (picoclaw's own managed workspace) + home, _ := os.UserHomeDir() + picoDir := filepath.Join(home, ".picoclaw") + if strings.HasPrefix(gitRoot, picoDir) { + return info + } + + // Current branch + out, err := exec.Command("git", "-C", gitRoot, "rev-parse", "--abbrev-ref", "HEAD").Output() + if err == nil { + info.Branch = strings.TrimSpace(string(out)) + } + + // Recent commits (20 entries) + out, err = exec.Command("git", "-C", gitRoot, "log", "--pretty=format:%h\x1f%s\x1f%an\x1f%cr", "-20").Output() + if err == nil { + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + parts := strings.SplitN(line, "\x1f", 4) + if len(parts) == 4 { + info.Commits = append(info.Commits, miniapp.GitCommit{ + Hash: parts[0], Subject: parts[1], Author: parts[2], Date: parts[3], + }) + } + } + } + + // Modified/untracked files (git status --porcelain) + out, err = exec.Command("git", "-C", gitRoot, "status", "--porcelain").Output() + if err == nil && len(out) > 0 { + for _, line := range strings.Split(strings.TrimRight(string(out), "\n"), "\n") { + if len(line) < 4 { + continue + } + info.Modified = append(info.Modified, miniapp.GitChange{ + Status: strings.TrimSpace(line[:2]), + Path: line[3:], + }) + } + } + + p.gitCache = info + p.gitCacheAt = time.Now() + return info +} + // telegramCommandSender injects Mini App commands into the message bus. type telegramCommandSender struct { bus *bus.MessageBus diff --git a/pkg/miniapp/miniapp.go b/pkg/miniapp/miniapp.go index 026264297..199954832 100644 --- a/pkg/miniapp/miniapp.go +++ b/pkg/miniapp/miniapp.go @@ -58,12 +58,34 @@ type SessionInfo struct { AgeSec int `json:"age_sec"` } +// GitInfo represents the git repository state exposed via the API. +type GitInfo struct { + Branch string `json:"branch"` + Commits []GitCommit `json:"commits"` + Modified []GitChange `json:"modified"` +} + +// GitCommit represents a single commit entry. +type GitCommit struct { + Hash string `json:"hash"` + Subject string `json:"subject"` + Author string `json:"author"` + Date string `json:"date"` +} + +// GitChange represents a modified/untracked file entry. +type GitChange struct { + Status string `json:"status"` + Path string `json:"path"` +} + // DataProvider is the read-only interface to agent state for the Mini App API. type DataProvider interface { ListSkills() []skills.SkillInfo GetPlanInfo() PlanInfo GetSessionStats() *stats.Stats GetActiveSessions() []SessionInfo + GetGitInfo() GitInfo } // CommandSender injects a command into the message bus on behalf of a user. @@ -154,6 +176,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/miniapp/api/session", h.requireAuth(h.apiSession)) mux.HandleFunc("/miniapp/api/sessions", h.requireAuth(h.apiSessions)) mux.HandleFunc("/miniapp/api/command", h.requireAuth(h.apiCommand)) + mux.HandleFunc("/miniapp/api/git", h.requireAuth(h.apiGit)) mux.HandleFunc("/miniapp/api/events", h.requireAuth(h.apiEvents)) } @@ -209,6 +232,10 @@ func (h *Handler) apiSession(w http.ResponseWriter, r *http.Request) { writeJSON(w, s) } +func (h *Handler) apiGit(w http.ResponseWriter, r *http.Request) { + writeJSON(w, h.provider.GetGitInfo()) +} + func (h *Handler) apiCommand(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) diff --git a/pkg/miniapp/miniapp_test.go b/pkg/miniapp/miniapp_test.go index a1142b859..d2b8869c3 100644 --- a/pkg/miniapp/miniapp_test.go +++ b/pkg/miniapp/miniapp_test.go @@ -171,6 +171,9 @@ func (m *mockDataProvider) GetSessionStats() *stats.Stats { func (m *mockDataProvider) GetActiveSessions() []SessionInfo { return []SessionInfo{} } +func (m *mockDataProvider) GetGitInfo() GitInfo { + return GitInfo{} +} type mockSender struct{} @@ -445,6 +448,9 @@ func (m *mutatingDataProvider) GetSessionStats() *stats.Stats { return nil } func (m *mutatingDataProvider) GetActiveSessions() []SessionInfo { return []SessionInfo{} } +func (m *mutatingDataProvider) GetGitInfo() GitInfo { + return GitInfo{} +} // drainEvents reads SSE event lines until it collects `want` distinct event names or times out. func drainEvents(t *testing.T, scanner *bufio.Scanner, want int, timeout time.Duration) map[string]bool { diff --git a/pkg/miniapp/static/index.html b/pkg/miniapp/static/index.html index bbe9c13bc..c35c3420a 100644 --- a/pkg/miniapp/static/index.html +++ b/pkg/miniapp/static/index.html @@ -84,7 +84,7 @@ top: 2px; bottom: 2px; left: 2px; - width: calc(25% - 2px); + width: calc(20% - 2px); border-radius: 8px; background: var(--tab-pill-bg); box-shadow: 0 0.5px 2px rgba(0,0,0,0.12), 0 0.5px 1px rgba(0,0,0,0.08); @@ -564,6 +564,36 @@ animation: none; } + /* Git log */ + .git-commit { + display: flex; + gap: 8px; + padding: 10px 12px; + border-bottom: 1px solid var(--glass-border); + font-size: 13px; + line-height: 1.4; + } + .git-commit:last-child { border-bottom: none; } + .git-hash { + font-family: monospace; + color: var(--btn); + flex-shrink: 0; + } + .git-subject { flex: 1; color: var(--text); } + .git-meta { color: var(--hint); font-size: 11px; flex-shrink: 0; text-align: right; } + .git-status { + font-family: monospace; + font-weight: 700; + font-size: 12px; + flex-shrink: 0; + width: 24px; + text-align: center; + } + .git-status-m { color: #e2b93d; } + .git-status-a { color: #4caf50; } + .git-status-d { color: #ef5350; } + .git-status-u { color: var(--hint); } + @@ -575,6 +605,7 @@ + @@ -611,6 +642,11 @@ +
+
Loading git log...
+ +
+