diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9a970d3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,58 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.25.0" + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: latest + + format: + name: Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.25.0" + + - name: Check formatting + run: | + if [ -n "$(gofmt -l .)" ]; then + echo "The following files are not formatted:" + gofmt -l . + exit 1 + fi + + test: + name: Test + runs-on: ubuntu-latest + needs: [lint, format] + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.25.0" + + - name: Run tests + run: go test -v ./... diff --git a/.github/workflows/release-dispatch.yml b/.github/workflows/release-dispatch.yml new file mode 100644 index 0000000..0623def --- /dev/null +++ b/.github/workflows/release-dispatch.yml @@ -0,0 +1,35 @@ +name: Release Dispatch + +on: + workflow_dispatch: + inputs: + version: + description: "Semantic version (e.g., v1.0.0)" + required: true + type: string + +jobs: + dispatch: + name: Trigger Release + runs-on: ubuntu-latest + steps: + - name: Validate version format + run: | + if [[ ! "${{ inputs.version }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: Version must be in format v*.*.* (e.g., v1.0.0)" + exit 1 + fi + + - name: Trigger release workflow + uses: actions/github-script@v7 + with: + script: | + await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'release.yml', + ref: 'main', + inputs: { + version: '${{ inputs.version }}' + } + }) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6354544 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,132 @@ +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: "Semantic version (e.g., v1.0.0)" + required: true + type: string + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + build: + name: Build + runs-on: ubuntu-latest + strategy: + matrix: + include: + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + - goos: darwin + goarch: amd64 + - goos: darwin + goarch: arm64 + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.25.0" + + - name: Determine version + id: version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT + else + echo "version=${{ github.ref_name }}" >> $GITHUB_OUTPUT + fi + + - name: Build binary + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: 0 + run: | + BINARY_NAME="ccmanager-${{ matrix.goos }}-${{ matrix.goarch }}" + go build -ldflags="-s -w -X main.version=${{ steps.version.outputs.version }}" -o "$BINARY_NAME" . + + - name: Compress binary + run: | + BINARY_NAME="ccmanager-${{ matrix.goos }}-${{ matrix.goarch }}" + tar -czvf "${BINARY_NAME}.tar.gz" "$BINARY_NAME" + + - name: Generate checksum + run: | + BINARY_NAME="ccmanager-${{ matrix.goos }}-${{ matrix.goarch }}" + sha256sum "${BINARY_NAME}.tar.gz" > "${BINARY_NAME}.tar.gz.sha256" + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: ccmanager-${{ matrix.goos }}-${{ matrix.goarch }} + path: | + ccmanager-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz + ccmanager-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz.sha256 + + release: + name: Release + runs-on: ubuntu-latest + needs: build + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Determine version + id: version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT + else + echo "version=${{ github.ref_name }}" >> $GITHUB_OUTPUT + fi + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Collect release files + run: | + mkdir -p release + find artifacts -type f \( -name "*.tar.gz" -o -name "*.sha256" \) -exec mv {} release/ \; + + - name: Generate changelog + id: changelog + run: | + PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") + if [ -n "$PREVIOUS_TAG" ]; then + CHANGELOG=$(git log --pretty=format:"- %s" "$PREVIOUS_TAG"..HEAD) + else + CHANGELOG=$(git log --pretty=format:"- %s" HEAD~10..HEAD 2>/dev/null || git log --pretty=format:"- %s") + fi + echo "changelog<> $GITHUB_OUTPUT + echo "$CHANGELOG" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.version.outputs.version }} + name: Release ${{ steps.version.outputs.version }} + body: | + ## Changelog + ${{ steps.changelog.outputs.changelog }} + + ## Checksums + ``` + $(cat release/*.sha256) + ``` + files: release/* + draft: false + prerelease: false diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b474104 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,50 @@ +# CCManager - Claude Code Session Manager + +Go CLI application with Bubble Tea TUI for managing Claude Code sessions with gamification features. + +## Development Commands + +```bash +make build # Build binary to bin/ccmanager +make test # Run all tests +make lint # Run golangci-lint +make deps # Run go mod tidy +make dev # Build and run +make run-debug # Run with CCMANAGER_DEBUG=1 +``` + +## Code Style + +- Follow Go conventions and existing patterns in the codebase +- Use standard library where possible +- Keep functions focused and small +- Error handling: return errors, don't panic + +## Project Structure + +``` +cmd/ccmanager/ # Main entry point +internal/ + app/ # Application orchestration + claude/ # Claude Code process detection + config/ # Configuration management + daemon/ # Background monitoring + game/ # Gamification (streaks, pomodoro, control groups) + store/ # SQLite persistence + tmux/ # Tmux integration + tui/ # Bubble Tea UI components + usage/ # Usage tracking and parsing + workspace/ # Workspace/project detection (git, jj) +``` + +## Before Committing + +1. Run tests: `make test` +2. Run linter: `make lint` +3. Ensure build passes: `make build` + +## Testing + +- Tests are in `*_test.go` files alongside source +- Run specific package: `go test -v ./internal/game/...` +- Run with coverage: `go test -coverprofile=coverage.out ./...` diff --git a/cmd/ccmanager/main.go b/cmd/ccmanager/main.go index 7e34124..b313228 100644 --- a/cmd/ccmanager/main.go +++ b/cmd/ccmanager/main.go @@ -15,7 +15,7 @@ func main() { fmt.Fprintf(os.Stderr, "Error initializing: %v\n", err) os.Exit(1) } - defer application.Close() + defer func() { _ = application.Close() }() if err := application.Run(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) diff --git a/go.mod b/go.mod index 6dec3cd..f209e3a 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/valentindosimont/ccmanager -go 1.25.0 +go 1.24.0 require ( github.com/charmbracelet/bubbles v0.21.0 diff --git a/internal/app/app.go b/internal/app/app.go index e2c1f6f..fee30ed 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -147,7 +147,7 @@ func (a *App) saveState() { score, lastScoreDate, pomodoroState, pomodoroRemaining := a.engine.State() now := time.Now() - a.store.UpdateGameState(&store.GameState{ + _ = a.store.UpdateGameState(&store.GameState{ CurrentScore: score, LastScoreDate: lastScoreDate, LastActionAt: &now, @@ -157,6 +157,6 @@ func (a *App) saveState() { // Save control groups for groupNum, session := range a.engine.ControlGroups().All() { - a.store.SetControlGroup(groupNum, session) + _ = a.store.SetControlGroup(groupNum, session) } } diff --git a/internal/claude/detector.go b/internal/claude/detector.go index cbe1b82..d813e98 100644 --- a/internal/claude/detector.go +++ b/internal/claude/detector.go @@ -11,11 +11,11 @@ import ( type SessionState int const ( - StateUnknown SessionState = iota - StateIdle // Prompt visible, waiting for input - StateActive // User recently typed - StateThinking // Claude is thinking - StateUrgent // Needs immediate input + StateUnknown SessionState = iota + StateIdle // Prompt visible, waiting for input + StateActive // User recently typed + StateThinking // Claude is thinking + StateUrgent // Needs immediate input ) func (s SessionState) String() string { @@ -203,21 +203,3 @@ func (d *Detector) getLastNonEmptyLine(content string) string { } return "" } - -func (d *Detector) endsWithPrompt(content string) bool { - trimmed := strings.TrimSpace(content) - if len(trimmed) == 0 { - return false - } - checkLen := 20 - if len(trimmed) < checkLen { - checkLen = len(trimmed) - } - suffix := trimmed[len(trimmed)-checkLen:] - for _, pattern := range d.idlePatterns { - if pattern.MatchString(suffix) { - return true - } - } - return false -} diff --git a/internal/config/config.go b/internal/config/config.go index 5d02308..c217577 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,11 +9,11 @@ import ( ) type PomodoroConfig struct { - WorkMinutes int `yaml:"work_minutes"` - ShortBreakMinutes int `yaml:"short_break_minutes"` - LongBreakMinutes int `yaml:"long_break_minutes"` - SessionsBeforeLongBreak int `yaml:"sessions_before_long_break"` - Multiplier float64 `yaml:"multiplier"` + WorkMinutes int `yaml:"work_minutes"` + ShortBreakMinutes int `yaml:"short_break_minutes"` + LongBreakMinutes int `yaml:"long_break_minutes"` + SessionsBeforeLongBreak int `yaml:"sessions_before_long_break"` + Multiplier float64 `yaml:"multiplier"` } type StreakConfig struct { @@ -22,9 +22,9 @@ type StreakConfig struct { } type ScoringConfig struct { - PointsPerAction int `yaml:"points_per_action"` - PointsTaskComplete int `yaml:"points_task_complete"` - PointsUrgentHandled int `yaml:"points_urgent_handled"` + PointsPerAction int `yaml:"points_per_action"` + PointsTaskComplete int `yaml:"points_task_complete"` + PointsUrgentHandled int `yaml:"points_urgent_handled"` PointsPomodoroComplete int `yaml:"points_pomodoro_complete"` } @@ -42,10 +42,10 @@ type MonitorConfig struct { } type UIConfig struct { - DoubleTapThresholdMs int `yaml:"double_tap_threshold_ms"` - NewlineSequence string `yaml:"newline_sequence"` - SessionListWidthPct int `yaml:"session_list_width_pct"` - Editor string `yaml:"editor"` + DoubleTapThresholdMs int `yaml:"double_tap_threshold_ms"` + NewlineSequence string `yaml:"newline_sequence"` + SessionListWidthPct int `yaml:"session_list_width_pct"` + Editor string `yaml:"editor"` } type WorkspaceConfig struct { @@ -68,11 +68,11 @@ type Config struct { func Default() *Config { return &Config{ Pomodoro: PomodoroConfig{ - WorkMinutes: 25, - ShortBreakMinutes: 5, - LongBreakMinutes: 15, + WorkMinutes: 25, + ShortBreakMinutes: 5, + LongBreakMinutes: 15, SessionsBeforeLongBreak: 4, - Multiplier: 1.5, + Multiplier: 1.5, }, Streak: StreakConfig{ TimeoutSeconds: 30, @@ -95,10 +95,10 @@ func Default() *Config { PollIntervalMs: 500, }, UI: UIConfig{ - DoubleTapThresholdMs: 300, - NewlineSequence: "\\", - SessionListWidthPct: 35, - Editor: "nvim", + DoubleTapThresholdMs: 300, + NewlineSequence: "\\", + SessionListWidthPct: 35, + Editor: "nvim", }, Workspace: WorkspaceConfig{ Strategy: "git", diff --git a/internal/daemon/monitor.go b/internal/daemon/monitor.go index 49691b4..2ab51bb 100644 --- a/internal/daemon/monitor.go +++ b/internal/daemon/monitor.go @@ -9,6 +9,7 @@ import ( "github.com/valentindosimont/ccmanager/internal/claude" "github.com/valentindosimont/ccmanager/internal/tmux" + "github.com/valentindosimont/ccmanager/internal/usage" ) // SessionState represents the monitored state of a Claude session @@ -23,6 +24,8 @@ type SessionState struct { Created time.Time Attached bool ClaudePane *tmux.Pane + WorkingDir string + Usage *usage.SessionUsage } // Event represents a session event @@ -54,10 +57,12 @@ type Monitor struct { mu sync.RWMutex sessions map[string]*SessionState - pollInterval time.Duration - stopCh chan struct{} - eventCh chan Event - debug bool + pollInterval time.Duration + stopCh chan struct{} + eventCh chan Event + debug bool + usageWatcher *usage.Watcher + usagePollTick int } // NewMonitor creates a new session monitor @@ -70,6 +75,7 @@ func NewMonitor(pollInterval time.Duration) *Monitor { stopCh: make(chan struct{}), eventCh: make(chan Event, 100), debug: os.Getenv("CCMANAGER_DEBUG") == "1", + usageWatcher: usage.NewWatcher(5 * time.Second), } } @@ -91,12 +97,14 @@ func (m *Monitor) Events() <-chan Event { // Start starts the monitor polling loop func (m *Monitor) Start() { + m.usageWatcher.Start() go m.pollLoop() } // Stop stops the monitor func (m *Monitor) Stop() { close(m.stopCh) + m.usageWatcher.Stop() } // Sessions returns all currently known sessions @@ -134,7 +142,37 @@ func (m *Monitor) pollLoop() { return case <-ticker.C: m.poll() + // Update usage every 5 poll cycles (roughly every 2.5s with 500ms poll) + m.usagePollTick++ + if m.usagePollTick >= 5 { + m.usagePollTick = 0 + m.updateUsage() + } + } + } +} + +func (m *Monitor) updateUsage() { + m.mu.RLock() + sessions := make(map[string]string) + for name, sess := range m.sessions { + if sess.WorkingDir != "" { + sessions[name] = sess.WorkingDir + } + } + m.mu.RUnlock() + + for name, workingDir := range sessions { + sessionUsage, err := usage.GetMostRecentSession(workingDir) + if err != nil || sessionUsage == nil { + continue + } + + m.mu.Lock() + if sess, ok := m.sessions[name]; ok { + sess.Usage = sessionUsage } + m.mu.Unlock() } } @@ -220,6 +258,9 @@ func (m *Monitor) poll() { state := m.detector.DetectState(content, "", time.Time{}) info := m.detector.ParseInfo(content) + // Get working directory for usage tracking + workingDir, _ := m.tmux.GetSessionPath(ts.Name) + m.sessions[ts.Name] = &SessionState{ Name: ts.Name, State: state, @@ -231,6 +272,12 @@ func (m *Monitor) poll() { Created: ts.Created, Attached: ts.Attached, ClaudePane: claudePane, + WorkingDir: workingDir, + } + + // Start watching for usage updates + if workingDir != "" { + m.usageWatcher.WatchSession(ts.Name, workingDir) } m.mu.Unlock() @@ -289,6 +336,7 @@ func (m *Monitor) poll() { m.mu.Lock() for name := range m.sessions { if !seen[name] { + m.usageWatcher.UnwatchSession(name) delete(m.sessions, name) m.eventCh <- Event{ Type: EventSessionClosed, diff --git a/internal/game/engine.go b/internal/game/engine.go index 821dcc9..7d52d4a 100644 --- a/internal/game/engine.go +++ b/internal/game/engine.go @@ -6,21 +6,21 @@ import ( ) type EngineConfig struct { - APMWindowSeconds int - StreakTimeoutSeconds int - StreakMultiplierCap float64 - PomodoroWorkMinutes int - PomodoroShortBreakMinutes int - PomodoroLongBreakMinutes int - PomodorosBeforeLongBreak int - PomodoroMultiplier float64 - FocusBonusMinutes int - FocusBonusMultiplier float64 - PointsAction int - PointsTaskComplete int - PointsUrgentHandled int - PointsPomodoroComplete int - DoubleTapThresholdMs int + APMWindowSeconds int + StreakTimeoutSeconds int + StreakMultiplierCap float64 + PomodoroWorkMinutes int + PomodoroShortBreakMinutes int + PomodoroLongBreakMinutes int + PomodorosBeforeLongBreak int + PomodoroMultiplier float64 + FocusBonusMinutes int + FocusBonusMultiplier float64 + PointsAction int + PointsTaskComplete int + PointsUrgentHandled int + PointsPomodoroComplete int + DoubleTapThresholdMs int } func DefaultEngineConfig() EngineConfig { @@ -72,11 +72,6 @@ type Engine struct { // Focus tracking focusSession string focusStart time.Time - - // Callbacks - onScoreChange func(score int) - onStreakChange func(multiplier float64) - onPomodoroChange func(state PomodoroState, remaining time.Duration) } // NewEngine creates a new game engine @@ -143,17 +138,6 @@ func (e *Engine) RecordAction(actionType ActionType) int { return 0 } -func (e *Engine) getBasePoints(actionType ActionType) int { - switch actionType { - case ActionTaskComplete: - return e.config.PointsTaskComplete - case ActionUrgentHandled: - return e.config.PointsUrgentHandled - default: - return e.config.PointsAction - } -} - // SetFocusSession updates the focused session func (e *Engine) SetFocusSession(session string) { e.mu.Lock() diff --git a/internal/game/pomodoro.go b/internal/game/pomodoro.go index 3a41534..a385958 100644 --- a/internal/game/pomodoro.go +++ b/internal/game/pomodoro.go @@ -41,9 +41,9 @@ type PomodoroTimer struct { completed int // Pomodoros completed in current cycle // Config - workMinutes int - shortBreakMinutes int - longBreakMinutes int + workMinutes int + shortBreakMinutes int + longBreakMinutes int sessionsBeforeLongBreak int // Callbacks diff --git a/internal/game/pomodoro_test.go b/internal/game/pomodoro_test.go index fe4af60..f38d993 100644 --- a/internal/game/pomodoro_test.go +++ b/internal/game/pomodoro_test.go @@ -6,9 +6,9 @@ import ( ) const ( - testWorkMinutes = 25 - testShortBreakMinutes = 5 - testLongBreakMinutes = 15 + testWorkMinutes = 25 + testShortBreakMinutes = 5 + testLongBreakMinutes = 15 testSessionsBeforeLongBreak = 4 ) diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index 2690f5c..da90581 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -72,7 +72,7 @@ func New(dbPath string) (*Store, error) { store := &Store{db: db} if err := store.migrate(); err != nil { - db.Close() + _ = db.Close() return nil, fmt.Errorf("migrate: %w", err) } @@ -184,7 +184,7 @@ func (s *Store) GetControlGroups() (map[int]string, error) { if err != nil { return nil, fmt.Errorf("get control groups: %w", err) } - defer rows.Close() + defer func() { _ = rows.Close() }() groups := make(map[int]string) for rows.Next() { @@ -316,7 +316,7 @@ func (s *Store) GetRecentActivity(limit int) ([]ActivityEntry, error) { if err != nil { return nil, fmt.Errorf("get recent activity: %w", err) } - defer rows.Close() + defer func() { _ = rows.Close() }() var entries []ActivityEntry for rows.Next() { @@ -361,7 +361,7 @@ func (s *Store) GetAllSessions() ([]Session, error) { if err != nil { return nil, fmt.Errorf("get all sessions: %w", err) } - defer rows.Close() + defer func() { _ = rows.Close() }() var sessions []Session for rows.Next() { @@ -443,7 +443,7 @@ func (s *Store) GetRecentPaths(limit int) ([]string, error) { if err != nil { return nil, fmt.Errorf("get recent paths: %w", err) } - defer rows.Close() + defer func() { _ = rows.Close() }() var paths []string for rows.Next() { diff --git a/internal/tui/messages/messages.go b/internal/tui/messages/messages.go index 018d50f..ec3a270 100644 --- a/internal/tui/messages/messages.go +++ b/internal/tui/messages/messages.go @@ -25,12 +25,12 @@ type SessionEventMsg struct { // GameStateMsg contains updated game state type GameStateMsg struct { - APM int - StreakMult float64 - StreakCount int - Score int - PomodoroState game.PomodoroState - PomodoroRemain time.Duration + APM int + StreakMult float64 + StreakCount int + Score int + PomodoroState game.PomodoroState + PomodoroRemain time.Duration } // FocusSessionMsg requests focusing a session diff --git a/internal/tui/model.go b/internal/tui/model.go index 91a9109..5e20185 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -276,7 +276,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.String() { case "y": for _, s := range m.sessions { - m.tmux.KillSession(s.Name) + _ = m.tmux.KillSession(s.Name) } return m, tea.Quit case "n": @@ -353,7 +353,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.addActivity("", "Workspace creation failed: %v", err) } else { if m.store != nil { - m.store.SaveSessionWorkspace(name, wsPath, path) + _ = m.store.SaveSessionWorkspace(name, wsPath, path) } path = wsPath } @@ -376,17 +376,17 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.focused = name m.engine.SetFocusSession(name) - m.tmux.SwitchClient(name) + _ = m.tmux.SwitchClient(name) if groupNum := m.engine.ControlGroups().FirstFreeGroup(); groupNum > 0 { m.engine.ControlGroups().Assign(groupNum, name) if m.store != nil { - m.store.SetControlGroup(groupNum, name) + _ = m.store.SetControlGroup(groupNum, name) } } if m.store != nil { - m.store.AddRecentPath(path) + _ = m.store.AddRecentPath(path) } m.sessions = m.monitor.Sessions() @@ -426,7 +426,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if text != "" && m.selected < len(m.sessions) { session := m.sessions[m.selected] - m.tmux.SendKeysToPane(session.Name, session.ClaudePane, text) + _ = m.tmux.SendKeysToPane(session.Name, session.ClaudePane, text) m.addActivity(session.Name, "Sent: %s", text) m.addToPromptHistory(text) } @@ -485,7 +485,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "shift+tab": if m.selected < len(m.sessions) { session := m.sessions[m.selected] - m.tmux.SendKeysToPaneRaw(session.Name, session.ClaudePane, "BTab") + _ = m.tmux.SendKeysToPaneRaw(session.Name, session.ClaudePane, "BTab") m.addActivity(session.Name, "Sent Shift+Tab (cycle mode)") } return m, tea.Batch(cmds...) @@ -544,7 +544,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd { m.engine.ControlGroups().Assign(groupNum, session) m.engine.RecordAction(game.ActionGroupAssign) if m.store != nil { - m.store.SetControlGroup(groupNum, session) + _ = m.store.SetControlGroup(groupNum, session) } } } @@ -566,7 +566,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd { if len(m.sessions) > 0 { session := m.sessions[m.selected] if session.State == claude.StateUrgent { - m.tmux.SendKeysToPaneRaw(session.Name, session.ClaudePane, "Up") + _ = m.tmux.SendKeysToPaneRaw(session.Name, session.ClaudePane, "Up") return nil } m.selected = (m.selected - 1 + len(m.sessions)) % len(m.sessions) @@ -578,7 +578,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd { if len(m.sessions) > 0 { session := m.sessions[m.selected] if session.State == claude.StateUrgent { - m.tmux.SendKeysToPaneRaw(session.Name, session.ClaudePane, "Down") + _ = m.tmux.SendKeysToPaneRaw(session.Name, session.ClaudePane, "Down") return nil } m.selected = (m.selected + 1) % len(m.sessions) @@ -590,12 +590,12 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd { if m.selected < len(m.sessions) { session := m.sessions[m.selected] if session.State == claude.StateUrgent { - m.tmux.SendKeysToPaneRaw(session.Name, session.ClaudePane, "Enter") + _ = m.tmux.SendKeysToPaneRaw(session.Name, session.ClaudePane, "Enter") return nil } m.focused = session.Name m.engine.SetFocusSession(session.Name) - m.tmux.SwitchClient(session.Name) + _ = m.tmux.SwitchClient(session.Name) m.engine.RecordAction(game.ActionSwitch) } @@ -609,7 +609,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd { case "shift+tab": if m.selected < len(m.sessions) { session := m.sessions[m.selected] - m.tmux.SendKeysToPaneRaw(session.Name, session.ClaudePane, "BTab") + _ = m.tmux.SendKeysToPaneRaw(session.Name, session.ClaudePane, "BTab") m.addActivity(session.Name, "Sent Shift+Tab (cycle mode)") } @@ -631,7 +631,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd { if doubleTap { m.focused = session m.engine.SetFocusSession(session) - m.tmux.SwitchClient(session) + _ = m.tmux.SwitchClient(session) m.engine.RecordAction(game.ActionSwitch) } } @@ -659,14 +659,14 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd { case "x": if m.selected < len(m.sessions) { session := m.sessions[m.selected] - m.tmux.SendKeysToPaneRaw(session.Name, session.ClaudePane, "Escape") + _ = m.tmux.SendKeysToPaneRaw(session.Name, session.ClaudePane, "Escape") m.addActivity(session.Name, "Sent Escape (cancel)") } case "c": if m.selected < len(m.sessions) { session := m.sessions[m.selected] - m.tmux.SendKeysToPaneRaw(session.Name, session.ClaudePane, "C-c") + _ = m.tmux.SendKeysToPaneRaw(session.Name, session.ClaudePane, "C-c") m.addActivity(session.Name, "Sent Ctrl+C (interrupt)") } @@ -675,7 +675,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd { now := time.Now() if m.deletePressed && now.Sub(m.deleteTime) < 500*time.Millisecond { session := m.sessions[m.selected] - m.tmux.KillSession(session.Name) + _ = m.tmux.KillSession(session.Name) m.addActivity(session.Name, "Session deleted") m.sessions = m.monitor.Sessions() m.deletePressed = false @@ -722,8 +722,8 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd { if editor == "" { editor = "nvim" } - m.tmux.NewWindow(session.Name, "editor", path, editor+" .") - m.tmux.SwitchClient(session.Name) + _ = m.tmux.NewWindow(session.Name, "editor", path, editor+" .") + _ = m.tmux.SwitchClient(session.Name) m.addActivity(session.Name, "Opened %s", editor) } } @@ -732,11 +732,6 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd { return nil } -func (m *Model) generateSessionName() string { - dir, _ := os.Getwd() - return m.generateSessionNameFromPath(dir) -} - func (m *Model) generateSessionNameFromPath(dir string) string { base := filepath.Base(dir) for i := 1; ; i++ { @@ -853,7 +848,7 @@ func (m *Model) handleSessionEvent(event daemon.Event) { m.addActivity(event.Session, "Session discovered") m.sessions = m.monitor.Sessions() if m.store != nil { - m.store.CreateSession(event.Session) + _ = m.store.CreateSession(event.Session) } case daemon.EventSessionClosed: @@ -870,9 +865,9 @@ func (m *Model) handleSessionEvent(event daemon.Event) { if delErr := m.workspaceManager.DeleteWorkspace(sourceRepo, wsPath); delErr != nil { m.addActivity(event.Session, "Workspace cleanup failed: %v", delErr) } - m.store.DeleteSessionWorkspace(event.Session) + _ = m.store.DeleteSessionWorkspace(event.Session) } - m.store.DeleteSession(event.Session) + _ = m.store.DeleteSession(event.Session) } case daemon.EventStateChanged: @@ -884,7 +879,7 @@ func (m *Model) handleSessionEvent(event daemon.Event) { m.interactiveMode = false } if m.store != nil { - m.store.UpdateSessionLastSeen(event.Session) + _ = m.store.UpdateSessionLastSeen(event.Session) } case daemon.EventTaskCompleted: @@ -899,7 +894,7 @@ func (m *Model) handleSessionEvent(event daemon.Event) { m.selectByName(event.Session) } if m.store != nil { - m.store.UpdateSessionLastSeen(event.Session) + _ = m.store.UpdateSessionLastSeen(event.Session) } case daemon.EventDebug: @@ -925,7 +920,7 @@ func (m *Model) validateControlGroups() { if !sessionNames[sessionName] { m.engine.ControlGroups().Remove(groupNum, sessionName) if m.store != nil { - m.store.RemoveFromControlGroup(groupNum, sessionName) + _ = m.store.RemoveFromControlGroup(groupNum, sessionName) } } } @@ -1072,19 +1067,19 @@ func (m *Model) handleInteractiveKey(msg tea.KeyMsg) tea.Cmd { m.focused = "" return nil case "up", "k": - m.tmux.SendKeysToPaneRaw(focusedSession.Name, focusedSession.ClaudePane, "Up") + _ = m.tmux.SendKeysToPaneRaw(focusedSession.Name, focusedSession.ClaudePane, "Up") return nil case "down", "j": - m.tmux.SendKeysToPaneRaw(focusedSession.Name, focusedSession.ClaudePane, "Down") + _ = m.tmux.SendKeysToPaneRaw(focusedSession.Name, focusedSession.ClaudePane, "Down") return nil case "enter": - m.tmux.SendKeysToPaneRaw(focusedSession.Name, focusedSession.ClaudePane, "Enter") + _ = m.tmux.SendKeysToPaneRaw(focusedSession.Name, focusedSession.ClaudePane, "Enter") return nil case "y": - m.tmux.SendKeysToPaneRaw(focusedSession.Name, focusedSession.ClaudePane, "y") + _ = m.tmux.SendKeysToPaneRaw(focusedSession.Name, focusedSession.ClaudePane, "y") return nil case "n": - m.tmux.SendKeysToPaneRaw(focusedSession.Name, focusedSession.ClaudePane, "n") + _ = m.tmux.SendKeysToPaneRaw(focusedSession.Name, focusedSession.ClaudePane, "n") return nil case "i": m.promptMode = true diff --git a/internal/tui/path_item.go b/internal/tui/path_item.go index 215895f..3a7d016 100644 --- a/internal/tui/path_item.go +++ b/internal/tui/path_item.go @@ -72,7 +72,7 @@ func (d pathDelegate) Render(w io.Writer, m list.Model, index int, listItem list sourceTag = d.styles.dimmed.Render(" [cwd]") } - fmt.Fprintf(w, "%s%s\n%s\n", title, sourceTag, desc) + _, _ = fmt.Fprintf(w, "%s%s\n%s\n", title, sourceTag, desc) } func truncatePath(path string, maxLen int) string { diff --git a/internal/tui/view.go b/internal/tui/view.go index 7342ebf..3b7bfd2 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -18,15 +18,10 @@ var ( colorUrgent = lipgloss.Color("#FF4444") colorSuccess = lipgloss.Color("#44FF44") colorMuted = lipgloss.Color("#666666") - colorBg = lipgloss.Color("#1A1A2E") ) // Styles var ( - headerStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(colorPrimary) - titleStyle = lipgloss.NewStyle(). Bold(true). Foreground(colorSecondary) @@ -34,10 +29,6 @@ var ( statStyle = lipgloss.NewStyle(). Foreground(colorPrimary) - borderStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(colorPrimary) - selectedStyle = lipgloss.NewStyle(). Bold(true). Foreground(colorSuccess) @@ -338,13 +329,19 @@ func (m *Model) viewSessionList(width, height int) string { elapsed := time.Since(sess.Created) elapsedStr := formatDuration(elapsed) + // Format cost if available + costStr := "" + if sess.Usage != nil && sess.Usage.EstimatedCost > 0 { + costStr = fmt.Sprintf("$%.2f", sess.Usage.EstimatedCost) + } + // Calculate available width for session name - nameWidth := width - 25 // cursor(2) + group(4) + icon(2) + state(8) + elapsed(6) + padding + nameWidth := width - 32 // cursor(2) + group(4) + icon(2) + state(8) + elapsed(6) + cost(7) + padding if nameWidth < 8 { nameWidth = 8 } - line := fmt.Sprintf("%s%-*s %s %s%-8s %5s", + line := fmt.Sprintf("%s%-*s %s %s%-8s %5s %6s", cursor, nameWidth, truncate(sess.Name, nameWidth), @@ -352,6 +349,7 @@ func (m *Model) viewSessionList(width, height int) string { stateIcon, stateStr, elapsedStr, + costStr, ) if i == m.selected { @@ -423,6 +421,12 @@ func (m *Model) viewPreview(width, height int) string { if sess.Tokens > 0 { statusLine += fmt.Sprintf(" · ↓ %s tokens", formatTokens(sess.Tokens)) } + + // Add usage info if available + if sess.Usage != nil { + usageStr := formatUsageCompact(sess.Usage.TotalUsage.TotalInput(), sess.Usage.TotalUsage.OutputTokens, sess.Usage.EstimatedCost) + statusLine += " · " + usageStr + } lines = append(lines, statStyle.Render(statusLine)) // Get content: use direct capture for selected (with cache fallback), cache for others @@ -490,47 +494,6 @@ func (m *Model) viewPreview(width, height int) string { return strings.Join(lines[:height], "\n") } -func (m *Model) viewActivityLog(width, height int) string { - var lines []string - - // Header - header := sectionHeaderStyle.Render(" ACTIVITY") - lines = append(lines, header) - - // Calculate how many entries we can show - maxEntries := height - 1 - if maxEntries < 1 { - maxEntries = 1 - } - - for i := 0; i < maxEntries && i < len(m.activityLog); i++ { - entry := m.activityLog[i] - timeStr := entry.Time.Format("15:04:05") - sessionStr := "" - if entry.Session != "" { - sessionStr = fmt.Sprintf("[%-8s] ", truncate(entry.Session, 8)) - } else { - sessionStr = strings.Repeat(" ", 11) - } - - msg := entry.Message - maxMsgLen := width - 22 // time(8) + space(1) + session(11) + padding(2) - if maxMsgLen > 0 && len(msg) > maxMsgLen { - msg = msg[:maxMsgLen-1] + "…" - } - - line := fmt.Sprintf(" %s %s%s", timeStr, sessionStr, msg) - lines = append(lines, mutedStyle.Render(line)) - } - - // Pad to fill height - for len(lines) < height { - lines = append(lines, "") - } - - return strings.Join(lines[:height], "\n") -} - func (m *Model) viewPromptPanel(width, height int) string { var lines []string @@ -801,45 +764,18 @@ func truncate(s string, maxLen int) string { return s[:maxLen-1] + "…" } -func wrapText(s string, width int) []string { - if width <= 0 { - return []string{s} - } - var lines []string - for len(s) > width { - lines = append(lines, s[:width]) - s = s[width:] - } - if len(s) > 0 { - lines = append(lines, s) - } - return lines +func formatUsageCompact(inputTokens, outputTokens int64, cost float64) string { + inStr := formatTokensLarge(inputTokens) + outStr := formatTokensLarge(outputTokens) + return fmt.Sprintf("↓%s ↑%s $%.2f", inStr, outStr, cost) } -func min(a, b int) int { - if a < b { - return a +func formatTokensLarge(n int64) string { + if n < 1000 { + return fmt.Sprintf("%d", n) } - return b -} - -func stripANSI(s string) string { - var result strings.Builder - i := 0 - for i < len(s) { - if s[i] == '\x1b' && i+1 < len(s) && s[i+1] == '[' { - // Skip ANSI escape sequence - i += 2 - for i < len(s) && !((s[i] >= 'A' && s[i] <= 'Z') || (s[i] >= 'a' && s[i] <= 'z')) { - i++ - } - if i < len(s) { - i++ // Skip the final letter - } - } else { - result.WriteByte(s[i]) - i++ - } + if n < 1_000_000 { + return fmt.Sprintf("%.1fk", float64(n)/1000) } - return result.String() + return fmt.Sprintf("%.1fM", float64(n)/1_000_000) } diff --git a/internal/usage/parser.go b/internal/usage/parser.go new file mode 100644 index 0000000..63a3a81 --- /dev/null +++ b/internal/usage/parser.go @@ -0,0 +1,193 @@ +package usage + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "strings" + "time" +) + +// jsonlMessage represents a message in the JSONL file +type jsonlMessage struct { + Type string `json:"type"` + Message struct { + Model string `json:"model"` + Usage struct { + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"` + CacheReadInputTokens int64 `json:"cache_read_input_tokens"` + } `json:"usage"` + } `json:"message"` +} + +// ParseSessionFile parses a Claude JSONL session file and returns usage data +func ParseSessionFile(path string) (*SessionUsage, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer func() { _ = file.Close() }() + + usage := &SessionUsage{ + SessionID: filepath.Base(strings.TrimSuffix(path, ".jsonl")), + ProjectPath: filepath.Dir(path), + LastUpdated: time.Now(), + } + + scanner := bufio.NewScanner(file) + buf := make([]byte, 0, 1024*1024) + scanner.Buffer(buf, 10*1024*1024) + + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + + var msg jsonlMessage + if err := json.Unmarshal(line, &msg); err != nil { + continue + } + + if msg.Type == "assistant" { + usage.TotalUsage.Add(TokenUsage{ + InputTokens: msg.Message.Usage.InputTokens, + OutputTokens: msg.Message.Usage.OutputTokens, + CacheCreationInputTokens: msg.Message.Usage.CacheCreationInputTokens, + CacheReadInputTokens: msg.Message.Usage.CacheReadInputTokens, + }) + if msg.Message.Model != "" { + usage.Model = msg.Message.Model + } + } + } + + if err := scanner.Err(); err != nil { + return usage, err + } + + return usage, nil +} + +// ParseSessionFileTail parses only the last N bytes of a session file for efficiency +func ParseSessionFileTail(path string, tailBytes int64) (*SessionUsage, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer func() { _ = file.Close() }() + + info, err := file.Stat() + if err != nil { + return nil, err + } + + // If file is small enough, parse the whole thing + if info.Size() <= tailBytes { + return ParseSessionFile(path) + } + + // Seek to near the end + _, err = file.Seek(-tailBytes, 2) + if err != nil { + return nil, err + } + + usage := &SessionUsage{ + SessionID: filepath.Base(strings.TrimSuffix(path, ".jsonl")), + ProjectPath: filepath.Dir(path), + LastUpdated: time.Now(), + } + + scanner := bufio.NewScanner(file) + buf := make([]byte, 0, 1024*1024) + scanner.Buffer(buf, 10*1024*1024) + + first := true + for scanner.Scan() { + line := scanner.Bytes() + // Skip partial first line when reading from middle + if first { + first = false + continue + } + if len(line) == 0 { + continue + } + + var msg jsonlMessage + if err := json.Unmarshal(line, &msg); err != nil { + continue + } + + if msg.Type == "assistant" { + usage.TotalUsage.Add(TokenUsage{ + InputTokens: msg.Message.Usage.InputTokens, + OutputTokens: msg.Message.Usage.OutputTokens, + CacheCreationInputTokens: msg.Message.Usage.CacheCreationInputTokens, + CacheReadInputTokens: msg.Message.Usage.CacheReadInputTokens, + }) + if msg.Message.Model != "" { + usage.Model = msg.Message.Model + } + } + } + + return usage, nil +} + +// FindSessionFiles finds all JSONL session files for a given project directory +func FindSessionFiles(projectDir string) ([]string, error) { + pattern := filepath.Join(projectDir, "*.jsonl") + return filepath.Glob(pattern) +} + +// GetClaudeProjectsDir returns the Claude projects directory path +func GetClaudeProjectsDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".claude", "projects"), nil +} + +// FindProjectDir finds the Claude project directory for a given working directory +func FindProjectDir(workingDir string) (string, error) { + claudeProjectsDir, err := GetClaudeProjectsDir() + if err != nil { + return "", err + } + + // 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 { + return "", err + } + + return projectDir, nil +} + +// SumUsageFromDir sums usage from all session files in a project directory +func SumUsageFromDir(projectDir string) (*TokenUsage, error) { + files, err := FindSessionFiles(projectDir) + if err != nil { + return nil, err + } + + total := &TokenUsage{} + for _, file := range files { + usage, err := ParseSessionFile(file) + if err != nil { + continue + } + total.Add(usage.TotalUsage) + } + + return total, nil +} diff --git a/internal/usage/parser_test.go b/internal/usage/parser_test.go new file mode 100644 index 0000000..a0a9c27 --- /dev/null +++ b/internal/usage/parser_test.go @@ -0,0 +1,164 @@ +package usage + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseSessionFile(t *testing.T) { + tmpDir := t.TempDir() + sessionFile := filepath.Join(tmpDir, "test-session.jsonl") + + content := `{"type":"user","message":"hello"} +{"type":"assistant","message":{"model":"claude-sonnet-4-20250514","usage":{"input_tokens":100,"output_tokens":50,"cache_creation_input_tokens":200,"cache_read_input_tokens":300}}} +{"type":"user","message":"world"} +{"type":"assistant","message":{"model":"claude-sonnet-4-20250514","usage":{"input_tokens":150,"output_tokens":75,"cache_creation_input_tokens":0,"cache_read_input_tokens":500}}} +` + if err := os.WriteFile(sessionFile, []byte(content), 0644); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + usage, err := ParseSessionFile(sessionFile) + if err != nil { + t.Fatalf("ParseSessionFile failed: %v", err) + } + + if usage.TotalUsage.InputTokens != 250 { + t.Errorf("InputTokens = %d, want 250", usage.TotalUsage.InputTokens) + } + if usage.TotalUsage.OutputTokens != 125 { + t.Errorf("OutputTokens = %d, want 125", usage.TotalUsage.OutputTokens) + } + if usage.TotalUsage.CacheCreationInputTokens != 200 { + t.Errorf("CacheCreationInputTokens = %d, want 200", usage.TotalUsage.CacheCreationInputTokens) + } + if usage.TotalUsage.CacheReadInputTokens != 800 { + t.Errorf("CacheReadInputTokens = %d, want 800", usage.TotalUsage.CacheReadInputTokens) + } + if usage.Model != "claude-sonnet-4-20250514" { + t.Errorf("Model = %s, want claude-sonnet-4-20250514", usage.Model) + } +} + +func TestCalculateCost(t *testing.T) { + tests := []struct { + name string + usage TokenUsage + model string + wantCost float64 + }{ + { + name: "sonnet basic", + usage: TokenUsage{ + InputTokens: 1_000_000, + OutputTokens: 1_000_000, + }, + model: "claude-sonnet-4-20250514", + wantCost: 18.0, // $3 input + $15 output + }, + { + name: "sonnet with cache", + usage: TokenUsage{ + InputTokens: 0, + OutputTokens: 100_000, + CacheCreationInputTokens: 1_000_000, + CacheReadInputTokens: 1_000_000, + }, + model: "sonnet", + wantCost: 5.55, // $3.75 cache write + $0.30 cache read + $1.50 output + }, + { + name: "opus", + usage: TokenUsage{ + InputTokens: 1_000_000, + OutputTokens: 100_000, + }, + model: "claude-opus-4-5-20251101", + wantCost: 22.5, // $15 input + $7.5 output + }, + { + name: "haiku", + usage: TokenUsage{ + InputTokens: 1_000_000, + OutputTokens: 1_000_000, + }, + model: "claude-3-5-haiku-20241022", + wantCost: 4.8, // $0.80 input + $4 output + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cost := CalculateCost(tt.usage, tt.model) + if cost < tt.wantCost-0.01 || cost > tt.wantCost+0.01 { + t.Errorf("CalculateCost() = %v, want %v", cost, tt.wantCost) + } + }) + } +} + +func TestNormalizeModelName(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"claude-opus-4-5-20251101", "opus"}, + {"claude-sonnet-4-20250514", "sonnet"}, + {"claude-3-5-haiku-20241022", "haiku"}, + {"CLAUDE-OPUS-4", "opus"}, + {"unknown-model", "sonnet"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := NormalizeModelName(tt.input) + if got != tt.want { + t.Errorf("NormalizeModelName(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestTokenUsageAdd(t *testing.T) { + a := TokenUsage{ + InputTokens: 100, + OutputTokens: 50, + CacheCreationInputTokens: 200, + CacheReadInputTokens: 300, + } + b := TokenUsage{ + InputTokens: 50, + OutputTokens: 25, + CacheCreationInputTokens: 100, + CacheReadInputTokens: 150, + } + + a.Add(b) + + if a.InputTokens != 150 { + t.Errorf("InputTokens = %d, want 150", a.InputTokens) + } + if a.OutputTokens != 75 { + t.Errorf("OutputTokens = %d, want 75", a.OutputTokens) + } + if a.CacheCreationInputTokens != 300 { + t.Errorf("CacheCreationInputTokens = %d, want 300", a.CacheCreationInputTokens) + } + if a.CacheReadInputTokens != 450 { + t.Errorf("CacheReadInputTokens = %d, want 450", a.CacheReadInputTokens) + } +} + +func TestTokenUsageTotalInput(t *testing.T) { + usage := TokenUsage{ + InputTokens: 100, + CacheCreationInputTokens: 200, + CacheReadInputTokens: 300, + } + + total := usage.TotalInput() + if total != 600 { + t.Errorf("TotalInput() = %d, want 600", total) + } +} diff --git a/internal/usage/pricing.go b/internal/usage/pricing.go new file mode 100644 index 0000000..b82ba06 --- /dev/null +++ b/internal/usage/pricing.go @@ -0,0 +1,83 @@ +package usage + +import "strings" + +// ModelPricing represents pricing for a specific model (per 1M tokens) +type ModelPricing struct { + Input float64 + Output float64 + CacheWrite float64 + CacheRead float64 +} + +// Pricing table per 1M tokens +var modelPricing = map[string]ModelPricing{ + "opus": { + Input: 15.0, + Output: 75.0, + CacheWrite: 18.75, + CacheRead: 1.50, + }, + "sonnet": { + Input: 3.0, + Output: 15.0, + CacheWrite: 3.75, + CacheRead: 0.30, + }, + "haiku": { + Input: 0.80, + Output: 4.0, + CacheWrite: 1.0, + CacheRead: 0.08, + }, +} + +// NormalizeModelName converts various model ID formats to our internal name +func NormalizeModelName(model string) string { + model = strings.ToLower(model) + + if strings.Contains(model, "opus") { + return "opus" + } + if strings.Contains(model, "sonnet") { + return "sonnet" + } + if strings.Contains(model, "haiku") { + return "haiku" + } + + // Default to sonnet if unknown + return "sonnet" +} + +// CalculateCost calculates the estimated cost for token usage +func CalculateCost(usage TokenUsage, model string) float64 { + normalizedModel := NormalizeModelName(model) + pricing, ok := modelPricing[normalizedModel] + if !ok { + pricing = modelPricing["sonnet"] + } + + // Convert tokens to millions + inputM := float64(usage.InputTokens) / 1_000_000 + outputM := float64(usage.OutputTokens) / 1_000_000 + cacheWriteM := float64(usage.CacheCreationInputTokens) / 1_000_000 + cacheReadM := float64(usage.CacheReadInputTokens) / 1_000_000 + + cost := inputM*pricing.Input + + outputM*pricing.Output + + cacheWriteM*pricing.CacheWrite + + cacheReadM*pricing.CacheRead + + return cost +} + +// GetPricing returns the pricing for a given model +func GetPricing(model string) ModelPricing { + normalizedModel := NormalizeModelName(model) + pricing, ok := modelPricing[normalizedModel] + if !ok { + return modelPricing["sonnet"] + } + return pricing +} diff --git a/internal/usage/types.go b/internal/usage/types.go new file mode 100644 index 0000000..8020f54 --- /dev/null +++ b/internal/usage/types.go @@ -0,0 +1,34 @@ +package usage + +import "time" + +// TokenUsage represents token counts from a Claude session +type TokenUsage struct { + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"` + CacheReadInputTokens int64 `json:"cache_read_input_tokens"` +} + +// Add adds another TokenUsage to this one +func (t *TokenUsage) Add(other TokenUsage) { + t.InputTokens += other.InputTokens + t.OutputTokens += other.OutputTokens + t.CacheCreationInputTokens += other.CacheCreationInputTokens + t.CacheReadInputTokens += other.CacheReadInputTokens +} + +// TotalInput returns the total input tokens (regular + cache creation + cache read) +func (t *TokenUsage) TotalInput() int64 { + return t.InputTokens + t.CacheCreationInputTokens + t.CacheReadInputTokens +} + +// SessionUsage represents usage data for a Claude session +type SessionUsage struct { + SessionID string + ProjectPath string + TotalUsage TokenUsage + EstimatedCost float64 + Model string + LastUpdated time.Time +} diff --git a/internal/usage/watcher.go b/internal/usage/watcher.go new file mode 100644 index 0000000..205405d --- /dev/null +++ b/internal/usage/watcher.go @@ -0,0 +1,329 @@ +package usage + +import ( + "os" + "path/filepath" + "sync" + "time" +) + +// Watcher monitors session files for changes +type Watcher struct { + mu sync.RWMutex + sessions map[string]*sessionWatch + stopCh chan struct{} + updateCh chan string + pollInterval time.Duration + claudeBaseDir string +} + +type sessionWatch struct { + sessionFile string + lastSize int64 + lastMod time.Time + usage *SessionUsage +} + +// NewWatcher creates a new usage watcher +func NewWatcher(pollInterval time.Duration) *Watcher { + claudeDir, _ := GetClaudeProjectsDir() + return &Watcher{ + sessions: make(map[string]*sessionWatch), + stopCh: make(chan struct{}), + updateCh: make(chan string, 100), + pollInterval: pollInterval, + claudeBaseDir: claudeDir, + } +} + +// Updates returns a channel that receives session names when usage updates +func (w *Watcher) Updates() <-chan string { + return w.updateCh +} + +// Start begins watching for file changes +func (w *Watcher) Start() { + go w.pollLoop() +} + +// Stop stops the watcher +func (w *Watcher) Stop() { + close(w.stopCh) +} + +// WatchSession adds a session to the watch list +func (w *Watcher) WatchSession(sessionName, workingDir 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 + } + + // 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 info.ModTime().After(mostRecentTime) { + mostRecentTime = info.ModTime() + mostRecent = f + } + } + + if mostRecent == "" { + return + } + + w.mu.Lock() + defer w.mu.Unlock() + + if _, exists := w.sessions[sessionName]; !exists { + w.sessions[sessionName] = &sessionWatch{ + sessionFile: mostRecent, + } + } +} + +// UnwatchSession removes a session from the watch list +func (w *Watcher) UnwatchSession(sessionName string) { + w.mu.Lock() + defer w.mu.Unlock() + delete(w.sessions, sessionName) +} + +// GetUsage returns the current usage for a session +func (w *Watcher) GetUsage(sessionName string) *SessionUsage { + w.mu.RLock() + defer w.mu.RUnlock() + + watch, ok := w.sessions[sessionName] + if !ok || watch.usage == nil { + return nil + } + return watch.usage +} + +// RefreshSession forces a refresh of usage data for a session +func (w *Watcher) RefreshSession(sessionName string) { + w.mu.RLock() + watch, ok := w.sessions[sessionName] + w.mu.RUnlock() + + if !ok { + return + } + + w.updateSession(sessionName, watch) +} + +func (w *Watcher) pollLoop() { + ticker := time.NewTicker(w.pollInterval) + defer ticker.Stop() + + for { + select { + case <-w.stopCh: + return + case <-ticker.C: + w.poll() + } + } +} + +func (w *Watcher) poll() { + w.mu.RLock() + sessions := make(map[string]*sessionWatch) + for name, watch := range w.sessions { + sessions[name] = watch + } + w.mu.RUnlock() + + for name, watch := range sessions { + w.updateSession(name, watch) + } +} + +func (w *Watcher) updateSession(name string, watch *sessionWatch) { + info, err := os.Stat(watch.sessionFile) + if err != nil { + return + } + + // Check if file changed + if info.Size() == watch.lastSize && info.ModTime().Equal(watch.lastMod) { + return + } + + // Parse the file + usage, err := ParseSessionFile(watch.sessionFile) + if err != nil { + return + } + + // Calculate cost + usage.EstimatedCost = CalculateCost(usage.TotalUsage, usage.Model) + + w.mu.Lock() + if sw, ok := w.sessions[name]; ok { + sw.lastSize = info.Size() + sw.lastMod = info.ModTime() + sw.usage = usage + } + w.mu.Unlock() + + // Notify update + select { + case w.updateCh <- name: + default: + } +} + +// FindActiveSessionFile finds the active JSONL file for a given working directory +func FindActiveSessionFile(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 + } + } + + return mostRecent, nil +} + +// GetSessionUsage parses and returns usage for a working directory +func GetSessionUsage(workingDir string) (*SessionUsage, error) { + sessionFile, err := FindActiveSessionFile(workingDir) + if err != nil { + return nil, err + } + + if sessionFile == "" { + return nil, nil + } + + usage, err := ParseSessionFile(sessionFile) + if err != nil { + return nil, err + } + + usage.EstimatedCost = CalculateCost(usage.TotalUsage, usage.Model) + return usage, nil +} + +// GetAllSessionsUsage returns usage for all session files in a project directory +func GetAllSessionsUsage(workingDir string) ([]*SessionUsage, error) { + projectDir, err := FindProjectDir(workingDir) + if err != nil { + return nil, err + } + + files, err := FindSessionFiles(projectDir) + if err != nil { + return nil, err + } + + var results []*SessionUsage + for _, f := range files { + usage, err := ParseSessionFile(f) + if err != nil { + continue + } + usage.EstimatedCost = CalculateCost(usage.TotalUsage, usage.Model) + + // Get file mod time as last updated + if info, err := os.Stat(f); err == nil { + usage.LastUpdated = info.ModTime() + } + + results = append(results, usage) + } + + // Sort by last updated, most recent first + for i := 0; i < len(results)-1; i++ { + for j := i + 1; j < len(results); j++ { + if results[j].LastUpdated.After(results[i].LastUpdated) { + results[i], results[j] = results[j], results[i] + } + } + } + + return results, nil +} + +// GetMostRecentSession returns the most recently modified session in a project +func GetMostRecentSession(workingDir string) (*SessionUsage, error) { + sessions, err := GetAllSessionsUsage(workingDir) + if err != nil { + return nil, err + } + + if len(sessions) == 0 { + return nil, nil + } + + return sessions[0], nil +} + +// TotalProjectUsage returns combined usage for all sessions in a project +func TotalProjectUsage(workingDir string) (*TokenUsage, float64, error) { + sessions, err := GetAllSessionsUsage(workingDir) + if err != nil { + return nil, 0, err + } + + total := &TokenUsage{} + var totalCost float64 + + for _, s := range sessions { + total.Add(s.TotalUsage) + totalCost += s.EstimatedCost + } + + return total, totalCost, nil +} + +// ListAllProjects returns all Claude Code project directories +func ListAllProjects() ([]string, error) { + baseDir, err := GetClaudeProjectsDir() + if err != nil { + return nil, err + } + + entries, err := os.ReadDir(baseDir) + if err != nil { + return nil, err + } + + var projects []string + for _, entry := range entries { + if entry.IsDir() { + projects = append(projects, filepath.Join(baseDir, entry.Name())) + } + } + + return projects, nil +}