From a6a3aef5354ae28987ffe3bbb06ed02c55bc3d40 Mon Sep 17 00:00:00 2001 From: MartianGreed Date: Tue, 20 Jan 2026 16:06:26 +0100 Subject: [PATCH 1/2] fix: detect idle state when Claude Code logo is visible --- internal/claude/detector.go | 1 + internal/claude/detector_test.go | 1 + 2 files changed, 2 insertions(+) diff --git a/internal/claude/detector.go b/internal/claude/detector.go index d813e98..d974c5e 100644 --- a/internal/claude/detector.go +++ b/internal/claude/detector.go @@ -82,6 +82,7 @@ func NewDetector() *Detector { regexp.MustCompile(`(?m)claude>\s*$`), regexp.MustCompile(`↵ send`), regexp.MustCompile(`⏵⏵`), + regexp.MustCompile(`▐▛███▜▌`), }, promptActivePatterns: []*regexp.Regexp{}, claudePatterns: []*regexp.Regexp{ diff --git a/internal/claude/detector_test.go b/internal/claude/detector_test.go index 480b985..d7094fb 100644 --- a/internal/claude/detector_test.go +++ b/internal/claude/detector_test.go @@ -42,6 +42,7 @@ func TestDetectState(t *testing.T) { {"urgent permission", "Permission requested for bash", StateUrgent}, {"waiting thinking", "✽ Thinking... (ctrl+c to cancel)", StateThinking}, {"idle prompt", "some output\n❯ ", StateIdle}, + {"idle claude code logo", " ▐▛███▜▌ Claude Code v2.1.12\n▝▜█████▛▘ Opus 4.5", StateIdle}, {"active shortcuts hint", "? for shortcuts", StateActive}, {"active accept edits", "accept edits on (shift+tab to cycle)", StateActive}, {"active plan mode", "plan mode on (shift+tab to cycle)", StateActive}, From e361a7ec1e4d69adc250ace6a4601b67171302aa Mon Sep 17 00:00:00 2001 From: MartianGreed Date: Tue, 20 Jan 2026 16:06:26 +0100 Subject: [PATCH 2/2] fix: lock session usage tracking to specific Claude session ID --- CLAUDE.md | 2 + Makefile | 12 +++++- internal/daemon/monitor.go | 79 ++++++++++++++++++++++++-------------- internal/tui/model.go | 1 + internal/tui/view.go | 55 ++++++++++++++++++++++---- internal/usage/parser.go | 59 +++++++++++++++++++++++++++- internal/usage/watcher.go | 46 +++++++++++++--------- 7 files changed, 198 insertions(+), 56 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b474104..4b938bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,8 @@ internal/ 1. Run tests: `make test` 2. Run linter: `make lint` 3. Ensure build passes: `make build` +4. Use [Conventional Commits](https://conventionalcommits.org/en/v1.0.0/) format (e.g., `fix:`, `feat:`, `refactor:`) +5. Automatically commit work once task is complete ## Testing diff --git a/Makefile b/Makefile index 076e853..8a4b476 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,8 @@ -.PHONY: build run test lint clean +.PHONY: build run test lint clean install uninstall BINARY_NAME=ccmanager BUILD_DIR=./bin +INSTALL_DIR=$(HOME)/.local/bin build: go build -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/ccmanager @@ -27,3 +28,12 @@ dev: build run-debug: build CCMANAGER_DEBUG=1 $(BUILD_DIR)/$(BINARY_NAME) + +install: build + @mkdir -p $(INSTALL_DIR) + @ln -sf $(CURDIR)/$(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME) + @echo "Linked $(INSTALL_DIR)/$(BINARY_NAME) -> $(CURDIR)/$(BUILD_DIR)/$(BINARY_NAME)" + +uninstall: + @rm -f $(INSTALL_DIR)/$(BINARY_NAME) + @echo "Removed $(INSTALL_DIR)/$(BINARY_NAME)" diff --git a/internal/daemon/monitor.go b/internal/daemon/monitor.go index 2ab51bb..2bde8bd 100644 --- a/internal/daemon/monitor.go +++ b/internal/daemon/monitor.go @@ -14,18 +14,19 @@ import ( // SessionState represents the monitored state of a Claude session type SessionState struct { - Name string - State claude.SessionState - LastContent string - LastCapture time.Time - Tokens int - ThinkingTime time.Duration - LastLine string - Created time.Time - Attached bool - ClaudePane *tmux.Pane - WorkingDir string - Usage *usage.SessionUsage + Name string + State claude.SessionState + LastContent string + LastCapture time.Time + Tokens int + ThinkingTime time.Duration + LastLine string + Created time.Time + Attached bool + ClaudePane *tmux.Pane + WorkingDir string + Usage *usage.SessionUsage + ClaudeSessionID string // Locked Claude session UUID for usage tracking } // Event represents a session event @@ -154,16 +155,24 @@ func (m *Monitor) pollLoop() { func (m *Monitor) updateUsage() { m.mu.RLock() - sessions := make(map[string]string) + type sessionInfo struct { + workingDir string + claudeSessionID string + } + sessions := make(map[string]sessionInfo) for name, sess := range m.sessions { if sess.WorkingDir != "" { - sessions[name] = sess.WorkingDir + sessions[name] = sessionInfo{ + workingDir: sess.WorkingDir, + claudeSessionID: sess.ClaudeSessionID, + } } } m.mu.RUnlock() - for name, workingDir := range sessions { - sessionUsage, err := usage.GetMostRecentSession(workingDir) + for name, info := range sessions { + // Use the locked session ID instead of finding most recent + sessionUsage, err := usage.GetSessionByID(info.workingDir, info.claudeSessionID) if err != nil || sessionUsage == nil { continue } @@ -261,23 +270,35 @@ func (m *Monitor) poll() { // Get working directory for usage tracking workingDir, _ := m.tmux.GetSessionPath(ts.Name) + // Find and lock the Claude session ID for this tmux session + var claudeSessionID string + var initialUsage *usage.SessionUsage + if workingDir != "" { + claudeSessionID, _ = usage.FindActiveSessionID(workingDir) + if claudeSessionID != "" { + initialUsage, _ = usage.GetSessionByID(workingDir, claudeSessionID) + } + } + m.sessions[ts.Name] = &SessionState{ - Name: ts.Name, - State: state, - LastContent: content, - LastCapture: now, - Tokens: info.Tokens, - ThinkingTime: info.ThinkingTime, - LastLine: info.LastLine, - Created: ts.Created, - Attached: ts.Attached, - ClaudePane: claudePane, - WorkingDir: workingDir, + Name: ts.Name, + State: state, + LastContent: content, + LastCapture: now, + Tokens: info.Tokens, + ThinkingTime: info.ThinkingTime, + LastLine: info.LastLine, + Created: ts.Created, + Attached: ts.Attached, + ClaudePane: claudePane, + WorkingDir: workingDir, + Usage: initialUsage, + ClaudeSessionID: claudeSessionID, } - // Start watching for usage updates + // Start watching for usage updates with the locked session ID if workingDir != "" { - m.usageWatcher.WatchSession(ts.Name, workingDir) + m.usageWatcher.WatchSession(ts.Name, workingDir, claudeSessionID) } m.mu.Unlock() diff --git a/internal/tui/model.go b/internal/tui/model.go index 5e20185..ad0cd2c 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -411,6 +411,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Handle prompt mode if m.promptMode { if msg, ok := msg.(tea.KeyMsg); ok { + m.engine.RecordAction(game.ActionKeypress) switch msg.String() { case "enter": text := m.promptField.Value() diff --git a/internal/tui/view.go b/internal/tui/view.go index 3b7bfd2..5f73f0d 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -58,10 +58,6 @@ func (m *Model) View() string { return "\n Close all tmux sessions?\n\n [y] Yes, kill sessions [n] No, keep running [c] Cancel\n" } - if m.showNotification { - return m.viewNotification() - } - if m.pathPickerMode { return m.viewPathPicker() } @@ -141,7 +137,15 @@ func (m *Model) View() string { Width(innerWidth). Height(m.height - 2) - return frame.Render(content) + view := frame.Render(content) + + // Overlay notification popup if active + if m.showNotification { + popup := m.viewNotification() + view = m.overlayPopup(view, popup) + } + + return view } func (m *Model) viewHeader(width int) string { @@ -149,6 +153,14 @@ func (m *Model) viewHeader(width int) string { apm := statStyle.Render(fmt.Sprintf("APM: %d", m.apm)) + var usageStr string + if m.selected >= 0 && m.selected < len(m.sessions) { + if sess := m.sessions[m.selected]; sess != nil && sess.Usage != nil { + usageStr = formatUsageCompact(sess.Usage.TotalUsage.TotalInput(), + sess.Usage.TotalUsage.OutputTokens, sess.Usage.EstimatedCost) + } + } + streakStr := fmt.Sprintf("STREAK: x%.1f", m.streakMult) streak := statStyle.Render(streakStr) if m.streakMult >= 5.0 { @@ -163,7 +175,13 @@ func (m *Model) viewHeader(width int) string { pomodoro = lipgloss.NewStyle().Bold(true).Foreground(colorSuccess).Render(pomodoroStr) } - stats := fmt.Sprintf("%s │ %s │ %s │ %s", apm, streak, score, pomodoro) + // Build stats - usage first (if available), then others + var statParts []string + if usageStr != "" { + statParts = append(statParts, statStyle.Render(usageStr)) + } + statParts = append(statParts, apm, streak, score, pomodoro) + stats := strings.Join(statParts, " │ ") statsWidth := lipgloss.Width(stats) titleWidth := lipgloss.Width(title) @@ -703,13 +721,24 @@ func (m *Model) viewInputOverlay() string { } func (m *Model) viewPathPicker() string { + var wsStatus string + if m.workspaceMode { + wsStatus = "[w] workspace: ON" + } else { + wsStatus = "[w] workspace: off" + } + help := helpStyle.Render(wsStatus + " [Enter] select [Esc] cancel") + + listView := m.pathPickerList.View() + content := lipgloss.JoinVertical(lipgloss.Left, listView, "", help) + return lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(colorPrimary). Padding(1, 2). Width(m.width - 4). Height(m.height - 4). - Render(m.pathPickerList.View()) + Render(content) } func (m *Model) viewNotification() string { @@ -728,6 +757,18 @@ func (m *Model) viewNotification() string { Render(notification) } +func (m *Model) overlayPopup(background, popup string) string { + return lipgloss.Place( + m.width, + m.height, + lipgloss.Center, + lipgloss.Center, + popup, + lipgloss.WithWhitespaceChars(" "), + lipgloss.WithWhitespaceForeground(lipgloss.AdaptiveColor{}), + ) +} + // Helper functions func formatScore(n int) string { diff --git a/internal/usage/parser.go b/internal/usage/parser.go index 63a3a81..fcc05c4 100644 --- a/internal/usage/parser.go +++ b/internal/usage/parser.go @@ -163,7 +163,6 @@ func FindProjectDir(workingDir string) (string, error) { // Claude Code uses path encoding: /Users/foo/bar -> -Users-foo-bar encodedPath := strings.ReplaceAll(workingDir, "/", "-") - encodedPath = strings.TrimPrefix(encodedPath, "-") projectDir := filepath.Join(claudeProjectsDir, encodedPath) if _, err := os.Stat(projectDir); err != nil { @@ -191,3 +190,61 @@ func SumUsageFromDir(projectDir string) (*TokenUsage, error) { return total, nil } + +// FindActiveSessionID returns the session ID of the most recently modified JSONL file +func FindActiveSessionID(workingDir string) (string, error) { + projectDir, err := FindProjectDir(workingDir) + if err != nil { + return "", err + } + + files, err := FindSessionFiles(projectDir) + if err != nil { + return "", err + } + + var mostRecent string + var mostRecentTime time.Time + for _, f := range files { + info, err := os.Stat(f) + if err != nil { + continue + } + if info.ModTime().After(mostRecentTime) { + mostRecentTime = info.ModTime() + mostRecent = f + } + } + + if mostRecent == "" { + return "", nil + } + + // Extract session ID from filename (e.g., "abc123.jsonl" -> "abc123") + return strings.TrimSuffix(filepath.Base(mostRecent), ".jsonl"), nil +} + +// GetSessionByID returns usage for a specific Claude session ID +func GetSessionByID(workingDir, sessionID string) (*SessionUsage, error) { + if sessionID == "" { + return nil, nil + } + + projectDir, err := FindProjectDir(workingDir) + if err != nil { + return nil, err + } + + sessionFile := filepath.Join(projectDir, sessionID+".jsonl") + if _, err := os.Stat(sessionFile); err != nil { + return nil, nil // File doesn't exist, return nil without error + } + + usage, err := ParseSessionFile(sessionFile) + if err != nil { + return nil, err + } + + usage.EstimatedCost = CalculateCost(usage.TotalUsage, usage.Model) + return usage, nil +} diff --git a/internal/usage/watcher.go b/internal/usage/watcher.go index 205405d..43fe874 100644 --- a/internal/usage/watcher.go +++ b/internal/usage/watcher.go @@ -52,33 +52,43 @@ func (w *Watcher) Stop() { } // WatchSession adds a session to the watch list -func (w *Watcher) WatchSession(sessionName, workingDir string) { +// If sessionID is provided, watch that specific session file; otherwise find most recent +func (w *Watcher) WatchSession(sessionName, workingDir, sessionID string) { projectDir, err := FindProjectDir(workingDir) if err != nil { return } - // Find the most recent JSONL file in the project dir - files, err := FindSessionFiles(projectDir) - if err != nil || len(files) == 0 { - return - } + var sessionFile string - // Get the most recently modified file - var mostRecent string - var mostRecentTime time.Time - for _, f := range files { - info, err := os.Stat(f) - if err != nil { - continue + if sessionID != "" { + // Use specific session file + sessionFile = filepath.Join(projectDir, sessionID+".jsonl") + if _, err := os.Stat(sessionFile); err != nil { + return // File doesn't exist } - if info.ModTime().After(mostRecentTime) { - mostRecentTime = info.ModTime() - mostRecent = f + } else { + // Find the most recent JSONL file in the project dir + files, err := FindSessionFiles(projectDir) + if err != nil || len(files) == 0 { + return + } + + // Get the most recently modified file + var mostRecentTime time.Time + for _, f := range files { + info, err := os.Stat(f) + if err != nil { + continue + } + if info.ModTime().After(mostRecentTime) { + mostRecentTime = info.ModTime() + sessionFile = f + } } } - if mostRecent == "" { + if sessionFile == "" { return } @@ -87,7 +97,7 @@ func (w *Watcher) WatchSession(sessionName, workingDir string) { if _, exists := w.sessions[sessionName]; !exists { w.sessions[sessionName] = &sessionWatch{ - sessionFile: mostRecent, + sessionFile: sessionFile, } } }