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 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-02-22 15:52:25 +09:00
parent 552c819d55
commit ce2ab80dc7
4 changed files with 185 additions and 3 deletions

View file

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

View file

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

View file

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

View file

@ -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); }
</style>
</head>
<body>
@ -575,6 +605,7 @@
<button class="tab" data-panel="skills">Skills</button>
<button class="tab" data-panel="session">Session</button>
<button class="tab" data-panel="config">Config</button>
<button class="tab" data-panel="git">Git</button>
</div>
</div>
@ -611,6 +642,11 @@
</div>
</div>
<div id="git" class="panel">
<div class="loading" id="git-loading">Loading git log...</div>
<div id="git-content" class="hidden"></div>
</div>
<div class="send-bar hidden" id="send-bar">
<input id="skill-msg" class="send-input glass glass-interactive" placeholder="Message for skill...">
<button class="send-btn" id="send-skill-btn" onclick="sendSkillCommand()">Send</button>
@ -649,6 +685,7 @@ tabs.forEach((tab, index) => {
if (p === 'plan' && !fresh) loadPlan();
if (p === 'skills' && !fresh) loadSkills();
if (p === 'session' && !fresh) loadSession();
if (p === 'git') loadGit();
});
});
@ -1079,6 +1116,49 @@ function escapeAttr(s) {
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// ── Git tab ──
function loadGit() {
return loadTab('git-loading', 'git-content', 'git log',
function() { return apiFetch('/miniapp/api/git'); },
renderGitFromData);
}
function renderGitFromData(data) {
var loading = document.getElementById('git-loading');
var el = document.getElementById('git-content');
loading.classList.add('hidden');
el.classList.remove('hidden');
var html = '<div class="card glass"><div class="card-title">Branch: ' +
escapeHtml(data.branch || 'unknown') + '</div>';
if (data.commits && data.commits.length > 0) {
data.commits.forEach(function(c) {
html += '<div class="git-commit">' +
'<span class="git-hash">' + escapeHtml(c.hash) + '</span>' +
'<span class="git-subject">' + escapeHtml(c.subject) + '</span>' +
'<span class="git-meta">' + escapeHtml(c.date) + '</span>' +
'</div>';
});
} else {
html += '<div style="padding:12px;color:var(--hint)">No commits found.</div>';
}
html += '</div>';
if (data.modified && data.modified.length > 0) {
html += '<div class="card glass" style="margin-top:12px"><div class="card-title">Changes (' + data.modified.length + ')</div>';
data.modified.forEach(function(f) {
html += '<div class="git-commit">' +
'<span class="git-status git-status-' + (f.status === '??' ? 'u' : f.status.toLowerCase()) + '">' + escapeHtml(f.status) + '</span>' +
'<span class="git-subject">' + escapeHtml(f.path) + '</span>' +
'</div>';
});
html += '</div>';
}
el.innerHTML = html;
}
// ── SSE real-time updates ──
var eventSource = null;