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
9 changes: 9 additions & 0 deletions internal/plugins/worktree/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ func (p *Plugin) Commands() []plugin.Command {
{ID: "cancel", Name: "Cancel", Description: "Cancel merge", Context: "worktree-commit-for-merge", Priority: 1},
{ID: "commit", Name: "Commit", Description: "Commit and continue", Context: "worktree-commit-for-merge", Priority: 2},
}
case ViewModeRenameShell:
return []plugin.Command{
{ID: "cancel", Name: "Cancel", Description: "Cancel rename", Context: "worktree-rename-shell", Priority: 1},
{ID: "confirm", Name: "Rename", Description: "Confirm new name", Context: "worktree-rename-shell", Priority: 2},
}
default:
// View toggle label changes based on current mode
viewToggleName := "Kanban"
Expand Down Expand Up @@ -123,11 +128,13 @@ func (p *Plugin) Commands() []plugin.Command {
if shell == nil || shell.Agent == nil {
cmds = append(cmds,
plugin.Command{ID: "attach-shell", Name: "Attach", Description: "Create and attach to shell", Context: "worktree-list", Priority: 10},
plugin.Command{ID: "rename-shell", Name: "Rename", Description: "Rename shell", Context: "worktree-list", Priority: 11},
)
} else {
cmds = append(cmds,
plugin.Command{ID: "attach-shell", Name: "Attach", Description: "Attach to shell", Context: "worktree-list", Priority: 10},
plugin.Command{ID: "kill-shell", Name: "Kill", Description: "Kill shell session", Context: "worktree-list", Priority: 11},
plugin.Command{ID: "rename-shell", Name: "Rename", Description: "Rename shell", Context: "worktree-list", Priority: 12},
)
}
return cmds
Expand Down Expand Up @@ -194,6 +201,8 @@ func (p *Plugin) FocusContext() string {
return "worktree-commit-for-merge"
case ViewModePromptPicker:
return "worktree-prompt-picker"
case ViewModeRenameShell:
return "worktree-rename-shell"
default:
if p.activePane == PanePreview {
return "worktree-preview"
Expand Down
111 changes: 111 additions & 0 deletions internal/plugins/worktree/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ func (p *Plugin) handleKeyPress(msg tea.KeyMsg) tea.Cmd {
return p.handlePromptPickerKeys(msg)
case ViewModeTypeSelector:
return p.handleTypeSelectorKeys(msg)
case ViewModeRenameShell:
return p.handleRenameShellKeys(msg)
}
return nil
}
Expand Down Expand Up @@ -591,6 +593,21 @@ func (p *Plugin) handleListKeys(msg tea.KeyMsg) tea.Cmd {
return p.killShellSessionByName(shell.TmuxName)
}
}
case "R":
// Rename selected shell session
if p.shellSelected && p.selectedShellIdx >= 0 && p.selectedShellIdx < len(p.shells) {
shell := p.shells[p.selectedShellIdx]
p.viewMode = ViewModeRenameShell
p.renameShellSession = shell
p.renameShellInput = textinput.New()
p.renameShellInput.SetValue(shell.Name)
p.renameShellInput.Focus()
p.renameShellInput.CharLimit = 50
p.renameShellInput.Width = 30
p.renameShellFocus = 0
p.renameShellButtonHover = 0
p.renameShellError = ""
}
case "y":
// Approve pending prompt on selected worktree
wt := p.selectedWorktree()
Expand Down Expand Up @@ -1059,3 +1076,97 @@ func (p *Plugin) handleCommitForMergeKeys(msg tea.KeyMsg) tea.Cmd {
p.mergeCommitMessageInput, cmd = p.mergeCommitMessageInput.Update(msg)
return cmd
}

// handleRenameShellKeys handles keys in the rename shell modal.
func (p *Plugin) handleRenameShellKeys(msg tea.KeyMsg) tea.Cmd {
switch msg.String() {
case "esc":
p.viewMode = ViewModeList
p.clearRenameShellModal()
return nil
case "tab":
p.renameShellInput.Blur()
p.renameShellFocus = (p.renameShellFocus + 1) % 3
if p.renameShellFocus == 0 {
p.renameShellInput.Focus()
}
return nil
case "shift+tab":
p.renameShellInput.Blur()
p.renameShellFocus = (p.renameShellFocus + 2) % 3
if p.renameShellFocus == 0 {
p.renameShellInput.Focus()
}
return nil
case "enter":
if p.renameShellFocus == 2 {
// Cancel button
p.viewMode = ViewModeList
p.clearRenameShellModal()
return nil
}
if p.renameShellFocus == 1 || p.renameShellFocus == 0 {
// Confirm button or input field
return p.executeRenameShell()
}
return nil
}

// Delegate to textinput when focused
if p.renameShellFocus == 0 {
p.renameShellError = "" // Clear error on typing
var cmd tea.Cmd
p.renameShellInput, cmd = p.renameShellInput.Update(msg)
return cmd
}
return nil
}

// executeRenameShell performs the rename operation.
func (p *Plugin) executeRenameShell() tea.Cmd {
newName := strings.TrimSpace(p.renameShellInput.Value())

// Validation
if newName == "" {
p.renameShellError = "Name cannot be empty"
return nil
}

if len(newName) > 50 {
p.renameShellError = "Name too long (max 50 characters)"
return nil
}

// Check for duplicates
for _, shell := range p.shells {
if shell.Name == newName && shell.TmuxName != p.renameShellSession.TmuxName {
p.renameShellError = "Name already in use"
return nil
}
}

shell := p.renameShellSession
tmuxName := shell.TmuxName

// Clear modal state
p.viewMode = ViewModeList
p.clearRenameShellModal()

return func() tea.Msg {
// Rename is just a local state change - no tmux operation needed
return RenameShellDoneMsg{
TmuxName: tmuxName,
NewName: newName,
Err: nil,
}
}
}

// clearRenameShellModal clears rename modal state.
func (p *Plugin) clearRenameShellModal() {
p.renameShellSession = nil
p.renameShellInput = textinput.Model{}
p.renameShellFocus = 0
p.renameShellButtonHover = 0
p.renameShellError = ""
}
26 changes: 26 additions & 0 deletions internal/plugins/worktree/mouse.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,21 @@ func (p *Plugin) handleMouseHover(action mouse.MouseAction) tea.Cmd {
default:
p.deleteShellConfirmButtonHover = 0
}
case ViewModeRenameShell:
if action.Region == nil {
p.renameShellButtonHover = 0
return nil
}
switch action.Region.ID {
case regionRenameShellInput:
p.renameShellButtonHover = 0 // Clear button hover when hovering input
case regionRenameShellConfirm:
p.renameShellButtonHover = 1
case regionRenameShellCancel:
p.renameShellButtonHover = 2
default:
p.renameShellButtonHover = 0
}
case ViewModePromptPicker:
if p.promptPicker == nil {
return nil
Expand Down Expand Up @@ -286,6 +301,17 @@ func (p *Plugin) handleMouseClick(action mouse.MouseAction) tea.Cmd {
case regionDeleteShellConfirmCancel:
// Click cancel button in shell delete modal
return p.cancelShellDelete()
case regionRenameShellInput:
// Click on rename input field
p.renameShellFocus = 0
p.renameShellInput.Focus()
case regionRenameShellConfirm:
// Click confirm button in rename shell modal
return p.executeRenameShell()
case regionRenameShellCancel:
// Click cancel button in rename shell modal
p.viewMode = ViewModeList
p.clearRenameShellModal()
case regionKanbanCard:
// Click on kanban card - select it
if data, ok := action.Region.Data.(kanbanCardData); ok {
Expand Down
47 changes: 38 additions & 9 deletions internal/plugins/worktree/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,16 +71,21 @@ const (
regionPromptFilter = "prompt-filter"

// Sidebar header regions
regionCreateWorktreeButton = "create-worktree-button"
regionShellsPlusButton = "shells-plus-button"
regionWorktreesPlusButton = "worktrees-plus-button"
regionCreateWorktreeButton = "create-worktree-button"
regionShellsPlusButton = "shells-plus-button"
regionWorktreesPlusButton = "worktrees-plus-button"

// Type selector modal regions
regionTypeSelectorOption = "type-selector-option"

// Shell delete confirmation modal regions
regionDeleteShellConfirmDelete = "delete-shell-confirm-delete"
regionDeleteShellConfirmCancel = "delete-shell-confirm-cancel"

// Rename shell modal regions
regionRenameShellInput = "rename-shell-input"
regionRenameShellConfirm = "rename-shell-confirm"
regionRenameShellCancel = "rename-shell-cancel"
)

// Plugin implements the worktree manager plugin.
Expand Down Expand Up @@ -218,16 +223,23 @@ type Plugin struct {
deleteShellConfirmFocus int // 0=delete button, 1=cancel button
deleteShellConfirmButtonHover int // 0=none, 1=delete, 2=cancel (for mouse hover)

// Rename shell modal state
renameShellSession *ShellSession // Shell being renamed
renameShellInput textinput.Model // Text input for new name
renameShellFocus int // 0=input, 1=confirm, 2=cancel
renameShellButtonHover int // 0=none, 1=confirm, 2=cancel (for mouse hover)
renameShellError string // Validation error message

// Initial reconnection tracking
initialReconnectDone bool

// State restoration tracking (only restore once on startup)
stateRestored bool

// Sidebar header hover state
hoverNewButton bool
hoverShellsPlusButton bool
hoverWorktreesPlusButton bool
hoverNewButton bool
hoverShellsPlusButton bool
hoverWorktreesPlusButton bool

// Multiple shell sessions (not tied to git worktrees)
shells []*ShellSession // All shell sessions for this project
Expand Down Expand Up @@ -422,7 +434,9 @@ func (p *Plugin) saveSelectionState() {
return
}

wtState := state.WorktreeState{}
wtState := state.GetWorktreeState(p.ctx.WorkDir)
wtState.WorktreeName = ""
wtState.ShellTmuxName = ""

if p.shellSelected {
// Shell is selected - save shell TmuxName
Expand All @@ -436,8 +450,23 @@ func (p *Plugin) saveSelectionState() {
}
}

// Only save if we have something selected
if wtState.WorktreeName != "" || wtState.ShellTmuxName != "" {
if len(p.shells) > 0 {
shellNames := make(map[string]string, len(p.shells))
for _, shell := range p.shells {
if shell == nil || shell.TmuxName == "" || shell.Name == "" {
continue
}
shellNames[shell.TmuxName] = shell.Name
}
if len(shellNames) > 0 {
wtState.ShellDisplayNames = shellNames
} else {
wtState.ShellDisplayNames = nil
}
}

// Only save if we have something selected or display names
if wtState.WorktreeName != "" || wtState.ShellTmuxName != "" || len(wtState.ShellDisplayNames) > 0 {
_ = state.SetWorktreeState(p.ctx.WorkDir, wtState)
}
}
Expand Down
31 changes: 30 additions & 1 deletion internal/plugins/worktree/shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"time"

tea "github.com/charmbracelet/bubbletea"
"github.com/marcus/sidecar/internal/state"
)

// Shell session constants
Expand Down Expand Up @@ -80,6 +81,13 @@ type (
Changed bool
}

// RenameShellDoneMsg signals shell rename operation completed
RenameShellDoneMsg struct {
TmuxName string // Session name (stable identifier)
NewName string // New display name
Err error // Non-nil if rename failed
}

// pollShellByNameMsg triggers a poll for a specific shell's output by name
pollShellByNameMsg struct {
TmuxName string
Expand All @@ -98,6 +106,27 @@ type pollShellMsg struct{}
// Called from Init() to reconnect to sessions from previous runs.
func (p *Plugin) initShellSessions() {
p.shells = p.discoverExistingShells()
p.restoreShellDisplayNames()
}

func (p *Plugin) restoreShellDisplayNames() {
if p.ctx == nil || len(p.shells) == 0 {
return
}

wtState := state.GetWorktreeState(p.ctx.WorkDir)
if len(wtState.ShellDisplayNames) == 0 {
return
}

for _, shell := range p.shells {
if shell == nil {
continue
}
if name, ok := wtState.ShellDisplayNames[shell.TmuxName]; ok && name != "" {
shell.Name = name
}
}
}

// discoverExistingShells finds all existing sidecar shell sessions for this project.
Expand Down Expand Up @@ -210,7 +239,7 @@ func (p *Plugin) createNewShell() tea.Cmd {
"new-session",
"-d", // Detached
"-s", sessionName, // Session name
"-c", workDir, // Working directory
"-c", workDir, // Working directory
}
cmd := exec.Command("tmux", args...)
if err := cmd.Run(); err != nil {
Expand Down
1 change: 1 addition & 0 deletions internal/plugins/worktree/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const (
ViewModeCommitForMerge // Commit modal before merge workflow
ViewModePromptPicker // Prompt template picker modal
ViewModeTypeSelector // Type selector modal (shell vs worktree)
ViewModeRenameShell // Rename shell modal
)

// FocusPane represents which pane is active in the split view.
Expand Down
11 changes: 11 additions & 0 deletions internal/plugins/worktree/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,17 @@ func (p *Plugin) Update(msg tea.Msg) (plugin.Plugin, tea.Cmd) {
}
return p, p.scheduleShellPollByName(msg.TmuxName, interval)

case RenameShellDoneMsg:
// Find shell and update its display name
for _, shell := range p.shells {
if shell.TmuxName == msg.TmuxName {
shell.Name = msg.NewName
break
}
}
// Persist the selection state
p.saveSelectionState()

case pollShellByNameMsg:
// Poll specific shell session for output by name
if p.findShellByName(msg.TmuxName) != nil {
Expand Down
Loading