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
1 change: 1 addition & 0 deletions internal/claude/detector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}`),
Expand Down
15 changes: 15 additions & 0 deletions internal/daemon/monitor.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ type Monitor struct {
debug bool
usageWatcher *usage.Watcher
usagePollTick int
lastCosts map[string]float64
}

// NewMonitor creates a new session monitor
Expand All @@ -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),
}
}

Expand Down Expand Up @@ -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
}
}
}

Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions internal/store/migrations/005_daily_cost.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE daily_stats ADD COLUMN daily_cost REAL DEFAULT 0;
30 changes: 27 additions & 3 deletions internal/store/sqlite.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type DailyStats struct {
MaxStreak float64
PomodorosCompleted int
FlowTimeSeconds int
DailyCost float64
}

// ActivityEntry represents a log entry
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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,
Expand All @@ -248,6 +255,7 @@ func (s *Store) GetTodayStats() (*DailyStats, error) {
&stats.MaxStreak,
&stats.PomodorosCompleted,
&stats.FlowTimeSeconds,
&stats.DailyCost,
)

if err == sql.ErrNoRows {
Expand All @@ -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,
Expand All @@ -288,6 +297,7 @@ func (s *Store) UpdateTodayStats(stats *DailyStats) error {
stats.MaxStreak,
stats.PomodorosCompleted,
stats.FlowTimeSeconds,
stats.DailyCost,
)

if err != nil {
Expand Down Expand Up @@ -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(`
Expand Down
37 changes: 36 additions & 1 deletion internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import (
"fmt"
"hash/fnv"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"

Expand Down Expand Up @@ -74,6 +76,8 @@ type Model struct {
score int
pomodoroState game.PomodoroState
pomodoroRemain time.Duration
dailyCost float64
costPollTick int

// Error state
lastError error
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand Down
18 changes: 4 additions & 14 deletions internal/tui/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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]
Expand All @@ -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
}
Expand Down