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 @@ -85,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(`⏵⏵`),
Expand Down
18 changes: 8 additions & 10 deletions internal/tmux/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,18 +155,15 @@ 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
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()
}

Expand All @@ -182,6 +179,7 @@ func (c *Client) SendKeysToPane(session string, pane *Pane, keys string) error {
if err := cmd.Run(); err != nil {
return err
}
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 {
Expand Down
5 changes: 5 additions & 0 deletions internal/tui/messages/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,8 @@ type PreviewCaptureMsg struct {
Hash uint64
Err error
}

// GlobalUsageMsg contains global usage data
type GlobalUsageMsg struct {
Usage interface{}
}
83 changes: 75 additions & 8 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ type Model struct {

// Input mode
inputMode bool
renameMode bool
inputField textinput.Model

// Path picker mode
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -259,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

Expand Down Expand Up @@ -357,6 +370,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)
}
Expand Down Expand Up @@ -413,6 +427,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 {
Expand Down Expand Up @@ -578,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":
Expand Down Expand Up @@ -742,10 +799,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
Expand Down Expand Up @@ -868,13 +931,17 @@ 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:
m.addActivity(event.Session, "Session closed")
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)
}
Expand Down
97 changes: 31 additions & 66 deletions internal/tui/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package tui

import (
"fmt"
"path/filepath"
"strings"
"time"

Expand Down Expand Up @@ -62,7 +63,7 @@ func (m *Model) View() string {
return m.viewPathPicker()
}

if m.inputMode {
if m.inputMode || m.renameMode {
return m.viewInputOverlay()
}

Expand All @@ -78,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

Expand All @@ -106,6 +103,10 @@ func (m *Model) View() string {
if promptHeight < promptLines+2 {
promptHeight = promptLines + 2
}
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
Expand Down Expand Up @@ -159,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)
Expand Down Expand Up @@ -368,10 +366,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,
Expand Down Expand Up @@ -648,6 +651,7 @@ NAVIGATION

SESSIONS
n Create new session
r Rename selected session
dd Delete selected session
e Open editor in session dir

Expand All @@ -674,7 +678,6 @@ GAME
p Start/pause pomodoro
P Stop pomodoro
s Show statistics
u Show usage summary

GENERAL
? Toggle help
Expand Down Expand Up @@ -708,65 +711,27 @@ 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()).
BorderForeground(colorPrimary).
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,
Expand Down