From 093ffc2f8103bd1a02dbf358ae66d79400d02fe9 Mon Sep 17 00:00:00 2001 From: MartianGreed Date: Wed, 21 Jan 2026 11:52:46 +0100 Subject: [PATCH] feat: add auto-switch Claude Code mode before sending prompts Adds default_mode config option to automatically switch Claude Code to specified mode (plan/code/auto) before sending prompts via ccmanager. --- internal/app/app.go | 2 +- internal/claude/detector.go | 24 ++++++- internal/claude/detector_test.go | 33 +++++++++ internal/config/config.go | 1 + internal/daemon/monitor.go | 20 ++++-- .../migrations/004_claude_session_id.sql | 1 + internal/store/sqlite.go | 30 ++++++++ internal/tmux/client.go | 18 ++++- internal/tui/model.go | 47 +++++++++++- internal/tui/view.go | 71 +++++++++++++++++-- internal/usage/parser.go | 52 ++++++++++++++ 11 files changed, 283 insertions(+), 16 deletions(-) create mode 100644 internal/store/migrations/004_claude_session_id.sql diff --git a/internal/app/app.go b/internal/app/app.go index fee30ed..7d7af63 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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) diff --git a/internal/claude/detector.go b/internal/claude/detector.go index d974c5e..45dbef6 100644 --- a/internal/claude/detector.go +++ b/internal/claude/detector.go @@ -51,6 +51,7 @@ type Detector struct { tokenPattern *regexp.Regexp thinkingPattern *regexp.Regexp ansiPattern *regexp.Regexp + modePattern *regexp.Regexp } // NewDetector creates a new Claude state detector @@ -71,14 +72,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`), }, idlePatterns: []*regexp.Regexp{ regexp.MustCompile(`(?m)❯\s*$`), - regexp.MustCompile(`(?m)>\s*$`), regexp.MustCompile(`(?m)claude>\s*$`), regexp.MustCompile(`↵ send`), regexp.MustCompile(`⏵⏵`), @@ -94,6 +100,7 @@ func NewDetector() *Detector { tokenPattern: regexp.MustCompile(`↓\s*([\d,.]+)k?\s*tokens?`), thinkingPattern: regexp.MustCompile(`Thinking[^(]*\((\d+)m?\s*(\d+)?s?\)`), ansiPattern: regexp.MustCompile(`\x1b\[[0-9;]*m`), + modePattern: regexp.MustCompile(`(?i)(plan|code|auto|accept[\s-]?edits?)\s+(?:mode\s+)?on\s+\(shift\+tab`), } } @@ -183,6 +190,19 @@ func (d *Detector) ParseInfo(content string) SessionInfo { return info } +// DetectMode detects the current Claude mode (plan, code, auto) from terminal output +func (d *Detector) DetectMode(content string) string { + content = d.stripANSI(content) + if matches := d.modePattern.FindStringSubmatch(content); len(matches) >= 2 { + mode := strings.ToLower(matches[1]) + if strings.Contains(mode, "accept") || strings.Contains(mode, "edit") { + return "edit" + } + return mode + } + return "" +} + func (d *Detector) stripANSI(s string) string { return d.ansiPattern.ReplaceAllString(s, "") } diff --git a/internal/claude/detector_test.go b/internal/claude/detector_test.go index d7094fb..23f289a 100644 --- a/internal/claude/detector_test.go +++ b/internal/claude/detector_test.go @@ -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}, @@ -58,6 +65,32 @@ func TestDetectState(t *testing.T) { } } +func TestDetectMode(t *testing.T) { + d := NewDetector() + + tests := []struct { + name string + content string + want string + }{ + {"plan mode", "plan mode on (shift+tab to cycle)", "plan"}, + {"code mode", "code mode on (shift+tab to cycle)", "code"}, + {"auto mode", "auto mode on (shift+tab to cycle)", "auto"}, + {"accept edits", "accept edits on (shift+tab to cycle)", "edit"}, + {"uppercase", "PLAN mode on (shift+tab to cycle)", "plan"}, + {"no mode", "some content", ""}, + {"partial match", "mode on (shift+tab", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := d.DetectMode(tt.content); got != tt.want { + t.Errorf("DetectMode() = %q, want %q", got, tt.want) + } + }) + } +} + func TestParseInfo(t *testing.T) { d := NewDetector() diff --git a/internal/config/config.go b/internal/config/config.go index c217577..7ed52e8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -46,6 +46,7 @@ type UIConfig struct { NewlineSequence string `yaml:"newline_sequence"` SessionListWidthPct int `yaml:"session_list_width_pct"` Editor string `yaml:"editor"` + DefaultMode string `yaml:"default_mode"` } type WorkspaceConfig struct { diff --git a/internal/daemon/monitor.go b/internal/daemon/monitor.go index 2bde8bd..c1e1048 100644 --- a/internal/daemon/monitor.go +++ b/internal/daemon/monitor.go @@ -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" ) @@ -54,6 +55,7 @@ const ( type Monitor struct { tmux *tmux.Client detector *claude.Detector + store *store.Store mu sync.RWMutex sessions map[string]*SessionState @@ -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{}), @@ -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, diff --git a/internal/store/migrations/004_claude_session_id.sql b/internal/store/migrations/004_claude_session_id.sql new file mode 100644 index 0000000..a7f7b9b --- /dev/null +++ b/internal/store/migrations/004_claude_session_id.sql @@ -0,0 +1 @@ +ALTER TABLE sessions ADD COLUMN claude_session_id TEXT; diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index da90581..2a08ac0 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -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 } @@ -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 +} diff --git a/internal/tmux/client.go b/internal/tmux/client.go index 50f7bc9..7845a1e 100644 --- a/internal/tmux/client.go +++ b/internal/tmux/client.go @@ -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() } @@ -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() } diff --git a/internal/tui/model.go b/internal/tui/model.go index ad0cd2c..344e937 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -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" ) @@ -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 @@ -427,6 +432,11 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if text != "" && m.selected < len(m.sessions) { session := m.sessions[m.selected] + if targetMode := m.config.UI.DefaultMode; targetMode != "" { + if m.switchToMode(session, targetMode) { + m.addActivity(session.Name, "Switched to %s mode", targetMode) + } + } _ = m.tmux.SendKeysToPane(session.Name, session.ClaudePane, text) m.addActivity(session.Name, "Sent: %s", text) m.addToPromptHistory(text) @@ -524,10 +534,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 } @@ -563,6 +574,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 + }() + case "up", "k": if len(m.sessions) > 0 { session := m.sessions[m.selected] @@ -1099,3 +1117,30 @@ func max(a, b int) int { } return b } + +func (m *Model) switchToMode(session *daemon.SessionState, targetMode string) bool { + if targetMode == "" || session.State == claude.StateUrgent { + return false + } + + content := m.previewCache[session.Name] + detector := claude.NewDetector() + currentMode := detector.DetectMode(content) + + if strings.EqualFold(currentMode, targetMode) { + return false + } + + const maxCycles = 3 + for i := 0; i < maxCycles; i++ { + _ = m.tmux.SendKeysToPaneRaw(session.Name, session.ClaudePane, "BTab") + time.Sleep(50 * time.Millisecond) + + newContent, _ := m.tmux.CapturePaneDefault(session.Name) + if strings.EqualFold(detector.DetectMode(newContent), targetMode) { + time.Sleep(50 * time.Millisecond) + return true + } + } + return false +} diff --git a/internal/tui/view.go b/internal/tui/view.go index 5f73f0d..68e32bb 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -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 @@ -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 } @@ -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) @@ -665,6 +674,7 @@ GAME p Start/pause pomodoro P Stop pomodoro s Show statistics + u Show usage summary GENERAL ? Toggle help @@ -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()). diff --git a/internal/usage/parser.go b/internal/usage/parser.go index fcc05c4..3f42d39 100644 --- a/internal/usage/parser.go +++ b/internal/usage/parser.go @@ -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 +}