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
24 changes: 22 additions & 2 deletions internal/claude/detector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(`⏵⏵`),
Expand All @@ -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`),
}
}

Expand Down Expand Up @@ -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, "")
}
Expand Down
33 changes: 33 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 All @@ -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()

Expand Down
1 change: 1 addition & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
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
47 changes: 46 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 @@ -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)
Expand Down Expand Up @@ -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
}

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