From 867a1df6804563350f8aa4a53adab5a66ae9760b Mon Sep 17 00:00:00 2001 From: MartianGreed Date: Wed, 21 Jan 2026 11:52:46 +0100 Subject: [PATCH 1/2] feat: add session rename with 'r' key --- internal/claude/detector.go | 3 ++ internal/tmux/client.go | 13 +++++++ internal/tui/model.go | 75 +++++++++++++++++++++++++++++++++---- internal/tui/view.go | 33 ++++++++++++++-- 4 files changed, 113 insertions(+), 11 deletions(-) diff --git a/internal/claude/detector.go b/internal/claude/detector.go index 45dbef6..4314cc1 100644 --- a/internal/claude/detector.go +++ b/internal/claude/detector.go @@ -52,6 +52,9 @@ type Detector struct { thinkingPattern *regexp.Regexp ansiPattern *regexp.Regexp modePattern *regexp.Regexp + tokenPattern *regexp.Regexp + thinkingPattern *regexp.Regexp + ansiPattern *regexp.Regexp } // NewDetector creates a new Claude state detector diff --git a/internal/tmux/client.go b/internal/tmux/client.go index 7845a1e..3afb5eb 100644 --- a/internal/tmux/client.go +++ b/internal/tmux/client.go @@ -155,6 +155,12 @@ func (c *Client) KillSession(name string) error { return cmd.Run() } +// RenameSession renames a tmux session +func (c *Client) RenameSession(oldName, newName string) error { + cmd := exec.Command("tmux", "rename-session", "-t", oldName, newName) + return cmd.Run() +} + // SendKeys sends keys to a tmux session func (c *Client) SendKeys(session, keys string) error { // Send double Enter for multi-line content (3+ lines) to signal paste completion @@ -188,6 +194,13 @@ func (c *Client) SendKeysToPane(session string, pane *Pane, keys string) error { args = append(args, "Enter") } cmd = exec.Command("tmux", args...) + time.Sleep(10 * time.Millisecond) + // 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 344e937..f548452 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -44,6 +44,7 @@ type Model struct { // Input mode inputMode bool + renameMode bool inputField textinput.Model // Path picker mode @@ -109,6 +110,9 @@ type Model struct { // Pending urgent session to switch to after prompt sent pendingUrgent string + + // Workspace repo cache (session name → source repo basename) + workspaceRepos map[string]string } // ActivityEntry represents a log entry @@ -149,6 +153,7 @@ func New(monitor *daemon.Monitor, engine *game.Engine, store *store.Store, cfg * previewHashes: make(map[string]uint64), previewScrollPos: make(map[string]int), autoScroll: make(map[string]bool), + workspaceRepos: make(map[string]string), } engine.Pomodoro().OnComplete(func() { @@ -191,13 +196,15 @@ func (m *Model) listenForMessages() tea.Cmd { func (m *Model) capturePreviewCmd(sessionName string, pane *tmux.Pane) tea.Cmd { return func() tea.Msg { - var content string - var err error - if pane != nil { - content, err = m.tmux.CapturePane(sessionName, pane.WindowIndex, pane.PaneIndex) - } else { - content, err = m.tmux.CapturePaneDefault(sessionName) + if pane == nil { + return messages.PreviewCaptureMsg{ + SessionName: sessionName, + Content: "", + Hash: 0, + Err: nil, + } } + content, err := m.tmux.CapturePane(sessionName, pane.WindowIndex, pane.PaneIndex) hash := fnv.New64a() hash.Write([]byte(content)) return messages.PreviewCaptureMsg{ @@ -357,6 +364,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.lastError = fmt.Errorf("workspace creation failed: %w", err) m.addActivity("", "Workspace creation failed: %v", err) } else { + m.workspaceRepos[name] = filepath.Base(path) if m.store != nil { _ = m.store.SaveSessionWorkspace(name, wsPath, path) } @@ -413,6 +421,49 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } + // Handle rename mode + if m.renameMode { + if msg, ok := msg.(tea.KeyMsg); ok { + switch msg.String() { + case "enter": + newName := m.inputField.Value() + if newName != "" && m.selected < len(m.sessions) { + oldName := m.sessions[m.selected].Name + if newName != oldName { + if err := m.tmux.RenameSession(oldName, newName); err != nil { + m.lastError = fmt.Errorf("failed to rename session: %w", err) + m.addActivity(oldName, "Rename failed: %v", err) + } else { + m.addActivity(newName, "Renamed from %s", oldName) + groups := m.engine.ControlGroups().GroupsForSession(oldName) + for _, groupNum := range groups { + m.engine.ControlGroups().Assign(groupNum, newName) + if m.store != nil { + _ = m.store.SetControlGroup(groupNum, newName) + } + } + if m.focused == oldName { + m.focused = newName + } + m.sessions = m.monitor.Sessions() + } + } + } + m.renameMode = false + m.inputField.Blur() + return m, tea.Batch(cmds...) + case "esc": + m.renameMode = false + m.inputField.Blur() + return m, tea.Batch(cmds...) + } + var cmd tea.Cmd + m.inputField, cmd = m.inputField.Update(msg) + cmds = append(cmds, cmd) + } + return m, tea.Batch(cmds...) + } + // Handle prompt mode if m.promptMode { if msg, ok := msg.(tea.KeyMsg); ok { @@ -742,10 +793,16 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd { editor = "nvim" } _ = m.tmux.NewWindow(session.Name, "editor", path, editor+" .") - _ = m.tmux.SwitchClient(session.Name) m.addActivity(session.Name, "Opened %s", editor) } } + + case "r": + if m.selected < len(m.sessions) { + m.renameMode = true + m.inputField.SetValue(m.sessions[m.selected].Name) + m.inputField.Focus() + } } return nil @@ -868,6 +925,9 @@ func (m *Model) handleSessionEvent(event daemon.Event) { m.sessions = m.monitor.Sessions() if m.store != nil { _ = m.store.CreateSession(event.Session) + if _, sourceRepo, err := m.store.GetSessionWorkspace(event.Session); err == nil && sourceRepo != "" { + m.workspaceRepos[event.Session] = filepath.Base(sourceRepo) + } } case daemon.EventSessionClosed: @@ -875,6 +935,7 @@ func (m *Model) handleSessionEvent(event daemon.Event) { m.sessions = m.monitor.Sessions() m.engine.ControlGroups().RemoveSession(event.Session) m.engine.RemoveSession(event.Session) + delete(m.workspaceRepos, event.Session) if m.selected >= len(m.sessions) { m.selected = max(0, len(m.sessions)-1) } diff --git a/internal/tui/view.go b/internal/tui/view.go index 68e32bb..5cda547 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "path/filepath" "strings" "time" @@ -62,7 +63,7 @@ func (m *Model) View() string { return m.viewPathPicker() } - if m.inputMode { + if m.inputMode || m.renameMode { return m.viewInputOverlay() } @@ -109,6 +110,13 @@ func (m *Model) View() string { // 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 + maxPromptHeight := m.height / 2 + if promptHeight > maxPromptHeight { + promptHeight = maxPromptHeight + } + // 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 } @@ -368,10 +376,15 @@ func (m *Model) viewSessionList(width, height int) string { nameWidth = 8 } + displayName := sess.Name + if repoName, ok := m.workspaceRepos[sess.Name]; ok { + displayName = fmt.Sprintf("%s (%s)", sess.Name, repoName) + } + line := fmt.Sprintf("%s%-*s %s %s%-8s %5s %6s", cursor, nameWidth, - truncate(sess.Name, nameWidth), + truncate(displayName, nameWidth), groupStr, stateIcon, stateStr, @@ -648,6 +661,7 @@ NAVIGATION SESSIONS n Create new session + r Rename selected session dd Delete selected session e Open editor in session dir @@ -764,9 +778,20 @@ func (m *Model) viewInputOverlay() string { Padding(1, 2). Width(50) - title := titleStyle.Render("New Session Name:") + var title, help string + if m.renameMode { + title = titleStyle.Render("Rename Session:") + help = helpStyle.Render("[Enter] Rename [Esc] Cancel") + } else { + if m.workspaceMode && m.selectedPath != "" { + repoName := filepath.Base(m.selectedPath) + title = titleStyle.Render(fmt.Sprintf("New Session for %s:", repoName)) + } else { + title = titleStyle.Render("New Session Name:") + } + help = helpStyle.Render("[Enter] Create [Esc] Cancel") + } input := m.inputField.View() - help := helpStyle.Render("[Enter] Create [Esc] Cancel") content := lipgloss.JoinVertical(lipgloss.Center, title, From 960d777b52b7c7aaf18b39fa2ad9166888379065 Mon Sep 17 00:00:00 2001 From: MartianGreed Date: Mon, 26 Jan 2026 14:53:00 +0100 Subject: [PATCH 2/2] fix: resolve race condition in global usage goroutine --- internal/claude/detector.go | 4 +- internal/tmux/client.go | 17 +------- internal/tui/messages/messages.go | 5 +++ internal/tui/model.go | 8 +++- internal/tui/view.go | 70 +++---------------------------- 5 files changed, 19 insertions(+), 85 deletions(-) diff --git a/internal/claude/detector.go b/internal/claude/detector.go index 4314cc1..5f87111 100644 --- a/internal/claude/detector.go +++ b/internal/claude/detector.go @@ -52,9 +52,6 @@ type Detector struct { thinkingPattern *regexp.Regexp ansiPattern *regexp.Regexp modePattern *regexp.Regexp - tokenPattern *regexp.Regexp - thinkingPattern *regexp.Regexp - ansiPattern *regexp.Regexp } // NewDetector creates a new Claude state detector @@ -88,6 +85,7 @@ func NewDetector() *Detector { }, idlePatterns: []*regexp.Regexp{ regexp.MustCompile(`(?m)❯\s*$`), + regexp.MustCompile(`(?m)>\s*$`), regexp.MustCompile(`(?m)claude>\s*$`), regexp.MustCompile(`↵ send`), regexp.MustCompile(`⏵⏵`), diff --git a/internal/tmux/client.go b/internal/tmux/client.go index 3afb5eb..52e91c0 100644 --- a/internal/tmux/client.go +++ b/internal/tmux/client.go @@ -163,16 +163,7 @@ func (c *Client) RenameSession(oldName, newName string) error { // SendKeys sends keys to a tmux session func (c *Client) SendKeys(session, keys string) error { - // 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...) + cmd := exec.Command("tmux", "send-keys", "-t", session, keys, "Enter") return cmd.Run() } @@ -188,12 +179,6 @@ func (c *Client) SendKeysToPane(session string, pane *Pane, keys string) error { if err := cmd.Run(); err != nil { return err } - // 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...) time.Sleep(10 * time.Millisecond) // Send double Enter for multi-line content (3+ lines) to signal paste completion args := []string{"send-keys", "-t", target, "Enter"} diff --git a/internal/tui/messages/messages.go b/internal/tui/messages/messages.go index ec3a270..6aa7772 100644 --- a/internal/tui/messages/messages.go +++ b/internal/tui/messages/messages.go @@ -98,3 +98,8 @@ type PreviewCaptureMsg struct { Hash uint64 Err error } + +// GlobalUsageMsg contains global usage data +type GlobalUsageMsg struct { + Usage interface{} +} diff --git a/internal/tui/model.go b/internal/tui/model.go index f548452..41985a7 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -266,6 +266,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.addActivity("", "Pomodoro complete! +%d points", msg.Points) cmds = append(cmds, m.listenForMessages()) + case messages.GlobalUsageMsg: + if global, ok := msg.Usage.(*usage.GlobalUsage); ok { + m.globalUsage = global + } + cmds = append(cmds, m.listenForMessages()) + case messages.ErrorMsg: m.lastError = msg.Err @@ -629,7 +635,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd { m.showUsage = true go func() { global, _ := usage.GetGlobalUsage() - m.globalUsage = global + m.msgChan <- messages.GlobalUsageMsg{Usage: global} }() case "up", "k": diff --git a/internal/tui/view.go b/internal/tui/view.go index 5cda547..eedbc95 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -79,10 +79,6 @@ 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 @@ -107,9 +103,6 @@ func (m *Model) View() string { if promptHeight < promptLines+2 { promptHeight = promptLines + 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 maxPromptHeight := m.height / 2 if promptHeight > maxPromptHeight { promptHeight = maxPromptHeight @@ -167,16 +160,13 @@ func (m *Model) viewHeader(width int) string { apm := statStyle.Render(fmt.Sprintf("APM: %d", m.apm)) - 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 + 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) } } - usageStr := formatUsageCompact(totalInput, totalOutput, totalCost) streakStr := fmt.Sprintf("STREAK: x%.1f", m.streakMult) streak := statStyle.Render(streakStr) @@ -688,7 +678,6 @@ GAME p Start/pause pomodoro P Stop pomodoro s Show statistics - u Show usage summary GENERAL ? Toggle help @@ -722,55 +711,6 @@ 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()).