Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 11 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)"
1 change: 1 addition & 0 deletions internal/claude/detector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
1 change: 1 addition & 0 deletions internal/claude/detector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
79 changes: 50 additions & 29 deletions internal/daemon/monitor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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()

Expand Down
1 change: 1 addition & 0 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
55 changes: 48 additions & 7 deletions internal/tui/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down Expand Up @@ -141,14 +137,30 @@ 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 {
title := titleStyle.Render("CCMANAGER")

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 {
Expand All @@ -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)

Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
59 changes: 58 additions & 1 deletion internal/usage/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "/", "-")
Comment on lines 164 to 165

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Removing TrimPrefix breaks path encoding for root-level directories. For example, /Users/foo/bar now encodes to -Users-foo-bar instead of Users-foo-bar, which won't match Claude's actual directory structure.

Suggested change
// Claude Code uses path encoding: /Users/foo/bar -> -Users-foo-bar
encodedPath := strings.ReplaceAll(workingDir, "/", "-")
// Claude Code uses path encoding: /Users/foo/bar -> -Users-foo-bar
encodedPath := strings.ReplaceAll(workingDir, "/", "-")
encodedPath = strings.TrimPrefix(encodedPath, "-")
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/usage/parser.go
Line: 164:165

Comment:
**logic:** Removing `TrimPrefix` breaks path encoding for root-level directories. For example, `/Users/foo/bar` now encodes to `-Users-foo-bar` instead of `Users-foo-bar`, which won't match Claude's actual directory structure.

```suggestion
	// Claude Code uses path encoding: /Users/foo/bar -> -Users-foo-bar
	encodedPath := strings.ReplaceAll(workingDir, "/", "-")
	encodedPath = strings.TrimPrefix(encodedPath, "-")
```

How can I resolve this? If you propose a fix, please make it concise.

encodedPath = strings.TrimPrefix(encodedPath, "-")

projectDir := filepath.Join(claudeProjectsDir, encodedPath)
if _, err := os.Stat(projectDir); err != nil {
Expand Down Expand Up @@ -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
}
Loading