From 2ba97bf60b1f93473337ba6bb703977b5f77b1de Mon Sep 17 00:00:00 2001 From: Marcus Vorwaller Date: Tue, 20 Jan 2026 15:17:50 -0800 Subject: [PATCH 1/3] Add shell rename feature and right-align [+] buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feature 1: Shell Rename - Add ViewModeRenameShell modal with text input for custom display names - Press R to rename selected shell (max 50 chars, unique, non-empty) - Modal shows current name and stable tmux session ID - Validation with user-friendly error messages (empty, duplicate, length) - Keyboard navigation: Tab/Shift+Tab cycle focus, Enter confirms, Esc cancels - Mouse support: click input field or buttons - Add RenameShellDoneMsg for state update after rename - Renamed names display in sidebar for easy identification - Names persist via saveSelectionState() (tmux session name stays stable) Feature 2: Right-Align [+] Buttons - Move "+" buttons from inline to right edge of sidebar headers - Shells subheader: "+ " → fill spacing + "+" - Worktrees subheader: "+ " → fill spacing + "+" - Matches "New" button styling (already right-aligned) - Improves visual hierarchy and reduces text clutter - Updates hit region X coordinates for correct mouse interaction - Works at all terminal widths (minimum 1 space between) Implementation details: - types.go: Add ViewModeRenameShell enum, RenameShellDoneMsg struct - plugin.go: Modal state fields, region constants - keys.go: R key handler with validation logic, keyboard nav functions - view_modals.go: renderRenameShellModal with dimmed overlay - view_list.go: Add ViewModeRenameShell case, right-align button code - update.go: Handle RenameShellDoneMsg, update shell name - mouse.go: Hover tracking and click handlers for modal - commands.go: Add rename-shell command to footer - worktrees-plugin.md: Document shell management and rename feature Tests: Rename shell with R, verify persistence after restart, test validation, verify buttons right-aligned at various terminal widths. Co-Authored-By: Claude Haiku 4.5 --- internal/plugins/worktree/commands.go | 2 + internal/plugins/worktree/keys.go | 111 +++++++++++++++++++++++ internal/plugins/worktree/mouse.go | 26 ++++++ internal/plugins/worktree/plugin.go | 12 +++ internal/plugins/worktree/shell.go | 7 ++ internal/plugins/worktree/types.go | 1 + internal/plugins/worktree/update.go | 11 +++ internal/plugins/worktree/view_list.go | 28 ++++-- internal/plugins/worktree/view_modals.go | 110 ++++++++++++++++++++++ website/docs/worktrees-plugin.md | 42 ++++++++- 10 files changed, 341 insertions(+), 9 deletions(-) diff --git a/internal/plugins/worktree/commands.go b/internal/plugins/worktree/commands.go index 4c8554ed..00f8b1fe 100644 --- a/internal/plugins/worktree/commands.go +++ b/internal/plugins/worktree/commands.go @@ -123,11 +123,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 diff --git a/internal/plugins/worktree/keys.go b/internal/plugins/worktree/keys.go index 0ce66d8a..bc856acf 100644 --- a/internal/plugins/worktree/keys.go +++ b/internal/plugins/worktree/keys.go @@ -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 } @@ -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() @@ -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 = "" +} diff --git a/internal/plugins/worktree/mouse.go b/internal/plugins/worktree/mouse.go index 0663f4df..bd3f0825 100644 --- a/internal/plugins/worktree/mouse.go +++ b/internal/plugins/worktree/mouse.go @@ -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 @@ -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 { diff --git a/internal/plugins/worktree/plugin.go b/internal/plugins/worktree/plugin.go index a2777965..72e5bc66 100644 --- a/internal/plugins/worktree/plugin.go +++ b/internal/plugins/worktree/plugin.go @@ -81,6 +81,11 @@ const ( // 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. @@ -218,6 +223,13 @@ 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 diff --git a/internal/plugins/worktree/shell.go b/internal/plugins/worktree/shell.go index cb35ab5c..0ae0de01 100644 --- a/internal/plugins/worktree/shell.go +++ b/internal/plugins/worktree/shell.go @@ -80,6 +80,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 diff --git a/internal/plugins/worktree/types.go b/internal/plugins/worktree/types.go index 1e5c0b70..a11619eb 100644 --- a/internal/plugins/worktree/types.go +++ b/internal/plugins/worktree/types.go @@ -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. diff --git a/internal/plugins/worktree/update.go b/internal/plugins/worktree/update.go index 87f0d072..06f040d5 100644 --- a/internal/plugins/worktree/update.go +++ b/internal/plugins/worktree/update.go @@ -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 { diff --git a/internal/plugins/worktree/view_list.go b/internal/plugins/worktree/view_list.go index b02e0e03..516831b7 100644 --- a/internal/plugins/worktree/view_list.go +++ b/internal/plugins/worktree/view_list.go @@ -63,6 +63,8 @@ func (p *Plugin) View(width, height int) string { return p.renderPromptPickerModal(width, height) case ViewModeTypeSelector: return p.renderTypeSelectorModal(width, height) + case ViewModeRenameShell: + return p.renderRenameShellModal(width, height) default: return p.renderListView(width, height) } @@ -213,7 +215,7 @@ func (p *Plugin) renderSidebarContent(width, height int) string { // === Render shells section === if len(p.shells) > 0 { - // Shells subheader with [+] button + // Shells subheader with [+] button (right-aligned) shellsTitle := styles.Muted.Render("Shells") shellsTitleWidth := lipgloss.Width(shellsTitle) shellsPlusStyle := styles.Button @@ -222,10 +224,15 @@ func (p *Plugin) renderSidebarContent(width, height int) string { } shellsPlusBtn := shellsPlusStyle.Render("+") shellsPlusBtnWidth := lipgloss.Width(shellsPlusBtn) - shellsHeader := shellsTitle + " " + shellsPlusBtn + // Right-align button with fill spacing + spacing := width - shellsTitleWidth - shellsPlusBtnWidth + if spacing < 1 { + spacing = 1 + } + shellsHeader := shellsTitle + strings.Repeat(" ", spacing) + shellsPlusBtn lines = append(lines, shellsHeader) - // Register hit region for shells [+] button (after title + space) - shellsPlusBtnX := 2 + shellsTitleWidth + 1 // 2 for left border+padding, +1 for space + // Register hit region for shells [+] button (right-aligned) + shellsPlusBtnX := 2 + shellsTitleWidth + spacing // 2 for left border+padding p.mouseHandler.HitMap.AddRect(regionShellsPlusButton, shellsPlusBtnX, currentY, shellsPlusBtnWidth, 1, nil) currentY++ @@ -286,7 +293,7 @@ func (p *Plugin) renderSidebarContent(width, height int) string { } // When shell is selected and no worktrees, just show the shell entries (already rendered above) } else { - // Worktrees subheader with [+] button (only if we have shells above) + // Worktrees subheader with [+] button (right-aligned, only if we have shells above) if len(p.shells) > 0 { worktreesTitle := styles.Muted.Render("Worktrees") worktreesTitleWidth := lipgloss.Width(worktreesTitle) @@ -296,10 +303,15 @@ func (p *Plugin) renderSidebarContent(width, height int) string { } worktreesPlusBtn := worktreesPlusStyle.Render("+") worktreesPlusBtnWidth := lipgloss.Width(worktreesPlusBtn) - worktreesHeader := worktreesTitle + " " + worktreesPlusBtn + // Right-align button with fill spacing + spacing := width - worktreesTitleWidth - worktreesPlusBtnWidth + if spacing < 1 { + spacing = 1 + } + worktreesHeader := worktreesTitle + strings.Repeat(" ", spacing) + worktreesPlusBtn lines = append(lines, worktreesHeader) - // Register hit region for worktrees [+] button (after title + space) - worktreesPlusBtnX := 2 + worktreesTitleWidth + 1 // 2 for left border+padding, +1 for space + // Register hit region for worktrees [+] button (right-aligned) + worktreesPlusBtnX := 2 + worktreesTitleWidth + spacing // 2 for left border+padding p.mouseHandler.HitMap.AddRect(regionWorktreesPlusButton, worktreesPlusBtnX, currentY, worktreesPlusBtnWidth, 1, nil) currentY++ } diff --git a/internal/plugins/worktree/view_modals.go b/internal/plugins/worktree/view_modals.go index ee181ee1..e1bac21c 100644 --- a/internal/plugins/worktree/view_modals.go +++ b/internal/plugins/worktree/view_modals.go @@ -908,6 +908,116 @@ func (p *Plugin) renderConfirmDeleteShellModal(width, height int) string { return ui.OverlayModal(background, modal, width, height) } +// renderRenameShellModal renders the rename shell modal. +func (p *Plugin) renderRenameShellModal(width, height int) string { + // Render the background (list view) + background := p.renderListView(width, height) + + if p.renameShellSession == nil { + return background + } + + // Modal dimensions + modalW := 50 + if modalW > width-4 { + modalW = width - 4 + } + + // Calculate input field width + inputW := modalW - 10 + if inputW < 20 { + inputW = 20 + } + + // Set textinput width and remove default prompt + p.renameShellInput.Width = inputW + p.renameShellInput.Prompt = "" + + shell := p.renameShellSession + + var sb strings.Builder + title := "Rename Shell" + sb.WriteString(lipgloss.NewStyle().Bold(true).Render(title)) + sb.WriteString("\n\n") + + // Shell info + sb.WriteString(fmt.Sprintf("Session: %s\n", dimText(shell.TmuxName))) + sb.WriteString(fmt.Sprintf("Current: %s\n", lipgloss.NewStyle().Bold(true).Render(shell.Name))) + sb.WriteString("\n") + + // New name field + nameLabel := "New Name:" + nameStyle := inputFocusedStyle + if p.renameShellFocus != 0 { + nameStyle = inputStyle + } + sb.WriteString(nameLabel) + sb.WriteString("\n") + sb.WriteString(nameStyle.Render(p.renameShellInput.View())) + sb.WriteString("\n") + + // Display error if present + if p.renameShellError != "" { + sb.WriteString("\n") + errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9")) + sb.WriteString(errStyle.Render("Error: " + p.renameShellError)) + } + + sb.WriteString("\n\n") + + // Render buttons with focus/hover states + confirmStyle := styles.Button + cancelStyle := styles.Button + if p.renameShellFocus == 1 { + confirmStyle = styles.ButtonFocused + } else if p.renameShellButtonHover == 1 { + confirmStyle = styles.ButtonHover + } + if p.renameShellFocus == 2 { + cancelStyle = styles.ButtonFocused + } else if p.renameShellButtonHover == 2 { + cancelStyle = styles.ButtonHover + } + sb.WriteString(confirmStyle.Render(" Rename ")) + sb.WriteString(" ") + sb.WriteString(cancelStyle.Render(" Cancel ")) + + content := sb.String() + modal := modalStyle.Width(modalW).Render(content) + + // Calculate modal position for hit regions + modalHeight := lipgloss.Height(modal) + modalStartX := (width - modalW) / 2 + modalStartY := (height - modalHeight) / 2 + + // Hit regions for input field and buttons + // Content structure: + // - Title (1) + blank (1) = 2 + // - Session line (1) + // - Current line (1) + // - blank (1) + // - "New Name:" label (1) + // - bordered input (3 lines) + // Total lines before buttons: 2 + 1 + 1 + 1 + 1 + 3 = 9 + hitX := modalStartX + 3 // border(1) + padding(2) + inputY := modalStartY + 2 + 6 // border(1) + padding(1) + header lines + p.mouseHandler.HitMap.AddRect(regionRenameShellInput, hitX, inputY, modalW-6, 3, nil) + + // Error line adds 2 lines if present + buttonYOffset := 9 + if p.renameShellError != "" { + buttonYOffset += 2 + } + + // Hit regions for buttons + buttonY := modalStartY + 2 + buttonYOffset + p.mouseHandler.HitMap.AddRect(regionRenameShellConfirm, hitX, buttonY, 12, 1, nil) + cancelX := hitX + 12 + 2 + p.mouseHandler.HitMap.AddRect(regionRenameShellCancel, cancelX, buttonY, 12, 1, nil) + + return ui.OverlayModal(background, modal, width, height) +} + // renderPromptPickerModal renders the prompt picker modal. func (p *Plugin) renderPromptPickerModal(width, height int) string { // Render the background (create modal behind it) diff --git a/website/docs/worktrees-plugin.md b/website/docs/worktrees-plugin.md index 896cb0d6..bf53af30 100644 --- a/website/docs/worktrees-plugin.md +++ b/website/docs/worktrees-plugin.md @@ -323,6 +323,45 @@ When creating a worktree, enable "Skip perms" to auto-approve agent actions. Eac **Warning:** Skip permissions mode grants agents unrestricted file access. Only use for trusted prompts in sandboxed environments. +## Shell Management + +Shells are standalone tmux sessions created for direct terminal access without an AI agent. They appear in the sidebar alongside worktrees for easy switching. + +### Creating Shells + +Press `n` and select "Shell" from the type selector modal, or press `A` in the sidebar to quickly create a new shell. Each shell is created with an auto-numbered display name (e.g., "Shell 1", "Shell 2") and a stable tmux session name for state persistence. + +### Renaming Shells + +Press `R` to rename a shell with a custom display name: + +1. **Modal appears**: Shows current name and tmux session ID +2. **Type new name**: Input field accepts up to 50 characters +3. **Validation**: Name must be unique and non-empty +4. **Confirm**: Press Enter to save, Esc to cancel + +Custom names persist across sidecar restarts. The underlying tmux session name (e.g., `sidecar-sh-project-1`) remains stable for reliable state restoration. + +**Example:** +- Default name: "Shell 1" +- Rename to: "Backend" +- Rename to: "Testing" +- Custom names appear in the sidebar for easy identification + +### Deleting Shells + +Press `D` to delete a shell session. This terminates the underlying tmux session and removes it from the sidebar. + +### Shell Capabilities + +| Operation | Key | Description | +|-----------|-----|-------------| +| Create shell | `n` + select Shell | Create new terminal session | +| Rename shell | `R` | Change display name (50 char limit) | +| Delete shell | `D` | Terminate tmux session | +| Attach to shell | `enter` | Interactive access to terminal | +| Kill shell | `K` | Force-terminate session | + ## Worktree Operations ### Creating Worktrees @@ -624,11 +663,12 @@ All keyboard shortcuts by context: | `l`, `→` | Next column / focus preview | | `v` | Toggle view mode | | `n` | Create worktree | -| `D` | Delete worktree | +| `D` | Delete worktree / Delete shell | | `p` | Push branch | | `d` | Show diff | | `m` | Merge workflow | | `t` | Link task | +| `R` | Rename shell (display name only) | | `s` | Start agent | | `S` | Stop agent | | `y` | Approve action | From a5a28f78f43f43b91c2b59b6cae40c9bb9b8bcb0 Mon Sep 17 00:00:00 2001 From: Marcus Vorwaller Date: Tue, 20 Jan 2026 16:11:21 -0800 Subject: [PATCH 2/3] Fix: Add missing ViewModeRenameShell cases to Commands() and FocusContext() - Added ViewModeRenameShell case in Commands() to return proper footer commands (Rename/Cancel) - Added ViewModeRenameShell case in FocusContext() to dispatch keyboard events to correct context - These were critical bugs preventing the rename shell modal from functioning correctly The feature was missing support for: 1. Footer command hints during rename operation 2. Proper keyboard context for keybinding dispatch Co-Authored-By: Claude Haiku 4.5 --- internal/plugins/worktree/commands.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/plugins/worktree/commands.go b/internal/plugins/worktree/commands.go index 00f8b1fe..b3305627 100644 --- a/internal/plugins/worktree/commands.go +++ b/internal/plugins/worktree/commands.go @@ -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" @@ -196,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" From a7ce8ec334261b29f0fdd99a98e3ff1fd0d94db6 Mon Sep 17 00:00:00 2001 From: Marcus Vorwaller Date: Tue, 20 Jan 2026 16:29:18 -0800 Subject: [PATCH 3/3] Persist shell display names across restarts --- internal/plugins/worktree/plugin.go | 45 ++++++++++++++++++++--------- internal/plugins/worktree/shell.go | 24 ++++++++++++++- internal/state/state.go | 23 ++++++++------- internal/state/state_test.go | 19 ++++++++++-- 4 files changed, 83 insertions(+), 28 deletions(-) diff --git a/internal/plugins/worktree/plugin.go b/internal/plugins/worktree/plugin.go index 72e5bc66..3ca5968f 100644 --- a/internal/plugins/worktree/plugin.go +++ b/internal/plugins/worktree/plugin.go @@ -71,9 +71,9 @@ 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" @@ -224,11 +224,11 @@ type Plugin struct { 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 + 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 @@ -237,9 +237,9 @@ type Plugin struct { 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 @@ -434,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 @@ -448,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) } } diff --git a/internal/plugins/worktree/shell.go b/internal/plugins/worktree/shell.go index 0ae0de01..2508f25c 100644 --- a/internal/plugins/worktree/shell.go +++ b/internal/plugins/worktree/shell.go @@ -12,6 +12,7 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" + "github.com/marcus/sidecar/internal/state" ) // Shell session constants @@ -105,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. @@ -217,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 { diff --git a/internal/state/state.go b/internal/state/state.go index 012ec23e..ca230e63 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -9,7 +9,7 @@ import ( // State holds persistent user preferences. type State struct { - GitDiffMode string `json:"gitDiffMode"` // "unified" or "side-by-side" + GitDiffMode string `json:"gitDiffMode"` // "unified" or "side-by-side" WorktreeDiffMode string `json:"worktreeDiffMode,omitempty"` // "unified" or "side-by-side" GitGraphEnabled bool `json:"gitGraphEnabled,omitempty"` // Show commit graph in sidebar @@ -27,20 +27,21 @@ type State struct { // FileBrowserState holds persistent file browser state. type FileBrowserState struct { - SelectedFile string `json:"selectedFile,omitempty"` // Currently selected file path (relative) - TreeScroll int `json:"treeScroll,omitempty"` // Tree pane scroll offset - PreviewScroll int `json:"previewScroll,omitempty"` // Preview pane scroll offset - ExpandedDirs []string `json:"expandedDirs,omitempty"` // List of expanded directory paths - ActivePane string `json:"activePane,omitempty"` // "tree" or "preview" - PreviewFile string `json:"previewFile,omitempty"` // File being previewed (relative) - TreeCursor int `json:"treeCursor,omitempty"` // Tree cursor position - ShowIgnored *bool `json:"showIgnored,omitempty"` // Whether to show git-ignored files (nil = default true) + SelectedFile string `json:"selectedFile,omitempty"` // Currently selected file path (relative) + TreeScroll int `json:"treeScroll,omitempty"` // Tree pane scroll offset + PreviewScroll int `json:"previewScroll,omitempty"` // Preview pane scroll offset + ExpandedDirs []string `json:"expandedDirs,omitempty"` // List of expanded directory paths + ActivePane string `json:"activePane,omitempty"` // "tree" or "preview" + PreviewFile string `json:"previewFile,omitempty"` // File being previewed (relative) + TreeCursor int `json:"treeCursor,omitempty"` // Tree cursor position + ShowIgnored *bool `json:"showIgnored,omitempty"` // Whether to show git-ignored files (nil = default true) } // WorktreeState holds persistent worktree plugin state. type WorktreeState struct { - WorktreeName string `json:"worktreeName,omitempty"` // Name of selected worktree - ShellTmuxName string `json:"shellTmuxName,omitempty"` // TmuxName of selected shell (empty = worktree selected) + WorktreeName string `json:"worktreeName,omitempty"` // Name of selected worktree + ShellTmuxName string `json:"shellTmuxName,omitempty"` // TmuxName of selected shell (empty = worktree selected) + ShellDisplayNames map[string]string `json:"shellDisplayNames,omitempty"` // TmuxName -> display name } var ( diff --git a/internal/state/state_test.go b/internal/state/state_test.go index 4f6f31c0..6d7fc26d 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -402,7 +402,7 @@ func TestGetWorktreeState_Default(t *testing.T) { current = nil state := GetWorktreeState("/path/to/project") - if state.WorktreeName != "" || state.ShellTmuxName != "" { + if state.WorktreeName != "" || state.ShellTmuxName != "" || len(state.ShellDisplayNames) > 0 { t.Errorf("GetWorktreeState() with nil current should return empty state") } } @@ -413,7 +413,7 @@ func TestGetWorktreeState_EmptyMap(t *testing.T) { current = &State{Worktree: nil} state := GetWorktreeState("/path/to/project") - if state.WorktreeName != "" || state.ShellTmuxName != "" { + if state.WorktreeName != "" || state.ShellTmuxName != "" || len(state.ShellDisplayNames) > 0 { t.Errorf("GetWorktreeState() with nil map should return empty state") } } @@ -427,6 +427,9 @@ func TestGetWorktreeState_Found(t *testing.T) { "/path/to/project": { WorktreeName: "feature-branch", ShellTmuxName: "sidecar-sh-project-1", + ShellDisplayNames: map[string]string{ + "sidecar-sh-project-1": "Backend", + }, }, }, } @@ -437,6 +440,9 @@ func TestGetWorktreeState_Found(t *testing.T) { if state.ShellTmuxName != "sidecar-sh-project-1" { t.Errorf("ShellTmuxName = %q, want sidecar-sh-project-1", state.ShellTmuxName) } + if state.ShellDisplayNames["sidecar-sh-project-1"] != "Backend" { + t.Errorf("ShellDisplayNames[sidecar-sh-project-1] = %q, want Backend", state.ShellDisplayNames["sidecar-sh-project-1"]) + } } func TestSetWorktreeState(t *testing.T) { @@ -455,6 +461,9 @@ func TestSetWorktreeState(t *testing.T) { wtState := WorktreeState{ WorktreeName: "my-worktree", ShellTmuxName: "", + ShellDisplayNames: map[string]string{ + "sidecar-sh-project-1": "Backend", + }, } err := SetWorktreeState("/projects/sidecar", wtState) @@ -467,6 +476,9 @@ func TestSetWorktreeState(t *testing.T) { if stored.WorktreeName != "my-worktree" { t.Errorf("stored WorktreeName = %q, want my-worktree", stored.WorktreeName) } + if stored.ShellDisplayNames["sidecar-sh-project-1"] != "Backend" { + t.Errorf("stored ShellDisplayNames[sidecar-sh-project-1] = %q, want Backend", stored.ShellDisplayNames["sidecar-sh-project-1"]) + } // Verify saved to disk data, _ := os.ReadFile(stateFile) @@ -475,6 +487,9 @@ func TestSetWorktreeState(t *testing.T) { if loaded.Worktree["/projects/sidecar"].WorktreeName != "my-worktree" { t.Errorf("persisted WorktreeName = %q, want my-worktree", loaded.Worktree["/projects/sidecar"].WorktreeName) } + if loaded.Worktree["/projects/sidecar"].ShellDisplayNames["sidecar-sh-project-1"] != "Backend" { + t.Errorf("persisted ShellDisplayNames[sidecar-sh-project-1] = %q, want Backend", loaded.Worktree["/projects/sidecar"].ShellDisplayNames["sidecar-sh-project-1"]) + } } func TestSetWorktreeState_ShellSelection(t *testing.T) {