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: 1 addition & 1 deletion internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ func New(cfg Config, fileCfg *config.Config) (*App, error) {
}

// Initialize monitor
monitor := daemon.NewMonitor(cfg.PollInterval)
monitor := daemon.NewMonitor(cfg.PollInterval, st)

// Initialize game engine
engine := game.NewEngine(cfg.GameConfig)
Expand Down
9 changes: 7 additions & 2 deletions internal/claude/detector.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,14 +71,19 @@ func NewDetector() *Detector {
regexp.MustCompile(`(?i)skip interview and plan`),
},
thinkingPatterns: []*regexp.Regexp{
regexp.MustCompile(`ing\.\.\. \(ctrl`),
regexp.MustCompile(`(?i)thinking\.{3}`),
regexp.MustCompile(`(?i)thinking…`),
regexp.MustCompile(`(?i)reasoning`),
regexp.MustCompile(`⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧|⠇|⠏`),
regexp.MustCompile(`Working\.\.\.`),
regexp.MustCompile(`Processing\.\.\.`),
regexp.MustCompile(`\(esc to cancel\)`),
regexp.MustCompile(`\(ctrl.* to interrupt\)`),
regexp.MustCompile(`thought for \d+`),
regexp.MustCompile(`thinking`),
Comment on lines +74 to +83

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: Overly broad pattern: the regexp.MustCompile(\thinking`)` pattern on line 83 will match any text containing "thinking" (e.g., "I'm thinking about the problem" in user input). This is too broad and will cause false positives. The case-insensitive patterns on lines 74-76 already cover thinking states properly.

Suggested change
regexp.MustCompile(`(?i)thinking\.{3}`),
regexp.MustCompile(`(?i)thinking…`),
regexp.MustCompile(`(?i)reasoning`),
regexp.MustCompile(`⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧|⠇|⠏`),
regexp.MustCompile(`Working\.\.\.`),
regexp.MustCompile(`Processing\.\.\.`),
regexp.MustCompile(`\(esc to cancel\)`),
regexp.MustCompile(`\(ctrl.* to interrupt\)`),
regexp.MustCompile(`thought for \d+`),
regexp.MustCompile(`thinking`),
thinkingPatterns: []*regexp.Regexp{
regexp.MustCompile(`(?i)thinking\.{3}`),
regexp.MustCompile(`(?i)thinking…`),
regexp.MustCompile(`(?i)reasoning`),
regexp.MustCompile(`⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧|⠇|⠏`),
regexp.MustCompile(`Working\.\.\.`),
regexp.MustCompile(`Processing\.\.\.`),
regexp.MustCompile(`\(esc to cancel\)`),
regexp.MustCompile(`\(ctrl.* to interrupt\)`),
regexp.MustCompile(`thought for \d+`),
},

Was the standalone "thinking" pattern intentional for matching lowercase thinking states without punctuation?

Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/claude/detector.go
Line: 74:83

Comment:
**logic:** Overly broad pattern: the `regexp.MustCompile(\`thinking\`)` pattern on line 83 will match any text containing "thinking" (e.g., "I'm thinking about the problem" in user input). This is too broad and will cause false positives. The case-insensitive patterns on lines 74-76 already cover thinking states properly.

```suggestion
		thinkingPatterns: []*regexp.Regexp{
			regexp.MustCompile(`(?i)thinking\.{3}`),
			regexp.MustCompile(`(?i)thinking…`),
			regexp.MustCompile(`(?i)reasoning`),
			regexp.MustCompile(`⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧|⠇|⠏`),
			regexp.MustCompile(`Working\.\.\.`),
			regexp.MustCompile(`Processing\.\.\.`),
			regexp.MustCompile(`\(esc to cancel\)`),
			regexp.MustCompile(`\(ctrl.* to interrupt\)`),
			regexp.MustCompile(`thought for \d+`),
		},
```

 Was the standalone "thinking" pattern intentional for matching lowercase thinking states without punctuation?

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

},
idlePatterns: []*regexp.Regexp{
regexp.MustCompile(`(?m)❯\s*$`),
regexp.MustCompile(`(?m)>\s*$`),
regexp.MustCompile(`(?m)claude>\s*$`),
regexp.MustCompile(`↵ send`),
regexp.MustCompile(`⏵⏵`),
Expand Down
7 changes: 7 additions & 0 deletions internal/claude/detector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ func TestDetectState(t *testing.T) {
{"urgent yn", "Allow execution? [Y/n]", StateUrgent},
{"urgent permission", "Permission requested for bash", StateUrgent},
{"waiting thinking", "✽ Thinking... (ctrl+c to cancel)", StateThinking},
{"thinking lowercase", "thinking...", StateThinking},
{"thinking unicode ellipsis", "Thinking…", StateThinking},
{"reasoning variant", "Reasoning about the problem", StateThinking},
{"esc to cancel", "Working (esc to cancel)", StateThinking},
{"ctrl to interrupt", "Processing (ctrl+c to interrupt)", StateThinking},
{"spinner char", "⠋ Loading", StateThinking},
{"thought for time", "(ctrl+c to interrupt · thought for 5s)", 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},
Expand Down
20 changes: 16 additions & 4 deletions internal/daemon/monitor.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"time"

"github.com/valentindosimont/ccmanager/internal/claude"
"github.com/valentindosimont/ccmanager/internal/store"
"github.com/valentindosimont/ccmanager/internal/tmux"
"github.com/valentindosimont/ccmanager/internal/usage"
)
Expand Down Expand Up @@ -54,6 +55,7 @@ const (
type Monitor struct {
tmux *tmux.Client
detector *claude.Detector
store *store.Store

mu sync.RWMutex
sessions map[string]*SessionState
Expand All @@ -67,10 +69,11 @@ type Monitor struct {
}

// NewMonitor creates a new session monitor
func NewMonitor(pollInterval time.Duration) *Monitor {
func NewMonitor(pollInterval time.Duration, st *store.Store) *Monitor {
return &Monitor{
tmux: tmux.NewClient(),
detector: claude.NewDetector(),
store: st,
sessions: make(map[string]*SessionState),
pollInterval: pollInterval,
stopCh: make(chan struct{}),
Expand Down Expand Up @@ -271,14 +274,23 @@ func (m *Monitor) poll() {
workingDir, _ := m.tmux.GetSessionPath(ts.Name)

// Find and lock the Claude session ID for this tmux session
// Try to load persisted ID first, so usage survives restarts
var claudeSessionID string
var initialUsage *usage.SessionUsage
if workingDir != "" {
if m.store != nil {
claudeSessionID, _ = m.store.GetClaudeSessionID(ts.Name)
}
if claudeSessionID == "" && workingDir != "" {
claudeSessionID, _ = usage.FindActiveSessionID(workingDir)
if claudeSessionID != "" {
initialUsage, _ = usage.GetSessionByID(workingDir, claudeSessionID)
// Persist it for next restart
if m.store != nil && claudeSessionID != "" {
_ = m.store.CreateSession(ts.Name)
_ = m.store.SetClaudeSessionID(ts.Name, claudeSessionID)
}
}
if claudeSessionID != "" && workingDir != "" {
initialUsage, _ = usage.GetSessionByID(workingDir, claudeSessionID)
}

m.sessions[ts.Name] = &SessionState{
Name: ts.Name,
Expand Down
1 change: 1 addition & 0 deletions internal/store/migrations/004_claude_session_id.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE sessions ADD COLUMN claude_session_id TEXT;
30 changes: 30 additions & 0 deletions internal/store/sqlite.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,12 @@ func (s *Store) migrate() error {

_, _ = s.db.Exec(string(schema3))

schema4, err := migrationsFS.ReadFile("migrations/004_claude_session_id.sql")
if err != nil {
return fmt.Errorf("read migration 004: %w", err)
}
_, _ = s.db.Exec(string(schema4))

return nil
}

Expand Down Expand Up @@ -494,3 +500,27 @@ func (s *Store) DeleteSessionWorkspace(sessionName string) error {

return nil
}

func (s *Store) SetClaudeSessionID(sessionName, claudeSessionID string) error {
_, err := s.db.Exec(`
UPDATE sessions SET claude_session_id = ? WHERE name = ?
`, claudeSessionID, sessionName)
if err != nil {
return fmt.Errorf("set claude session id: %w", err)
}
return nil
}

func (s *Store) GetClaudeSessionID(sessionName string) (string, error) {
var id sql.NullString
err := s.db.QueryRow(`
SELECT claude_session_id FROM sessions WHERE name = ?
`, sessionName).Scan(&id)
if err == sql.ErrNoRows {
return "", nil
}
if err != nil {
return "", fmt.Errorf("get claude session id: %w", err)
}
return id.String, nil
}
18 changes: 16 additions & 2 deletions internal/tmux/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,16 @@ func (c *Client) KillSession(name string) error {

// SendKeys sends keys to a tmux session
func (c *Client) SendKeys(session, keys string) error {
cmd := exec.Command("tmux", "send-keys", "-t", session, keys, "Enter")
// Send double Enter for multi-line content (3+ lines) to signal paste completion
enterCount := 1
if strings.Count(keys, "\n") >= 2 {
enterCount = 2
}
args := []string{"send-keys", "-t", session, keys}
for i := 0; i < enterCount; i++ {
args = append(args, "Enter")
}
cmd := exec.Command("tmux", args...)
return cmd.Run()
}

Expand All @@ -173,7 +182,12 @@ func (c *Client) SendKeysToPane(session string, pane *Pane, keys string) error {
if err := cmd.Run(); err != nil {
return err
}
cmd = exec.Command("tmux", "send-keys", "-t", target, "Enter")
// Send double Enter for multi-line content (3+ lines) to signal paste completion
args := []string{"send-keys", "-t", target, "Enter"}
if strings.Count(keys, "\n") >= 2 {
args = append(args, "Enter")
}
cmd = exec.Command("tmux", args...)
return cmd.Run()
}

Expand Down
15 changes: 14 additions & 1 deletion internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/valentindosimont/ccmanager/internal/store"
"github.com/valentindosimont/ccmanager/internal/tmux"
"github.com/valentindosimont/ccmanager/internal/tui/messages"
"github.com/valentindosimont/ccmanager/internal/usage"
"github.com/valentindosimont/ccmanager/internal/workspace"
)

Expand Down Expand Up @@ -61,6 +62,10 @@ type Model struct {
// Activity overlay
showActivity bool

// Usage overlay
showUsage bool
globalUsage *usage.GlobalUsage

// Game state (cached for display)
apm int
streakMult float64
Expand Down Expand Up @@ -524,10 +529,11 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd {
}

// Handle overlays first
if m.showHelp || m.showStats || m.showActivity {
if m.showHelp || m.showStats || m.showActivity || m.showUsage {
m.showHelp = false
m.showStats = false
m.showActivity = false
m.showUsage = false
return nil
}

Expand Down Expand Up @@ -563,6 +569,13 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd {
case "s":
m.showStats = true

case "u":
m.showUsage = true
go func() {
global, _ := usage.GetGlobalUsage()
m.globalUsage = global
}()
Comment on lines +572 to +577

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: Race condition: the goroutine updates m.globalUsage without synchronization while the UI reads it in viewUsageOverlay() on line 736-741 of view.go. This can cause data races.

Suggested change
case "u":
m.showUsage = true
go func() {
global, _ := usage.GetGlobalUsage()
m.globalUsage = global
}()
case "u":
m.showUsage = true
return tea.Cmd(func() tea.Msg {
global, _ := usage.GetGlobalUsage()
return messages.UsageLoaded{Usage: global}
})
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/tui/model.go
Line: 572:577

Comment:
**logic:** Race condition: the goroutine updates `m.globalUsage` without synchronization while the UI reads it in `viewUsageOverlay()` on line 736-741 of view.go. This can cause data races.

```suggestion
	case "u":
		m.showUsage = true
		return tea.Cmd(func() tea.Msg {
			global, _ := usage.GetGlobalUsage()
			return messages.UsageLoaded{Usage: global}
		})
```

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


case "up", "k":
if len(m.sessions) > 0 {
session := m.sessions[m.selected]
Expand Down
71 changes: 65 additions & 6 deletions internal/tui/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ func (m *Model) View() string {
return m.viewActivityOverlay()
}

if m.showUsage {
return m.viewUsageOverlay()
}

// Calculate layout dimensions
innerWidth := m.width - 2 // account for outer border

Expand All @@ -102,7 +106,9 @@ func (m *Model) View() string {
if promptHeight < promptLines+2 {
promptHeight = promptLines + 2
}
mainHeight := m.height - headerHeight - groupsHeight - promptHeight - helpBarHeight - borderOverhead - 2
// Frame interior is m.height - 2; content must fit within it
// content = header(1) + groups(1) + helpBar(1) + 4 dividers + mainHeight + promptHeight
mainHeight := m.height - headerHeight - groupsHeight - promptHeight - helpBarHeight - borderOverhead - 4
if mainHeight < 5 {
mainHeight = 5
}
Expand Down Expand Up @@ -153,13 +159,16 @@ 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)
var totalInput, totalOutput int64
var totalCost float64
for _, sess := range m.sessions {
if sess != nil && sess.Usage != nil {
totalInput += sess.Usage.TotalUsage.TotalInput()
totalOutput += sess.Usage.TotalUsage.OutputTokens
totalCost += sess.Usage.EstimatedCost
}
}
usageStr := formatUsageCompact(totalInput, totalOutput, totalCost)

streakStr := fmt.Sprintf("STREAK: x%.1f", m.streakMult)
streak := statStyle.Render(streakStr)
Expand Down Expand Up @@ -665,6 +674,7 @@ GAME
p Start/pause pomodoro
P Stop pomodoro
s Show statistics
u Show usage summary

GENERAL
? Toggle help
Expand Down Expand Up @@ -698,6 +708,55 @@ Today
Render(stats)
}

func (m *Model) viewUsageOverlay() string {
var lines []string

title := titleStyle.Render("GLOBAL USAGE SUMMARY")
lines = append(lines, title)
lines = append(lines, "")

// Current sessions usage
var currentInput, currentOutput int64
var currentCost float64
for _, sess := range m.sessions {
if sess != nil && sess.Usage != nil {
currentInput += sess.Usage.TotalUsage.TotalInput()
currentOutput += sess.Usage.TotalUsage.OutputTokens
currentCost += sess.Usage.EstimatedCost
}
}

lines = append(lines, sectionHeaderStyle.Render("Active Sessions"))
lines = append(lines, fmt.Sprintf(" Sessions: %d", len(m.sessions)))
lines = append(lines, fmt.Sprintf(" Input: %s tokens", formatTokensLarge(currentInput)))
lines = append(lines, fmt.Sprintf(" Output: %s tokens", formatTokensLarge(currentOutput)))
lines = append(lines, fmt.Sprintf(" Est. Cost: $%.2f", currentCost))
lines = append(lines, "")

// Global historical usage
lines = append(lines, sectionHeaderStyle.Render("All-Time (Local JSONL)"))
if m.globalUsage != nil {
lines = append(lines, fmt.Sprintf(" Projects: %d", m.globalUsage.ProjectCount))
lines = append(lines, fmt.Sprintf(" Sessions: %d", m.globalUsage.SessionCount))
lines = append(lines, fmt.Sprintf(" Input: %s tokens", formatTokensLarge(m.globalUsage.TotalUsage.TotalInput())))
lines = append(lines, fmt.Sprintf(" Output: %s tokens", formatTokensLarge(m.globalUsage.TotalUsage.OutputTokens)))
lines = append(lines, fmt.Sprintf(" Est. Cost: $%.2f", m.globalUsage.EstimatedCost))
} else {
lines = append(lines, mutedStyle.Render(" Loading..."))
}

lines = append(lines, "")
lines = append(lines, helpStyle.Render("Press any key to close"))

content := strings.Join(lines, "\n")

return lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorPrimary).
Padding(1, 2).
Render(content)
}

func (m *Model) viewInputOverlay() string {
inputBox := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
Expand Down
52 changes: 52 additions & 0 deletions internal/usage/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,3 +248,55 @@ func GetSessionByID(workingDir, sessionID string) (*SessionUsage, error) {
usage.EstimatedCost = CalculateCost(usage.TotalUsage, usage.Model)
return usage, nil
}

// GlobalUsage represents aggregated usage across all projects
type GlobalUsage struct {
TotalUsage TokenUsage
EstimatedCost float64
SessionCount int
ProjectCount int
}

// GetGlobalUsage scans all Claude projects and returns total historical usage
func GetGlobalUsage() (*GlobalUsage, error) {
projectsDir, err := GetClaudeProjectsDir()
if err != nil {
return nil, err
}

entries, err := os.ReadDir(projectsDir)
if err != nil {
return nil, err
}

global := &GlobalUsage{}

for _, entry := range entries {
if !entry.IsDir() {
continue
}

projectPath := filepath.Join(projectsDir, entry.Name())
files, err := FindSessionFiles(projectPath)
if err != nil {
continue
}

if len(files) > 0 {
global.ProjectCount++
}

for _, file := range files {
usage, err := ParseSessionFile(file)
if err != nil {
continue
}

global.TotalUsage.Add(usage.TotalUsage)
global.EstimatedCost += CalculateCost(usage.TotalUsage, usage.Model)
global.SessionCount++
}
}

return global, nil
}