diff --git a/internal/claude/detector.go b/internal/claude/detector.go index 5f87111..7e67275 100644 --- a/internal/claude/detector.go +++ b/internal/claude/detector.go @@ -70,6 +70,7 @@ func NewDetector() *Detector { regexp.MustCompile(`(?i)Hit enter`), regexp.MustCompile(`(?i)chat about this`), regexp.MustCompile(`(?i)skip interview and plan`), + regexp.MustCompile(`(?i)Ready to submit your answers`), }, thinkingPatterns: []*regexp.Regexp{ regexp.MustCompile(`(?i)thinking\.{3}`), diff --git a/internal/daemon/monitor.go b/internal/daemon/monitor.go index c1e1048..54b31c2 100644 --- a/internal/daemon/monitor.go +++ b/internal/daemon/monitor.go @@ -66,6 +66,7 @@ type Monitor struct { debug bool usageWatcher *usage.Watcher usagePollTick int + lastCosts map[string]float64 } // NewMonitor creates a new session monitor @@ -80,6 +81,7 @@ func NewMonitor(pollInterval time.Duration, st *store.Store) *Monitor { eventCh: make(chan Event, 100), debug: os.Getenv("CCMANAGER_DEBUG") == "1", usageWatcher: usage.NewWatcher(5 * time.Second), + lastCosts: make(map[string]float64), } } @@ -185,6 +187,18 @@ func (m *Monitor) updateUsage() { sess.Usage = sessionUsage } m.mu.Unlock() + + if sessionUsage.EstimatedCost > 0 { + last, exists := m.lastCosts[name] + current := sessionUsage.EstimatedCost + if exists { + delta := current - last + if delta > 0 && m.store != nil { + _ = m.store.AddToDailyCost(delta) + } + } + m.lastCosts[name] = current + } } } @@ -371,6 +385,7 @@ func (m *Monitor) poll() { if !seen[name] { m.usageWatcher.UnwatchSession(name) delete(m.sessions, name) + delete(m.lastCosts, name) m.eventCh <- Event{ Type: EventSessionClosed, Session: name, diff --git a/internal/store/migrations/005_daily_cost.sql b/internal/store/migrations/005_daily_cost.sql new file mode 100644 index 0000000..592d7de --- /dev/null +++ b/internal/store/migrations/005_daily_cost.sql @@ -0,0 +1 @@ +ALTER TABLE daily_stats ADD COLUMN daily_cost REAL DEFAULT 0; diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index 2a08ac0..35ef4d2 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -37,6 +37,7 @@ type DailyStats struct { MaxStreak float64 PomodorosCompleted int FlowTimeSeconds int + DailyCost float64 } // ActivityEntry represents a log entry @@ -118,6 +119,12 @@ func (s *Store) migrate() error { } _, _ = s.db.Exec(string(schema4)) + schema5, err := migrationsFS.ReadFile("migrations/005_daily_cost.sql") + if err != nil { + return fmt.Errorf("read migration 005: %w", err) + } + _, _ = s.db.Exec(string(schema5)) + return nil } @@ -239,7 +246,7 @@ func (s *Store) GetTodayStats() (*DailyStats, error) { var stats DailyStats err := s.db.QueryRow(` SELECT date, total_score, total_actions, max_streak, - pomodoros_completed, flow_time_seconds + pomodoros_completed, flow_time_seconds, COALESCE(daily_cost, 0) FROM daily_stats WHERE date = ? `, today).Scan( &stats.Date, @@ -248,6 +255,7 @@ func (s *Store) GetTodayStats() (*DailyStats, error) { &stats.MaxStreak, &stats.PomodorosCompleted, &stats.FlowTimeSeconds, + &stats.DailyCost, ) if err == sql.ErrNoRows { @@ -272,14 +280,15 @@ func (s *Store) GetTodayStats() (*DailyStats, error) { func (s *Store) UpdateTodayStats(stats *DailyStats) error { _, err := s.db.Exec(` INSERT INTO daily_stats (date, total_score, total_actions, max_streak, - pomodoros_completed, flow_time_seconds) - VALUES (?, ?, ?, ?, ?, ?) + pomodoros_completed, flow_time_seconds, daily_cost) + VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(date) DO UPDATE SET total_score = excluded.total_score, total_actions = excluded.total_actions, max_streak = excluded.max_streak, pomodoros_completed = excluded.pomodoros_completed, flow_time_seconds = excluded.flow_time_seconds, + daily_cost = excluded.daily_cost, updated_at = CURRENT_TIMESTAMP `, stats.Date, @@ -288,6 +297,7 @@ func (s *Store) UpdateTodayStats(stats *DailyStats) error { stats.MaxStreak, stats.PomodorosCompleted, stats.FlowTimeSeconds, + stats.DailyCost, ) if err != nil { @@ -511,6 +521,20 @@ func (s *Store) SetClaudeSessionID(sessionName, claudeSessionID string) error { return nil } +func (s *Store) AddToDailyCost(amount float64) error { + today := time.Now().Format("2006-01-02") + _, err := s.db.Exec(` + INSERT INTO daily_stats (date, daily_cost) VALUES (?, ?) + ON CONFLICT(date) DO UPDATE SET + daily_cost = daily_cost + ?, + updated_at = CURRENT_TIMESTAMP + `, today, amount, amount) + if err != nil { + return fmt.Errorf("add to daily cost: %w", err) + } + return nil +} + func (s *Store) GetClaudeSessionID(sessionName string) (string, error) { var id sql.NullString err := s.db.QueryRow(` diff --git a/internal/tui/model.go b/internal/tui/model.go index 6104418..6fb9c22 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -4,7 +4,9 @@ import ( "fmt" "hash/fnv" "os" + "os/exec" "path/filepath" + "runtime" "strings" "time" @@ -74,6 +76,8 @@ type Model struct { score int pomodoroState game.PomodoroState pomodoroRemain time.Duration + dailyCost float64 + costPollTick int // Error state lastError error @@ -130,7 +134,7 @@ func New(monitor *daemon.Monitor, engine *game.Engine, store *store.Store, cfg * prompt := textarea.New() prompt.Placeholder = "Type command and press Enter..." - prompt.CharLimit = 4096 + prompt.CharLimit = 0 prompt.SetHeight(2) prompt.ShowLineNumbers = false prompt.SetWidth(60) @@ -554,6 +558,13 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, tea.Batch(cmds...) } + case "ctrl+v": + clip, err := readClipboard() + if err == nil && clip != "" { + m.promptField.InsertString(clip) + m.autoGrowPrompt() + } + return m, tea.Batch(cmds...) case "shift+tab": if m.selected < len(m.sessions) { session := m.sessions[m.selected] @@ -1013,6 +1024,13 @@ func (m *Model) updateGameState() { m.score = m.engine.Score() m.pomodoroState = m.engine.Pomodoro().State() m.pomodoroRemain = m.engine.Pomodoro().Remaining() + m.costPollTick++ + if m.costPollTick >= 25 && m.store != nil { + m.costPollTick = 0 + if stats, err := m.store.GetTodayStats(); err == nil { + m.dailyCost = stats.DailyCost + } + } } func (m *Model) validateControlGroups() { @@ -1196,6 +1214,23 @@ func (m *Model) handleInteractiveKey(msg tea.KeyMsg) tea.Cmd { return nil } +func readClipboard() (string, error) { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("pbpaste") + case "linux": + cmd = exec.Command("xclip", "-selection", "clipboard", "-o") + default: + return "", fmt.Errorf("unsupported platform") + } + out, err := cmd.Output() + if err != nil { + return "", err + } + return string(out), nil +} + func max(a, b int) int { if a > b { return a diff --git a/internal/tui/view.go b/internal/tui/view.go index a058bf9..d263a9f 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -175,6 +175,7 @@ func (m *Model) viewHeader(width int) string { } score := statStyle.Render(fmt.Sprintf("SCORE: %s", formatScore(m.score))) + cost := statStyle.Render(fmt.Sprintf("COST: $%.2f", m.dailyCost)) pomodoroStr := m.formatPomodoro() pomodoro := statStyle.Render(pomodoroStr) @@ -187,7 +188,7 @@ func (m *Model) viewHeader(width int) string { if usageStr != "" { statParts = append(statParts, statStyle.Render(usageStr)) } - statParts = append(statParts, apm, streak, score, pomodoro) + statParts = append(statParts, cost, apm, streak, score, pomodoro) stats := strings.Join(statParts, " │ ") statsWidth := lipgloss.Width(stats) titleWidth := lipgloss.Width(title) @@ -467,22 +468,11 @@ func (m *Model) viewPreview(width, height int) string { content = m.previewCache[sess.Name] } if content != "" { - // Wrap lines to fit preview width - rawLines := strings.Split(strings.TrimSpace(content), "\n") - var contentLines []string + contentLines := strings.Split(strings.TrimSpace(content), "\n") maxLineWidth := width - 2 if maxLineWidth < 10 { maxLineWidth = 10 } - for _, line := range rawLines { - if ansi.StringWidth(line) <= maxLineWidth { - contentLines = append(contentLines, line) - } else { - // Wrap long lines - wrapped := ansi.Hardwrap(line, maxLineWidth, false) - contentLines = append(contentLines, strings.Split(wrapped, "\n")...) - } - } availableHeight := height - len(lines) - 1 scrollPos := m.previewScrollPos[sess.Name] @@ -506,7 +496,7 @@ func (m *Model) viewPreview(width, height int) string { } for _, line := range contentLines[start:end] { - lines = append(lines, " "+line) + lines = append(lines, " "+ansi.Truncate(line, maxLineWidth, "")+"\x1b[0m") if len(lines) >= height-1 { break }