From 767edf8de5d4555c620efe30c3667f4b3074f155 Mon Sep 17 00:00:00 2001 From: Fabian Urbanek Date: Mon, 23 Feb 2026 15:38:37 +0100 Subject: [PATCH 01/15] fix: wire A key for automations through GlobalKeyStringsMap The handleDefaultKeys function returns early for any key not in GlobalKeyStringsMap, making the previous default-case A check dead code. Add KeyAutomations constant, register "A" in the map, and use a proper case in the switch. --- app/app_input.go | 9 +++------ keys/keys.go | 7 +++++++ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/app/app_input.go b/app/app_input.go index 805ce91..833bc4f 100644 --- a/app/app_input.go +++ b/app/app_input.go @@ -1641,13 +1641,10 @@ func (m *home) handleDefaultKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case keys.KeyMemoryBrowser: return m.openMemoryBrowser() + case keys.KeyAutomations: + m.state = stateAutomations + return m, nil default: - // Check raw key string for features without a named key binding. - switch msg.String() { - case "A": - m.state = stateAutomations - return m, nil - } return m, nil } } diff --git a/keys/keys.go b/keys/keys.go index aa37c3d..5d4a5e4 100644 --- a/keys/keys.go +++ b/keys/keys.go @@ -62,6 +62,8 @@ const ( KeyCommandPalette // Key for opening command palette KeyMemoryBrowser // Key for opening the memory file browser + KeyAutomations // Key for opening the automations manager + // Diff keybindings KeyShiftUp KeyShiftDown @@ -111,6 +113,7 @@ var GlobalKeyStringsMap = map[string]KeyName{ "y": KeyAutoYes, "ctrl+p": KeyCommandPalette, "M": KeyMemoryBrowser, + "A": KeyAutomations, } // GlobalkeyBindings is a global, immutable map of KeyName tot keybinding. @@ -263,6 +266,10 @@ var GlobalkeyBindings = map[KeyName]key.Binding{ key.WithKeys("M"), key.WithHelp("M", "memory"), ), + KeyAutomations: key.NewBinding( + key.WithKeys("A"), + key.WithHelp("A", "automations"), + ), // -- Special keybindings -- From b07a853222b8bb4f88ec80fc4aacc33dd6bafea0 Mon Sep 17 00:00:00 2001 From: Fabian Urbanek Date: Mon, 23 Feb 2026 15:41:33 +0100 Subject: [PATCH 02/15] fix: render automations manager as modal overlay instead of full-screen --- app/app.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/app.go b/app/app.go index d056f36..cd587c0 100644 --- a/app/app.go +++ b/app/app.go @@ -741,11 +741,15 @@ func (m *home) View() string { case m.state == stateSkillPicker && m.pickerOverlay != nil: result = overlay.PlaceOverlay(0, 0, m.pickerOverlay.Render(), mainView, true, true) case m.state == stateAutomations || m.state == stateNewAutomation: - mainView = ui.RenderAutomationsList(m.automations, m.autoSelectedIdx, m.width) + modalWidth := m.width - 4 + if modalWidth > 76 { + modalWidth = 76 + } + autoView := ui.RenderAutomationsList(m.automations, m.autoSelectedIdx, modalWidth) if m.textInputOverlay != nil { - mainView = overlay.PlaceOverlay(0, 0, m.textInputOverlay.Render(), mainView, true, true) + autoView = overlay.PlaceOverlay(0, 0, m.textInputOverlay.Render(), autoView, true, true) } - result = mainView + result = overlay.PlaceOverlay(0, 0, autoView, mainView, true, true) case m.state == stateMemoryBrowser && m.memoryBrowser != nil: result = m.memoryBrowser.Render() case m.state == stateContextMenu && m.contextMenu != nil: From 15d3bfd069ec751ed515c8feff88d52d4e9e2374 Mon Sep 17 00:00:00 2001 From: Fabian Urbanek Date: Mon, 23 Feb 2026 15:45:20 +0100 Subject: [PATCH 03/15] fix: make automations modal fill most of the screen with proper layout --- app/app.go | 8 ++-- ui/automations_list.go | 89 +++++++++++++++++++++++++++++++++--------- 2 files changed, 74 insertions(+), 23 deletions(-) diff --git a/app/app.go b/app/app.go index cd587c0..460dcae 100644 --- a/app/app.go +++ b/app/app.go @@ -189,6 +189,7 @@ type home struct { listWidth int // full allocation including gaps columnGap int // gap on each side of the instance list contentHeight int + height int width int // full terminal width // Automations @@ -388,6 +389,7 @@ func (m *home) updateHandleWindowSizeEvent(msg tea.WindowSizeMsg) { m.columnGap = columnGap m.contentHeight = contentHeight m.width = msg.Width + m.height = msg.Height if m.textInputOverlay != nil { m.textInputOverlay.SetSize(int(float32(msg.Width)*0.6), int(float32(msg.Height)*0.4)) @@ -741,11 +743,7 @@ func (m *home) View() string { case m.state == stateSkillPicker && m.pickerOverlay != nil: result = overlay.PlaceOverlay(0, 0, m.pickerOverlay.Render(), mainView, true, true) case m.state == stateAutomations || m.state == stateNewAutomation: - modalWidth := m.width - 4 - if modalWidth > 76 { - modalWidth = 76 - } - autoView := ui.RenderAutomationsList(m.automations, m.autoSelectedIdx, modalWidth) + autoView := ui.RenderAutomationsList(m.automations, m.autoSelectedIdx, m.width-4, m.height-4) if m.textInputOverlay != nil { autoView = overlay.PlaceOverlay(0, 0, m.textInputOverlay.Render(), autoView, true, true) } diff --git a/ui/automations_list.go b/ui/automations_list.go index 657d76c..71851b1 100644 --- a/ui/automations_list.go +++ b/ui/automations_list.go @@ -34,26 +34,63 @@ var ( Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("#F0A868")). Padding(1, 2) + + autoColumnHeaderStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#F0A868")). + Underline(true) + + autoDividerStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#444444")) ) -// RenderAutomationsList renders the automations manager screen as a table. -func RenderAutomationsList(automations []*config.Automation, selectedIdx int, width int) string { +// RenderAutomationsList renders the automations manager as a large modal. +func RenderAutomationsList(automations []*config.Automation, selectedIdx int, width int, height int) string { + borderFrame := autoBorderStyle.GetHorizontalFrameSize() + innerWidth := width - borderFrame + if innerWidth < 20 { + innerWidth = 20 + } + var sb strings.Builder - sb.WriteString(autoHeaderStyle.Render("Automations") + "\n") - sb.WriteString(autoHintStyle.Render("n: new e: toggle r: run now d: delete esc: back") + "\n\n") + // Header + sb.WriteString(autoHeaderStyle.Render("⚡ Automations") + "\n") + sb.WriteString(autoHintStyle.Render("n new e toggle r run now d delete esc close") + "\n") + sb.WriteString(autoDividerStyle.Render(strings.Repeat("─", innerWidth)) + "\n\n") if len(automations) == 0 { - sb.WriteString(autoDisabledStyle.Render("No automations yet. Press 'n' to create one.")) - return autoBorderStyle.Width(width - 4).Render(sb.String()) + empty := autoDisabledStyle.Render("No automations yet. Press 'n' to create one.\n\nAutomations let you schedule recurring agent tasks — e.g. run a daily\ncode review, sync documentation, or monitor for regressions.") + sb.WriteString(empty) + } else { + // Column header + col := renderColumnHeader(innerWidth) + sb.WriteString(col + "\n") + sb.WriteString(autoDividerStyle.Render(strings.Repeat("─", innerWidth)) + "\n") + + for i, auto := range automations { + row := renderAutomationRow(auto, i == selectedIdx, innerWidth) + sb.WriteString(row + "\n") + } } - for i, auto := range automations { - row := renderAutomationRow(auto, i == selectedIdx, width) - sb.WriteString(row + "\n") + vertFrame := autoBorderStyle.GetVerticalFrameSize() + innerHeight := height - vertFrame + if innerHeight < 5 { + innerHeight = 5 } - return autoBorderStyle.Width(width - 4).Render(sb.String()) + return autoBorderStyle.Width(innerWidth).Height(innerHeight).Render(sb.String()) +} + +func renderColumnHeader(width int) string { + name := fmt.Sprintf("%-28s", "NAME") + schedule := fmt.Sprintf("%-16s", "SCHEDULE") + nextRun := fmt.Sprintf("%-12s", "NEXT RUN") + status := "STATUS" + row := fmt.Sprintf(" %s %s %s %s", name, schedule, nextRun, status) + _ = width + return autoColumnHeaderStyle.Render(row) } // renderAutomationRow renders a single automation row. @@ -69,22 +106,38 @@ func renderAutomationRow(auto *config.Automation, selected bool, width int) stri } nextRunStr := formatNextRun(auto.NextRun) - scheduleStr := auto.Schedule - // Truncate name if needed. name := auto.Name - maxNameLen := 24 - if len(name) > maxNameLen { - name = name[:maxNameLen-1] + "…" + if len(name) > 26 { + name = name[:25] + "…" } + schedule := auto.Schedule + if len(schedule) > 14 { + schedule = schedule[:13] + "…" + } + + row := fmt.Sprintf(" %s %-27s %-16s %-12s %s", + enabledMark, name, schedule, nextRunStr, + enabledText(auto.Enabled)) - row := fmt.Sprintf(" %s %-26s %-14s next: %s", - enabledMark, name, scheduleStr, nextRunStr) + if selected { + // Pad to full width so the highlight bar spans the row + visLen := len([]rune(row)) + if visLen < width { + row += strings.Repeat(" ", width-visLen) + } + } - _ = width // width reserved for future truncation return rowStyle.Render(row) } +func enabledText(enabled bool) string { + if enabled { + return lipgloss.NewStyle().Foreground(lipgloss.Color("#22c55e")).Render("enabled") + } + return lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Render("disabled") +} + // formatNextRun formats the NextRun time into a human-readable relative string. func formatNextRun(t time.Time) string { if t.IsZero() { From 05936d6ca28ebe49877f6b96b5c057a0d29185a7 Mon Sep 17 00:00:00 2001 From: Fabian Urbanek Date: Mon, 23 Feb 2026 15:51:34 +0100 Subject: [PATCH 04/15] feat: replace 3-step automation wizard with single inline form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the consecutive text-input modal chain (name → instructions → schedule) with a single AutomationForm that shows all three fields at once inside the automations modal. Tab/Shift+Tab moves between fields, Ctrl+S saves, Esc cancels. Also adds edit support: press 'e' on a selected automation to open the same form pre-populated. Toggle is now 't' to free up 'e' for edit. --- app/app.go | 7 +- app/app_input.go | 98 ++++++++++----------- ui/automation_form.go | 190 +++++++++++++++++++++++++++++++++++++++++ ui/automations_list.go | 26 ++++-- 4 files changed, 256 insertions(+), 65 deletions(-) create mode 100644 ui/automation_form.go diff --git a/app/app.go b/app/app.go index 460dcae..27fee2e 100644 --- a/app/app.go +++ b/app/app.go @@ -195,8 +195,9 @@ type home struct { // Automations automations []*config.Automation autoSelectedIdx int - autoCreating *config.Automation - autoCreateStep int + autoForm *ui.AutomationForm + autoEditIdx int // -1 = new, >=0 = index of automation being edited + // embeddedTerminal is the VT emulator for focus mode (nil when not in focus mode) embeddedTerminal *session.EmbeddedTerminal @@ -743,7 +744,7 @@ func (m *home) View() string { case m.state == stateSkillPicker && m.pickerOverlay != nil: result = overlay.PlaceOverlay(0, 0, m.pickerOverlay.Render(), mainView, true, true) case m.state == stateAutomations || m.state == stateNewAutomation: - autoView := ui.RenderAutomationsList(m.automations, m.autoSelectedIdx, m.width-4, m.height-4) + autoView := ui.RenderAutomationsList(m.automations, m.autoSelectedIdx, m.width-4, m.height-4, m.autoForm) if m.textInputOverlay != nil { autoView = overlay.PlaceOverlay(0, 0, m.textInputOverlay.Render(), autoView, true, true) } diff --git a/app/app_input.go b/app/app_input.go index 833bc4f..f283e49 100644 --- a/app/app_input.go +++ b/app/app_input.go @@ -1978,14 +1978,23 @@ func (m *home) handleAutomationsKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case "n": - // Start creation flow: step 0 = name. - m.autoCreateStep = 0 - m.textInputOverlay = overlay.NewTextInputOverlay("Automation name", "") - m.textInputOverlay.SetSize(60, 10) + m.autoForm = ui.NewAutomationForm("", "", "", false) + m.autoEditIdx = -1 m.state = stateNewAutomation return m, nil case "e": + // Open edit form for selected automation. + if len(m.automations) == 0 { + return m, nil + } + auto := m.automations[m.autoSelectedIdx] + m.autoForm = ui.NewAutomationForm(auto.Name, auto.Schedule, auto.Instructions, true) + m.autoEditIdx = m.autoSelectedIdx + m.state = stateNewAutomation + return m, nil + + case "t": // Toggle enabled/disabled on selected automation. if len(m.automations) == 0 { return m, nil @@ -2045,65 +2054,50 @@ func (m *home) handleAutomationsKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } -// handleNewAutomationKeys handles key events in the new-automation creation flow. -// Steps: 0=name, 1=instructions, 2=schedule. +// handleNewAutomationKeys handles key events in the inline create/edit form. func (m *home) handleNewAutomationKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - if m.textInputOverlay == nil { + if m.autoForm == nil { m.state = stateAutomations return m, nil } - done := m.textInputOverlay.HandleKeyPress(msg) - if m.textInputOverlay.Canceled { - m.textInputOverlay = nil - m.autoCreating = nil - m.state = stateAutomations - return m, nil - } + done := m.autoForm.HandleKey(msg) if !done { return m, nil } - value := m.textInputOverlay.GetValue() - m.textInputOverlay = nil - - switch m.autoCreateStep { - case 0: // name captured - if value == "" { - m.toastManager.Error("Automation name cannot be empty") - // Retry - m.textInputOverlay = overlay.NewTextInputOverlay("Automation name", "") - m.textInputOverlay.SetSize(60, 10) - return m, m.toastTickCmd() - } - m.autoCreating = &config.Automation{Name: value} - m.autoCreateStep = 1 - m.textInputOverlay = overlay.NewTextInputOverlay("Instructions (prompt for the agent)", "") - m.textInputOverlay.SetSize(60, 15) + if m.autoForm.Canceled { + m.autoForm = nil + m.state = stateAutomations return m, nil + } - case 1: // instructions captured - m.autoCreating.Instructions = value - m.autoCreateStep = 2 - m.textInputOverlay = overlay.NewTextInputOverlay("Schedule (e.g. hourly, daily, every 4h, @06:00)", "") - m.textInputOverlay.SetSize(60, 10) - return m, nil + // Submitted — validate and save. + name, schedule, instructions := m.autoForm.GetValues() + if name == "" { + m.toastManager.Error("Name cannot be empty") + return m, m.toastTickCmd() + } + if _, err := config.ParseSchedule(schedule); err != nil { + m.toastManager.Error("Invalid schedule: " + err.Error()) + return m, m.toastTickCmd() + } - case 2: // schedule captured — validate - schedule := value - _, err := config.ParseSchedule(schedule) - if err != nil { - m.toastManager.Error("Invalid schedule: " + err.Error()) - // Retry schedule step - m.textInputOverlay = overlay.NewTextInputOverlay("Schedule (e.g. hourly, daily, every 4h, @06:00)", schedule) - m.textInputOverlay.SetSize(60, 10) - return m, m.toastTickCmd() + if m.autoEditIdx >= 0 && m.autoEditIdx < len(m.automations) { + // Editing existing automation. + auto := m.automations[m.autoEditIdx] + auto.Name = name + auto.Schedule = schedule + auto.Instructions = instructions + if err := config.SaveAutomations(m.automations); err != nil { + return m, m.handleError(err) } - auto, err := config.NewAutomation(m.autoCreating.Name, m.autoCreating.Instructions, schedule) + m.toastManager.Info(fmt.Sprintf("Automation %q updated", name)) + } else { + // Creating new automation. + auto, err := config.NewAutomation(name, instructions, schedule) if err != nil { m.toastManager.Error("Failed to create automation: " + err.Error()) - m.state = stateAutomations - m.autoCreating = nil return m, m.toastTickCmd() } m.automations = append(m.automations, auto) @@ -2111,14 +2105,12 @@ func (m *home) handleNewAutomationKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if err := config.SaveAutomations(m.automations); err != nil { return m, m.handleError(err) } - m.autoCreating = nil - m.state = stateAutomations - m.toastManager.Info(fmt.Sprintf("Automation %q created", auto.Name)) - return m, m.toastTickCmd() + m.toastManager.Info(fmt.Sprintf("Automation %q created", name)) } + m.autoForm = nil m.state = stateAutomations - return m, nil + return m, m.toastTickCmd() } func (m *home) handleMemoryBrowserKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { diff --git a/ui/automation_form.go b/ui/automation_form.go new file mode 100644 index 0000000..40b3ad1 --- /dev/null +++ b/ui/automation_form.go @@ -0,0 +1,190 @@ +package ui + +import ( + "strings" + + "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + formLabelStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#F0A868")) + + formLabelDimStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#888888")) + + formHintStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#555555")). + Italic(true) + + formActiveFieldStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#F0A868")). + Padding(0, 1) + + formInactiveFieldStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#444444")). + Padding(0, 1) +) + +// AutomationForm is an inline multi-field form for creating or editing automations. +// All three fields are visible and editable in a single screen. +type AutomationForm struct { + nameInput textinput.Model + scheduleInput textinput.Model + instrArea textarea.Model + focusedField int // 0=name, 1=schedule, 2=instructions + Submitted bool + Canceled bool + IsEditing bool +} + +// NewAutomationForm creates a form pre-populated with the given values. +// Set isEditing=false for a new automation (empty values), true for edit. +func NewAutomationForm(name, schedule, instructions string, isEditing bool) *AutomationForm { + ni := textinput.New() + ni.Placeholder = "e.g. Daily code review" + ni.SetValue(name) + ni.CharLimit = 64 + ni.Focus() + + si := textinput.New() + si.Placeholder = "e.g. daily, every 4h, @06:00" + si.SetValue(schedule) + si.CharLimit = 32 + + ta := textarea.New() + ta.Placeholder = "Prompt sent to the agent when this automation runs…" + ta.SetValue(instructions) + ta.ShowLineNumbers = false + ta.Prompt = "" + ta.FocusedStyle.CursorLine = lipgloss.NewStyle() + ta.CharLimit = 0 + ta.MaxHeight = 0 + ta.Blur() + + return &AutomationForm{ + nameInput: ni, + scheduleInput: si, + instrArea: ta, + focusedField: 0, + IsEditing: isEditing, + } +} + +// HandleKey processes a key event. Returns true when the form should close. +func (f *AutomationForm) HandleKey(msg tea.KeyMsg) bool { + switch msg.String() { + case "ctrl+s", "ctrl+d": + f.Submitted = true + return true + case "esc": + f.Canceled = true + return true + case "tab": + f.setFocus((f.focusedField + 1) % 3) + return false + case "shift+tab": + f.setFocus((f.focusedField + 2) % 3) + return false + } + + // Forward keystroke to the active field. + switch f.focusedField { + case 0: + f.nameInput, _ = f.nameInput.Update(msg) + case 1: + f.scheduleInput, _ = f.scheduleInput.Update(msg) + case 2: + f.instrArea, _ = f.instrArea.Update(msg) + } + return false +} + +func (f *AutomationForm) setFocus(idx int) { + f.focusedField = idx + if idx == 0 { + f.nameInput.Focus() + f.scheduleInput.Blur() + f.instrArea.Blur() + } else if idx == 1 { + f.nameInput.Blur() + f.scheduleInput.Focus() + f.instrArea.Blur() + } else { + f.nameInput.Blur() + f.scheduleInput.Blur() + f.instrArea.Focus() + } +} + +// GetValues returns the current field values. +func (f *AutomationForm) GetValues() (name, schedule, instructions string) { + return strings.TrimSpace(f.nameInput.Value()), + strings.TrimSpace(f.scheduleInput.Value()), + strings.TrimSpace(f.instrArea.Value()) +} + +// Render draws the form at the given inner width and height. +func (f *AutomationForm) Render(width, height int) string { + title := "New Automation" + if f.IsEditing { + title = "Edit Automation" + } + + fieldW := width - 4 // account for field border padding + if fieldW < 20 { + fieldW = 20 + } + + f.nameInput.Width = fieldW + f.scheduleInput.Width = fieldW + f.instrArea.SetWidth(fieldW) + instrLines := height - 14 // remaining lines for instructions textarea + if instrLines < 3 { + instrLines = 3 + } + f.instrArea.SetHeight(instrLines) + + var sb strings.Builder + + sb.WriteString(autoHeaderStyle.Render("⚡ "+title) + "\n") + sb.WriteString(autoDividerStyle.Render(strings.Repeat("─", width)) + "\n\n") + + // Name field + sb.WriteString(f.fieldLabel("Name", f.focusedField == 0) + "\n") + sb.WriteString(f.wrapField(f.nameInput.View(), f.focusedField == 0, fieldW) + "\n\n") + + // Schedule field + sb.WriteString(f.fieldLabel("Schedule", f.focusedField == 1) + "\n") + sb.WriteString(formHintStyle.Render(" hourly · daily · weekly · every 4h · every 30m · @06:00") + "\n") + sb.WriteString(f.wrapField(f.scheduleInput.View(), f.focusedField == 1, fieldW) + "\n\n") + + // Instructions field + sb.WriteString(f.fieldLabel("Instructions", f.focusedField == 2) + "\n") + sb.WriteString(f.wrapField(f.instrArea.View(), f.focusedField == 2, fieldW) + "\n\n") + + sb.WriteString(autoHintStyle.Render("tab next field shift+tab prev ctrl+s save esc cancel")) + + return sb.String() +} + +func (f *AutomationForm) fieldLabel(label string, active bool) string { + if active { + return formLabelStyle.Render(" " + label) + } + return formLabelDimStyle.Render(" " + label) +} + +func (f *AutomationForm) wrapField(content string, active bool, width int) string { + style := formInactiveFieldStyle.Width(width) + if active { + style = formActiveFieldStyle.Width(width) + } + return style.Render(content) +} diff --git a/ui/automations_list.go b/ui/automations_list.go index 71851b1..cce4aa1 100644 --- a/ui/automations_list.go +++ b/ui/automations_list.go @@ -45,25 +45,39 @@ var ( ) // RenderAutomationsList renders the automations manager as a large modal. -func RenderAutomationsList(automations []*config.Automation, selectedIdx int, width int, height int) string { +// When form is non-nil the create/edit form is shown instead of the list. +func RenderAutomationsList(automations []*config.Automation, selectedIdx int, width int, height int, form *AutomationForm) string { borderFrame := autoBorderStyle.GetHorizontalFrameSize() + vertFrame := autoBorderStyle.GetVerticalFrameSize() innerWidth := width - borderFrame + innerHeight := height - vertFrame if innerWidth < 20 { innerWidth = 20 } + if innerHeight < 5 { + innerHeight = 5 + } + + if form != nil { + content := form.Render(innerWidth, innerHeight) + return autoBorderStyle.Width(innerWidth).Height(innerHeight).Render(content) + } + return renderList(automations, selectedIdx, innerWidth, innerHeight) +} + +func renderList(automations []*config.Automation, selectedIdx int, innerWidth, innerHeight int) string { var sb strings.Builder // Header sb.WriteString(autoHeaderStyle.Render("⚡ Automations") + "\n") - sb.WriteString(autoHintStyle.Render("n new e toggle r run now d delete esc close") + "\n") + sb.WriteString(autoHintStyle.Render("n new e edit t toggle r run now d delete esc close") + "\n") sb.WriteString(autoDividerStyle.Render(strings.Repeat("─", innerWidth)) + "\n\n") if len(automations) == 0 { empty := autoDisabledStyle.Render("No automations yet. Press 'n' to create one.\n\nAutomations let you schedule recurring agent tasks — e.g. run a daily\ncode review, sync documentation, or monitor for regressions.") sb.WriteString(empty) } else { - // Column header col := renderColumnHeader(innerWidth) sb.WriteString(col + "\n") sb.WriteString(autoDividerStyle.Render(strings.Repeat("─", innerWidth)) + "\n") @@ -74,12 +88,6 @@ func RenderAutomationsList(automations []*config.Automation, selectedIdx int, wi } } - vertFrame := autoBorderStyle.GetVerticalFrameSize() - innerHeight := height - vertFrame - if innerHeight < 5 { - innerHeight = 5 - } - return autoBorderStyle.Width(innerWidth).Height(innerHeight).Render(sb.String()) } From ff4502beea2a10fb4a4ecd536e752396afdb77df Mon Sep 17 00:00:00 2001 From: Fabian Urbanek Date: Mon, 23 Feb 2026 16:49:21 +0100 Subject: [PATCH 05/15] docs: add topic worktree mode design doc --- .../2026-02-23-topic-worktree-mode-design.md | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 docs/plans/2026-02-23-topic-worktree-mode-design.md diff --git a/docs/plans/2026-02-23-topic-worktree-mode-design.md b/docs/plans/2026-02-23-topic-worktree-mode-design.md new file mode 100644 index 0000000..f3474b3 --- /dev/null +++ b/docs/plans/2026-02-23-topic-worktree-mode-design.md @@ -0,0 +1,113 @@ +# Topic Worktree Mode Design + +**Date:** 2026-02-23 +**Branch:** fabian.urbanek/worktree-manageent + +## Problem + +Topics currently support two worktree modes, expressed as `SharedWorktree bool`: + +- `false` (default) — each instance in the topic creates its own git worktree and branch +- `true` — all instances share one git worktree and branch + +There is no way to run instances directly in the main repository directory without any worktree. This is useful when the user wants agents to work on the current branch as-is, without creating new branches or directories. + +## Solution + +Replace `SharedWorktree bool` with a `TopicWorktreeMode` string enum with three values: + +| Mode | Value | Behaviour | +|------|-------|-----------| +| Per-instance worktrees | `"per_instance"` | Each instance gets its own branch + worktree directory (existing default) | +| Shared worktree | `"shared"` | All instances share one branch + worktree directory (existing "yes" option) | +| Main repo | `"main_repo"` | Instances run directly in the repo directory; no worktree, no new branch | + +## Architecture + +### Data model changes + +**`session/topic.go`** +- Add `TopicWorktreeMode` string type with three constants +- Replace `SharedWorktree bool` with `WorktreeMode TopicWorktreeMode` in `Topic` and `TopicOptions` +- Helper methods: `IsSharedWorktree() bool`, `IsMainRepo() bool` +- `Setup()`: only create git worktree when `WorktreeMode == TopicWorktreeModeShared` + +**`session/topic_storage.go`** +- `TopicData.WorktreeMode` (new, JSON: `"worktree_mode"`) +- `TopicData.SharedWorktree` (legacy, JSON: `"shared_worktree"`) — kept as read-only migration field +- `FromTopicData`: if `worktree_mode` is empty, derive from `shared_worktree` (true → `shared`, false → `per_instance`) +- `ToTopicData`: write `worktree_mode` only; drop `shared_worktree` from new output + +### Instance lifecycle changes + +**`session/instance.go`** +- Add `mainRepo bool` field (not persisted; re-derived at start time from topic mode) +- Add `GetWorkingPath() string`: returns `gitWorktree.GetWorktreePath()` when a worktree exists, else `i.Path` + +**`session/instance_lifecycle.go`** +- New `StartInMainRepo()`: skips git worktree creation, starts tmux session at `i.Path`, sets `mainRepo = true` +- `Kill()`: no worktree cleanup when `gitWorktree == nil` +- `Pause()`: skip worktree commit/remove when `mainRepo == true`; only detach tmux +- `Resume()`: skip worktree setup when `mainRepo == true`; just restart tmux at `i.Path` + +**`session/instance_session.go`** +- `GetGitWorktree()` already returns `(nil, nil)` when unset — callers updated to use `GetWorkingPath()` + +### App layer changes + +**`app/app_input.go`** +- `handleNewTopicConfirmKeys`: replace `ConfirmationOverlay` (Y/N) with `PickerOverlay` showing three mode options +- All `topic.SharedWorktree` checks → `topic.IsSharedWorktree()` +- Instance start dispatch: add `MainRepo` branch calling `StartInMainRepo()` +- Move prevention: only block moves for `IsSharedWorktree()` topics + +**`app/app_actions.go`** +- Context menu "Push branch": gate on `topic.IsSharedWorktree()` + +**`app/app_brain.go`** +- Instance start dispatch: add `MainRepo` branch calling `StartInMainRepo()` + +**`app/app_state.go`** +- `topicMeta()`: build `WorktreeMode` map instead of `shared` bool map; pass to sidebar +- Callers of `selected.GetGitWorktree()` → `selected.GetWorkingPath()` + +### UI changes + +**`ui/sidebar.go`** +- `SidebarItem.SharedWorktree bool` → `SidebarItem.WorktreeMode session.TopicWorktreeMode` +- Keep `\ue727` icon for `Shared` topics; no icon for `PerInstance` and `MainRepo` +- `SetItems` / `SetGroupedItems`: accept mode map instead of bool map + +## UX flow + +``` +[T] New topic + ↓ +Enter topic name + ↓ +Picker: "Worktree mode for ''" + ▸ Per-instance worktrees (each agent gets its own branch + directory) + Shared worktree (all agents share one branch + directory) + Main repo (no worktree) (agents work directly in the repo) + ↓ +Topic created +``` + +## Backward compatibility + +Existing stored topics (`~/.hivemind/topics.json`) with `"shared_worktree": true` load as `Shared`, `false` loads as `PerInstance`. No manual migration required. + +## Files changed + +| File | Change | +|------|--------| +| `session/topic.go` | Add enum, replace bool, update `Setup()`, add helpers | +| `session/topic_storage.go` | Add `worktree_mode` field, migration shim in `FromTopicData` | +| `session/instance.go` | Add `mainRepo bool`, add `GetWorkingPath()` | +| `session/instance_lifecycle.go` | Add `StartInMainRepo()`, update Pause/Resume/Kill | +| `session/instance_session.go` | Update callers of `GetGitWorktree()` where relevant | +| `app/app_input.go` | Replace Y/N confirm with picker, update all mode checks | +| `app/app_actions.go` | Update `SharedWorktree` check | +| `app/app_brain.go` | Add `MainRepo` dispatch branch | +| `app/app_state.go` | Update `topicMeta()`, replace `GetGitWorktree()` callers | +| `ui/sidebar.go` | Replace `SharedWorktree bool` with `WorktreeMode` | From e59acf5294544f95a45fcafdd700c518d2b11328 Mon Sep 17 00:00:00 2001 From: Fabian Urbanek Date: Mon, 23 Feb 2026 16:52:53 +0100 Subject: [PATCH 06/15] docs: add topic worktree mode implementation plan --- ...2-23-topic-worktree-mode-implementation.md | 1040 +++++++++++++++++ 1 file changed, 1040 insertions(+) create mode 100644 docs/plans/2026-02-23-topic-worktree-mode-implementation.md diff --git a/docs/plans/2026-02-23-topic-worktree-mode-implementation.md b/docs/plans/2026-02-23-topic-worktree-mode-implementation.md new file mode 100644 index 0000000..ad663d5 --- /dev/null +++ b/docs/plans/2026-02-23-topic-worktree-mode-implementation.md @@ -0,0 +1,1040 @@ +# Topic Worktree Mode Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace `SharedWorktree bool` on `Topic` with a `TopicWorktreeMode` enum (`per_instance`, `shared`, `main_repo`) and wire it through the full stack so users pick a mode from a 3-item picker when creating a topic. + +**Architecture:** Add the enum type and helpers in `session/topic.go`, add a backward-compat JSON migration shim in `session/topic_storage.go`, add `StartInMainRepo()` + `GetWorkingPath()` to the instance lifecycle, then update the app layer (input handler, action dispatchers, state helpers) to use the new mode everywhere `SharedWorktree` was used. + +**Tech Stack:** Go, Bubble Tea (TUI), tmux, git worktrees. + +--- + +### Task 1: Add `TopicWorktreeMode` enum to `session/topic.go` + +**Files:** +- Modify: `session/topic.go` +- Create: `session/topic_test.go` + +**Step 1: Write the failing test** + +Create `session/topic_test.go`: + +```go +package session + +import ( + "testing" +) + +func TestTopicWorktreeMode_Helpers(t *testing.T) { + tests := []struct { + mode TopicWorktreeMode + wantShared bool + wantMain bool + }{ + {TopicWorktreeModePerInstance, false, false}, + {TopicWorktreeModeShared, true, false}, + {TopicWorktreeModeMainRepo, false, true}, + } + for _, tc := range tests { + topic := &Topic{WorktreeMode: tc.mode} + if got := topic.IsSharedWorktree(); got != tc.wantShared { + t.Errorf("mode %q IsSharedWorktree(): got %v, want %v", tc.mode, got, tc.wantShared) + } + if got := topic.IsMainRepo(); got != tc.wantMain { + t.Errorf("mode %q IsMainRepo(): got %v, want %v", tc.mode, got, tc.wantMain) + } + } +} + +func TestNewTopic_DefaultMode(t *testing.T) { + topic := NewTopic(TopicOptions{Name: "t", Path: "/repo"}) + if topic.WorktreeMode != TopicWorktreeModePerInstance { + t.Errorf("default WorktreeMode: got %q, want %q", topic.WorktreeMode, TopicWorktreeModePerInstance) + } +} +``` + +**Step 2: Run test to verify it fails** + +```bash +go test ./session/ -run TestTopicWorktreeMode -v +``` +Expected: compile error — `TopicWorktreeMode`, `WorktreeMode`, `IsSharedWorktree`, `IsMainRepo` undefined. + +**Step 3: Implement in `session/topic.go`** + +Replace the current `Topic` struct and related code. The full replacement for the file: + +```go +package session + +import ( + "fmt" + "time" + + "github.com/ByteMirror/hivemind/session/git" +) + +// TopicTask is a single item in the topic's todo list. +type TopicTask struct { + ID string `json:"id"` + Text string `json:"text"` + Done bool `json:"done"` +} + +// NewTopicTask creates a TopicTask with a unique ID generated from the current time. +func NewTopicTask(text string) TopicTask { + return TopicTask{ + ID: fmt.Sprintf("%d", time.Now().UnixNano()), + Text: text, + } +} + +// TopicWorktreeMode controls how instances in a topic interact with git. +type TopicWorktreeMode string + +const ( + // TopicWorktreeModePerInstance gives each instance its own branch + worktree directory. + TopicWorktreeModePerInstance TopicWorktreeMode = "per_instance" + // TopicWorktreeModeShared makes all instances share one branch + worktree directory. + TopicWorktreeModeShared TopicWorktreeMode = "shared" + // TopicWorktreeModeMainRepo runs instances directly in the repo directory with no worktree. + TopicWorktreeModeMainRepo TopicWorktreeMode = "main_repo" +) + +// Topic groups related instances, optionally sharing a single git worktree. +type Topic struct { + Name string + WorktreeMode TopicWorktreeMode + AutoYes bool + Branch string + Path string + CreatedAt time.Time + Notes string + Tasks []TopicTask + gitWorktree *git.GitWorktree + started bool +} + +// IsSharedWorktree reports whether all instances in this topic share one worktree. +func (t *Topic) IsSharedWorktree() bool { + return t.WorktreeMode == TopicWorktreeModeShared +} + +// IsMainRepo reports whether instances in this topic run directly in the repo directory. +func (t *Topic) IsMainRepo() bool { + return t.WorktreeMode == TopicWorktreeModeMainRepo +} + +type TopicOptions struct { + Name string + WorktreeMode TopicWorktreeMode + Path string +} + +func NewTopic(opts TopicOptions) *Topic { + mode := opts.WorktreeMode + if mode == "" { + mode = TopicWorktreeModePerInstance + } + return &Topic{ + Name: opts.Name, + WorktreeMode: mode, + Path: opts.Path, + CreatedAt: time.Now(), + } +} + +func (t *Topic) Setup() error { + if t.WorktreeMode != TopicWorktreeModeShared { + t.started = true + return nil + } + gitWorktree, branchName, err := git.NewGitWorktree(t.Path, t.Name) + if err != nil { + return fmt.Errorf("failed to create topic worktree: %w", err) + } + if err := gitWorktree.Setup(); err != nil { + return fmt.Errorf("failed to setup topic worktree: %w", err) + } + t.gitWorktree = gitWorktree + t.Branch = branchName + t.started = true + return nil +} + +func (t *Topic) GetWorktreePath() string { + if t.gitWorktree == nil { + return "" + } + return t.gitWorktree.GetWorktreePath() +} + +func (t *Topic) GetGitWorktree() *git.GitWorktree { + return t.gitWorktree +} + +func (t *Topic) Started() bool { + return t.started +} + +func (t *Topic) Cleanup() error { + if t.gitWorktree == nil { + return nil + } + return t.gitWorktree.Cleanup() +} +``` + +**Step 4: Run test to verify it passes** + +```bash +go test ./session/ -run TestTopicWorktreeMode -v +go test ./session/ -run TestNewTopic_DefaultMode -v +``` +Expected: PASS both. + +**Step 5: Verify nothing else broke** + +```bash +go build ./... +``` +Expected: compile errors in `topic_storage.go`, `app/app_input.go`, etc. — that's expected and will be fixed in subsequent tasks. + +**Step 6: Commit** + +```bash +git add session/topic.go session/topic_test.go +git commit -m "feat: add TopicWorktreeMode enum replacing SharedWorktree bool" +``` + +--- + +### Task 2: Storage migration shim in `session/topic_storage.go` + +**Files:** +- Modify: `session/topic_storage.go` +- Create: `session/topic_storage_test.go` + +**Step 1: Write the failing tests** + +Create `session/topic_storage_test.go`: + +```go +package session + +import ( + "testing" + "time" +) + +func TestFromTopicData_MigratesLegacySharedWorktree(t *testing.T) { + // Old JSON had shared_worktree:true — should become Shared mode + data := TopicData{ + Name: "my-topic", + SharedWorktree: true, + Path: "/repo", + CreatedAt: time.Now(), + } + topic := FromTopicData(data) + if topic.WorktreeMode != TopicWorktreeModeShared { + t.Errorf("legacy shared_worktree:true → got %q, want %q", topic.WorktreeMode, TopicWorktreeModeShared) + } +} + +func TestFromTopicData_MigratesLegacyNonShared(t *testing.T) { + // Old JSON had shared_worktree:false — should become PerInstance mode + data := TopicData{ + Name: "my-topic", + SharedWorktree: false, + Path: "/repo", + CreatedAt: time.Now(), + } + topic := FromTopicData(data) + if topic.WorktreeMode != TopicWorktreeModePerInstance { + t.Errorf("legacy shared_worktree:false → got %q, want %q", topic.WorktreeMode, TopicWorktreeModePerInstance) + } +} + +func TestFromTopicData_UsesExplicitWorktreeMode(t *testing.T) { + // New JSON has worktree_mode set — should be used directly, ignoring shared_worktree + data := TopicData{ + Name: "my-topic", + WorktreeMode: TopicWorktreeModeMainRepo, + Path: "/repo", + CreatedAt: time.Now(), + } + topic := FromTopicData(data) + if topic.WorktreeMode != TopicWorktreeModeMainRepo { + t.Errorf("explicit worktree_mode → got %q, want %q", topic.WorktreeMode, TopicWorktreeModeMainRepo) + } +} + +func TestToTopicData_RoundTrip(t *testing.T) { + original := NewTopic(TopicOptions{ + Name: "round-trip", + WorktreeMode: TopicWorktreeModeMainRepo, + Path: "/repo", + }) + data := original.ToTopicData() + if data.WorktreeMode != TopicWorktreeModeMainRepo { + t.Errorf("ToTopicData WorktreeMode: got %q, want %q", data.WorktreeMode, TopicWorktreeModeMainRepo) + } + restored := FromTopicData(data) + if restored.WorktreeMode != TopicWorktreeModeMainRepo { + t.Errorf("round-trip WorktreeMode: got %q, want %q", restored.WorktreeMode, TopicWorktreeModeMainRepo) + } +} +``` + +**Step 2: Run to verify they fail** + +```bash +go test ./session/ -run TestFromTopicData -v +go test ./session/ -run TestToTopicData -v +``` +Expected: compile errors — `WorktreeMode` field missing from `TopicData`. + +**Step 3: Replace `session/topic_storage.go`** + +```go +package session + +import ( + "time" + + "github.com/ByteMirror/hivemind/session/git" +) + +// TopicData represents the serializable data of a Topic. +type TopicData struct { + Name string `json:"name"` + WorktreeMode TopicWorktreeMode `json:"worktree_mode,omitempty"` + // SharedWorktree is the legacy field kept for reading old JSON files. + // New topics write worktree_mode instead. FromTopicData migrates this automatically. + SharedWorktree bool `json:"shared_worktree"` + AutoYes bool `json:"auto_yes"` + Branch string `json:"branch,omitempty"` + Path string `json:"path"` + CreatedAt time.Time `json:"created_at"` + Worktree GitWorktreeData `json:"worktree,omitempty"` + Notes string `json:"notes,omitempty"` + Tasks []TopicTask `json:"tasks,omitempty"` +} + +// ToTopicData converts a Topic to its serializable form. +func (t *Topic) ToTopicData() TopicData { + data := TopicData{ + Name: t.Name, + WorktreeMode: t.WorktreeMode, + AutoYes: t.AutoYes, + Branch: t.Branch, + Path: t.Path, + CreatedAt: t.CreatedAt, + Notes: t.Notes, + Tasks: t.Tasks, + } + if t.gitWorktree != nil { + data.Worktree = GitWorktreeData{ + RepoPath: t.gitWorktree.GetRepoPath(), + WorktreePath: t.gitWorktree.GetWorktreePath(), + SessionName: t.Name, + BranchName: t.gitWorktree.GetBranchName(), + BaseCommitSHA: t.gitWorktree.GetBaseCommitSHA(), + } + } + return data +} + +// FromTopicData creates a Topic from serialized data. +// It migrates the legacy shared_worktree bool field when worktree_mode is absent. +func FromTopicData(data TopicData) *Topic { + mode := data.WorktreeMode + if mode == "" { + // Migrate legacy bool field + if data.SharedWorktree { + mode = TopicWorktreeModeShared + } else { + mode = TopicWorktreeModePerInstance + } + } + + topic := &Topic{ + Name: data.Name, + WorktreeMode: mode, + AutoYes: data.AutoYes, + Branch: data.Branch, + Path: data.Path, + CreatedAt: data.CreatedAt, + Notes: data.Notes, + Tasks: data.Tasks, + started: true, + } + if mode == TopicWorktreeModeShared && data.Worktree.WorktreePath != "" { + topic.gitWorktree = git.NewGitWorktreeFromStorage( + data.Worktree.RepoPath, + data.Worktree.WorktreePath, + data.Worktree.SessionName, + data.Worktree.BranchName, + data.Worktree.BaseCommitSHA, + ) + } + return topic +} +``` + +**Step 4: Run tests** + +```bash +go test ./session/ -run TestFromTopicData -v +go test ./session/ -run TestToTopicData -v +``` +Expected: all PASS. + +**Step 5: Run all session tests** + +```bash +go test ./session/... +``` +Expected: all pass. + +**Step 6: Commit** + +```bash +git add session/topic_storage.go session/topic_storage_test.go +git commit -m "feat: add worktree_mode field to TopicData with legacy migration shim" +``` + +--- + +### Task 3: Add `GetWorkingPath()` and `mainRepo` to Instance + +**Files:** +- Modify: `session/instance.go` (add `mainRepo bool` field) +- Modify: `session/instance_session.go` (add `GetWorkingPath()`) +- Modify: `session/instance_test.go` (add test) + +**Step 1: Write the failing test** + +Add to `session/instance_test.go`: + +```go +func TestInstance_GetWorkingPath(t *testing.T) { + t.Run("returns repo path when no worktree", func(t *testing.T) { + inst := &Instance{Path: "/my/repo"} + inst.started.Store(true) + if got := inst.GetWorkingPath(); got != "/my/repo" { + t.Errorf("got %q, want /my/repo", got) + } + }) +} +``` + +**Step 2: Verify it fails** + +```bash +go test ./session/ -run TestInstance_GetWorkingPath -v +``` +Expected: compile error — `GetWorkingPath` undefined. + +**Step 3: Add field and method** + +In `session/instance.go`, add `mainRepo bool` alongside `sharedWorktree bool`: +```go +// mainRepo is true if this instance runs directly in the repo directory (no worktree). +mainRepo bool +``` + +In `session/instance_session.go`, add after `GetGitWorktree()`: +```go +// GetWorkingPath returns the working directory for this instance. +// For instances with a git worktree, this is the worktree path. +// For main-repo instances, this is the repo path. +func (i *Instance) GetWorkingPath() string { + if i.gitWorktree != nil { + return i.gitWorktree.GetWorktreePath() + } + return i.Path +} +``` + +**Step 4: Run tests** + +```bash +go test ./session/ -run TestInstance_GetWorkingPath -v +``` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add session/instance.go session/instance_session.go session/instance_test.go +git commit -m "feat: add mainRepo flag and GetWorkingPath() to Instance" +``` + +--- + +### Task 4: Add `StartInMainRepo()` and update `Pause()`/`Resume()` + +**Files:** +- Modify: `session/instance_lifecycle.go` + +No unit tests for this task — lifecycle methods depend on tmux and are integration-level. Build verification and manual test cover it. + +**Step 1: Add `StartInMainRepo()` method** + +Add after `StartInSharedWorktree()` in `session/instance_lifecycle.go`: + +```go +// StartInMainRepo starts the instance directly in the repository directory. +// Unlike Start(), this does NOT create a git worktree or a new branch. +func (i *Instance) StartInMainRepo() error { + if i.Title == "" { + return ErrTitleEmpty + } + + i.LoadingTotal = 5 + i.LoadingMessage = "Initializing..." + i.setLoadingProgress(1, "Preparing session...") + + i.mainRepo = true + + var tmuxSession *tmux.TmuxSession + if i.tmuxSession != nil { + tmuxSession = i.tmuxSession + } else { + tmuxSession = tmux.NewTmuxSession(i.Title, i.Program, i.SkipPermissions) + } + tmuxSession.ProgressFunc = func(stage int, desc string) { + i.setLoadingProgress(1+stage, desc) + } + i.tmuxSession = tmuxSession + + if isClaudeProgram(i.Program) { + repoPath := i.Path + title := i.Title + go func() { + if err := registerMCPServer(repoPath, repoPath, title); err != nil { + log.WarningLog.Printf("failed to write MCP config: %v", err) + } + }() + } + + var setupErr error + defer func() { + if setupErr != nil { + if cleanupErr := i.Kill(); cleanupErr != nil { + setupErr = fmt.Errorf("%v (cleanup error: %v)", setupErr, cleanupErr) + } + } else { + i.started.Store(true) + } + }() + + i.setLoadingProgress(3, "Starting tmux session...") + if err := i.tmuxSession.Start(i.Path); err != nil { + setupErr = fmt.Errorf("failed to start session in main repo: %w", err) + return setupErr + } + + i.SetStatus(Running) + return nil +} +``` + +**Step 2: Guard `Pause()` against nil worktree** + +In `Pause()`, the existing `!i.sharedWorktree` guard needs a `!i.mainRepo` guard too. Find this block: + +```go + if !i.sharedWorktree { + // Check if there are any changes to commit + if dirty, err := i.gitWorktree.IsDirty(); err != nil { +``` + +Change the guard to: + +```go + if !i.sharedWorktree && !i.mainRepo { + // Check if there are any changes to commit + if dirty, err := i.gitWorktree.IsDirty(); err != nil { +``` + +Also find the second `!i.sharedWorktree` block in `Pause()`: + +```go + if !i.sharedWorktree { + // Check if worktree exists before trying to remove it + if _, err := os.Stat(i.gitWorktree.GetWorktreePath()); err == nil { +``` + +Change to: + +```go + if !i.sharedWorktree && !i.mainRepo { + // Check if worktree exists before trying to remove it + if _, err := os.Stat(i.gitWorktree.GetWorktreePath()); err == nil { +``` + +Also fix the `clipboard.WriteAll` call at the end of `Pause()`: + +```go + _ = clipboard.WriteAll(i.gitWorktree.GetBranchName()) +``` + +Wrap it: + +```go + if i.gitWorktree != nil { + _ = clipboard.WriteAll(i.gitWorktree.GetBranchName()) + } +``` + +**Step 3: Guard `Resume()` against nil worktree** + +At the top of `Resume()`, add an early return for main-repo instances since there's no worktree to restore: + +```go +func (i *Instance) Resume() error { + if !i.started.Load() { + return ErrInstanceNotStarted + } + if i.Status != Paused && i.Status != Loading { + return fmt.Errorf("can only resume paused instances") + } + + i.tmuxDead.Store(false) + + // Main-repo instances have no worktree to set up; just restart the tmux session. + if i.mainRepo { + i.LoadingTotal = 2 + i.setLoadingProgress(1, "Restoring session...") + if i.tmuxSession.DoesSessionExist() { + if err := i.tmuxSession.Restore(); err != nil { + if err := i.tmuxSession.Start(i.Path); err != nil { + return fmt.Errorf("failed to restart main-repo session: %w", err) + } + } + } else { + if err := i.tmuxSession.Start(i.Path); err != nil { + return fmt.Errorf("failed to restart main-repo session: %w", err) + } + } + i.setLoadingProgress(2, "Ready") + i.SetStatus(Running) + return nil + } + + // ... rest of existing Resume() code unchanged ... +``` + +**Step 4: Build to verify** + +```bash +go build ./... +``` +Expected: compile errors only in `app/` (still references `SharedWorktree`). No errors in `session/`. + +**Step 5: Commit** + +```bash +git add session/instance_lifecycle.go +git commit -m "feat: add StartInMainRepo() and guard Pause/Resume against nil worktree" +``` + +--- + +### Task 5: Update `app/app_state.go` + +**Files:** +- Modify: `app/app_state.go` + +Two changes: +1. `topicMeta()` — use `IsSharedWorktree()` instead of `.SharedWorktree` +2. All `selected.GetGitWorktree()` callers — switch to `selected.GetWorkingPath()` + +**Step 1: Update `topicMeta()`** + +Find: +```go + if t.SharedWorktree { + shared[t.Name] = true + } +``` + +Replace with: +```go + if t.IsSharedWorktree() { + shared[t.Name] = true + } +``` + +**Step 2: Replace `GetGitWorktree()` callers with `GetWorkingPath()`** + +There are five locations. Each has the pattern: +```go +worktree, err := selected.GetGitWorktree() +if err != nil { + return m.handleError(err) +} +// ... then uses worktree.GetWorktreePath() +``` + +Replace each with a direct call using `GetWorkingPath()`. For example: + +In `enterGitFocusMode()` (~line 171): +```go +// BEFORE: +worktree, err := selected.GetGitWorktree() +if err != nil { + return m.handleError(err) +} +gitPane.Attach(worktree.GetWorktreePath(), selected.Title) + +// AFTER: +gitPane.Attach(selected.GetWorkingPath(), selected.Title) +``` + +In `enterTerminalFocusMode()` (~line 197): +```go +// BEFORE: +worktree, err := selected.GetGitWorktree() +if err != nil { + return m.handleError(err) +} +termPane.Attach(worktree.GetWorktreePath(), selected.Title) + +// AFTER: +termPane.Attach(selected.GetWorkingPath(), selected.Title) +``` + +In `openFileInTerminal()` (~line 231): +```go +// BEFORE: +worktree, err := selected.GetGitWorktree() +if err != nil { + return m, m.handleError(err) +} +fullPath := filepath.Join(worktree.GetWorktreePath(), relativePath) +// ... +termPane.Attach(worktree.GetWorktreePath(), selected.Title) + +// AFTER: +workingPath := selected.GetWorkingPath() +fullPath := filepath.Join(workingPath, relativePath) +// ... +termPane.Attach(workingPath, selected.Title) +``` + +In `attachGitTab()` (~line 851): +```go +// BEFORE: +worktree, err := selected.GetGitWorktree() +if err != nil { + return m.handleError(err) +} +gitPane.Attach(worktree.GetWorktreePath(), selected.Title) + +// AFTER: +gitPane.Attach(selected.GetWorkingPath(), selected.Title) +``` + +In `spawnTerminalTab()` (~line 869): +```go +// BEFORE: +worktree, err := selected.GetGitWorktree() +if err != nil { + return m.handleError(err) +} +termPane.Attach(worktree.GetWorktreePath(), selected.Title) + +// AFTER: +termPane.Attach(selected.GetWorkingPath(), selected.Title) +``` + +**Step 3: Build** + +```bash +go build ./... +``` +Expected: remaining compile errors only in `app/app_input.go`, `app/app_actions.go`, `app/app_brain.go`. + +**Step 4: Commit** + +```bash +git add app/app_state.go +git commit -m "refactor: use IsSharedWorktree() and GetWorkingPath() in app_state" +``` + +--- + +### Task 6: Update topic creation UI in `app/app_input.go` + +**Files:** +- Modify: `app/app_input.go` + +Four changes: +1. `handleNewTopicKeys` — replace `ConfirmationOverlay` with `PickerOverlay` +2. `handleNewTopicConfirmKeys` — replace Y/N logic with picker value → mode mapping +3. Instance start dispatch (`~line 488`) — add `MainRepo` branch +4. `SharedWorktree` checks → `IsSharedWorktree()` + +**Step 1: Update `handleNewTopicKeys`** + +Find: +```go + // Show shared worktree confirmation + m.textInputOverlay = nil + m.confirmationOverlay = overlay.NewConfirmationOverlay( + fmt.Sprintf("Create shared worktree for topic '%s'?\nAll instances will share one branch and directory.", m.pendingTopicName), + ) + m.confirmationOverlay.SetWidth(60) + m.state = stateNewTopicConfirm + return m, nil +``` + +Replace with: +```go + // Show worktree mode picker + m.textInputOverlay = nil + m.pickerOverlay = overlay.NewPickerOverlay( + fmt.Sprintf("Worktree mode for '%s'", m.pendingTopicName), + []string{ + "Per-instance worktrees", + "Shared worktree", + "Main repo (no worktree)", + }, + ) + m.state = stateNewTopicConfirm + return m, nil +``` + +**Step 2: Replace `handleNewTopicConfirmKeys`** + +Replace the entire function: + +```go +func (m *home) handleNewTopicConfirmKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.pickerOverlay == nil { + m.state = stateDefault + return m, nil + } + shouldClose := m.pickerOverlay.HandleKeyPress(msg) + if !shouldClose { + return m, nil + } + + if !m.pickerOverlay.IsSubmitted() { + // Cancelled + m.pickerOverlay = nil + m.pendingTopicName = "" + m.pendingTopicRepoPath = "" + m.state = stateDefault + m.menu.SetState(ui.StateDefault) + return m, tea.WindowSize() + } + + var mode session.TopicWorktreeMode + switch m.pickerOverlay.Value() { + case "Shared worktree": + mode = session.TopicWorktreeModeShared + case "Main repo (no worktree)": + mode = session.TopicWorktreeModeMainRepo + default: + mode = session.TopicWorktreeModePerInstance + } + m.pickerOverlay = nil + + topicRepoPath := m.pendingTopicRepoPath + if topicRepoPath == "" { + topicRepoPath = m.activeRepoPaths[0] + } + topic := session.NewTopic(session.TopicOptions{ + Name: m.pendingTopicName, + WorktreeMode: mode, + Path: topicRepoPath, + }) + if err := topic.Setup(); err != nil { + m.pendingTopicName = "" + m.state = stateDefault + m.menu.SetState(ui.StateDefault) + return m, m.handleError(err) + } + m.allTopics = append(m.allTopics, topic) + m.topics = append(m.topics, topic) + m.updateSidebarItems() + if err := m.saveAllTopics(); err != nil { + return m, m.handleError(err) + } + m.pendingTopicName = "" + m.pendingTopicRepoPath = "" + m.state = stateDefault + m.menu.SetState(ui.StateDefault) + return m, tea.WindowSize() +} +``` + +**Step 3: Update instance start dispatch (~line 488)** + +Find: +```go + startCmd := func() tea.Msg { + var startErr error + if topic != nil && topic.SharedWorktree && topic.Started() { + startErr = instance.StartInSharedWorktree(topic.GetGitWorktree(), topic.Branch) + } else { + startErr = instance.Start(true) + } +``` + +Replace with: +```go + startCmd := func() tea.Msg { + var startErr error + switch { + case topic != nil && topic.IsSharedWorktree() && topic.Started(): + startErr = instance.StartInSharedWorktree(topic.GetGitWorktree(), topic.Branch) + case topic != nil && topic.IsMainRepo(): + startErr = instance.StartInMainRepo() + default: + startErr = instance.Start(true) + } +``` + +**Step 4: Update remaining `SharedWorktree` checks in this file** + +Find (context menu, ~line 267): +```go + if topic.SharedWorktree { + items = append(items, overlay.ContextMenuItem{Label: "Push branch", Action: "push_topic"}) + } +``` +Replace with: +```go + if topic.IsSharedWorktree() { + items = append(items, overlay.ContextMenuItem{Label: "Push branch", Action: "push_topic"}) + } +``` + +Find (move prevention, ~line 1554): +```go + if t.Name == selected.TopicName && t.SharedWorktree { + return m, m.handleError(fmt.Errorf("cannot move instances in shared-worktree topics")) +``` +Replace with: +```go + if t.Name == selected.TopicName && t.IsSharedWorktree() { + return m, m.handleError(fmt.Errorf("cannot move instances in shared-worktree topics")) +``` + +**Step 5: Build** + +```bash +go build ./... +``` +Expected: errors only in `app/app_actions.go` and `app/app_brain.go`. + +**Step 6: Commit** + +```bash +git add app/app_input.go +git commit -m "feat: replace Y/N worktree confirm with 3-way mode picker, wire MainRepo dispatch" +``` + +--- + +### Task 7: Update `app/app_actions.go` and `app/app_brain.go` + +**Files:** +- Modify: `app/app_actions.go` +- Modify: `app/app_brain.go` + +**Step 1: `app/app_actions.go`** + +Find (~line 302): +```go + if topic.SharedWorktree { + items = append(items, overlay.ContextMenuItem{Label: "Push branch", Action: "push_topic"}) + } +``` +Replace with: +```go + if topic.IsSharedWorktree() { + items = append(items, overlay.ContextMenuItem{Label: "Push branch", Action: "push_topic"}) + } +``` + +**Step 2: `app/app_brain.go`** + +Find (~line 134): +```go + if topicObj != nil && topicObj.SharedWorktree && topicObj.Started() { + startErr = instance.StartInSharedWorktree(topicObj.GetGitWorktree(), topicObj.Branch) + } else { + startErr = instance.Start(true) + } +``` +Replace with: +```go + switch { + case topicObj != nil && topicObj.IsSharedWorktree() && topicObj.Started(): + startErr = instance.StartInSharedWorktree(topicObj.GetGitWorktree(), topicObj.Branch) + case topicObj != nil && topicObj.IsMainRepo(): + startErr = instance.StartInMainRepo() + default: + startErr = instance.Start(true) + } +``` + +**Step 3: Build everything cleanly** + +```bash +go build ./... +``` +Expected: zero errors. + +**Step 4: Run all tests** + +```bash +go test ./... +``` +Expected: all pass. + +**Step 5: Commit** + +```bash +git add app/app_actions.go app/app_brain.go +git commit -m "refactor: update remaining SharedWorktree refs in actions and brain" +``` + +--- + +### Task 8: Final verification + +**Step 1: Full build + test** + +```bash +go build ./... && go test ./... && go vet ./... +``` +Expected: zero errors, zero failures, zero vet warnings. + +**Step 2: Manual smoke test** + +1. Run hivemind: `go run . --path /tmp/test-repo` (or any git repo) +2. Press `T` to create a new topic +3. Enter a topic name, press Enter +4. Verify the picker shows three options: "Per-instance worktrees", "Shared worktree", "Main repo (no worktree)" +5. Select "Main repo (no worktree)", press Enter — topic created +6. Press `N` to create an instance in that topic +7. Verify the instance starts in the repo directory (not a worktree path) +8. Repeat for "Per-instance worktrees" and "Shared worktree" to confirm existing behaviour unchanged +9. Kill hivemind, re-launch — verify topics survive restart with correct mode (migration works) + +**Step 3: Final commit** + +```bash +git add -A +git commit -m "chore: final verification pass for topic worktree mode feature" +``` From 004c990e01619adcb1f7d917a84481f4c7d45c49 Mon Sep 17 00:00:00 2001 From: Fabian Urbanek Date: Mon, 23 Feb 2026 17:01:49 +0100 Subject: [PATCH 07/15] feat: add TopicWorktreeMode enum replacing SharedWorktree bool Introduces TopicWorktreeMode string enum (per_instance, shared, main_repo) on Topic, with IsSharedWorktree/IsMainRepo helpers and a default of per_instance in NewTopic. Updates topic_storage.go to serialise WorktreeMode and migrate legacy SharedWorktree bool on read. --- session/topic.go | 62 ++++++++++++++++++++++++++++------------ session/topic_storage.go | 52 ++++++++++++++++++++------------- session/topic_test.go | 33 +++++++++++++++++++++ 3 files changed, 109 insertions(+), 38 deletions(-) create mode 100644 session/topic_test.go diff --git a/session/topic.go b/session/topic.go index a00f387..f2a8dc5 100644 --- a/session/topic.go +++ b/session/topic.go @@ -22,37 +22,63 @@ func NewTopicTask(text string) TopicTask { } } +// TopicWorktreeMode controls how instances in a topic interact with git. +type TopicWorktreeMode string + +const ( + // TopicWorktreeModePerInstance gives each instance its own branch + worktree directory. + TopicWorktreeModePerInstance TopicWorktreeMode = "per_instance" + // TopicWorktreeModeShared makes all instances share one branch + worktree directory. + TopicWorktreeModeShared TopicWorktreeMode = "shared" + // TopicWorktreeModeMainRepo runs instances directly in the repo directory with no worktree. + TopicWorktreeModeMainRepo TopicWorktreeMode = "main_repo" +) + // Topic groups related instances, optionally sharing a single git worktree. type Topic struct { - Name string - SharedWorktree bool - AutoYes bool - Branch string - Path string - CreatedAt time.Time - Notes string - Tasks []TopicTask - gitWorktree *git.GitWorktree - started bool + Name string + WorktreeMode TopicWorktreeMode + AutoYes bool + Branch string + Path string + CreatedAt time.Time + Notes string + Tasks []TopicTask + gitWorktree *git.GitWorktree + started bool +} + +// IsSharedWorktree reports whether all instances in this topic share one worktree. +func (t *Topic) IsSharedWorktree() bool { + return t.WorktreeMode == TopicWorktreeModeShared +} + +// IsMainRepo reports whether instances in this topic run directly in the repo directory. +func (t *Topic) IsMainRepo() bool { + return t.WorktreeMode == TopicWorktreeModeMainRepo } type TopicOptions struct { - Name string - SharedWorktree bool - Path string + Name string + WorktreeMode TopicWorktreeMode + Path string } func NewTopic(opts TopicOptions) *Topic { + mode := opts.WorktreeMode + if mode == "" { + mode = TopicWorktreeModePerInstance + } return &Topic{ - Name: opts.Name, - SharedWorktree: opts.SharedWorktree, - Path: opts.Path, - CreatedAt: time.Now(), + Name: opts.Name, + WorktreeMode: mode, + Path: opts.Path, + CreatedAt: time.Now(), } } func (t *Topic) Setup() error { - if !t.SharedWorktree { + if t.WorktreeMode != TopicWorktreeModeShared { t.started = true return nil } diff --git a/session/topic_storage.go b/session/topic_storage.go index 2533dcc..3b67f43 100644 --- a/session/topic_storage.go +++ b/session/topic_storage.go @@ -8,8 +8,11 @@ import ( // TopicData represents the serializable data of a Topic. type TopicData struct { - Name string `json:"name"` - SharedWorktree bool `json:"shared_worktree"` + Name string `json:"name"` + WorktreeMode TopicWorktreeMode `json:"worktree_mode,omitempty"` + // SharedWorktree is kept for backwards-compatible JSON deserialization. + // New writes use WorktreeMode instead. + SharedWorktree bool `json:"shared_worktree,omitempty"` AutoYes bool `json:"auto_yes"` Branch string `json:"branch,omitempty"` Path string `json:"path"` @@ -22,14 +25,14 @@ type TopicData struct { // ToTopicData converts a Topic to its serializable form. func (t *Topic) ToTopicData() TopicData { data := TopicData{ - Name: t.Name, - SharedWorktree: t.SharedWorktree, - AutoYes: t.AutoYes, - Branch: t.Branch, - Path: t.Path, - CreatedAt: t.CreatedAt, - Notes: t.Notes, - Tasks: t.Tasks, + Name: t.Name, + WorktreeMode: t.WorktreeMode, + AutoYes: t.AutoYes, + Branch: t.Branch, + Path: t.Path, + CreatedAt: t.CreatedAt, + Notes: t.Notes, + Tasks: t.Tasks, } if t.gitWorktree != nil { data.Worktree = GitWorktreeData{ @@ -45,18 +48,27 @@ func (t *Topic) ToTopicData() TopicData { // FromTopicData creates a Topic from serialized data. func FromTopicData(data TopicData) *Topic { + // Migrate legacy SharedWorktree bool to WorktreeMode. + mode := data.WorktreeMode + if mode == "" { + if data.SharedWorktree { + mode = TopicWorktreeModeShared + } else { + mode = TopicWorktreeModePerInstance + } + } topic := &Topic{ - Name: data.Name, - SharedWorktree: data.SharedWorktree, - AutoYes: data.AutoYes, - Branch: data.Branch, - Path: data.Path, - CreatedAt: data.CreatedAt, - Notes: data.Notes, - Tasks: data.Tasks, - started: true, + Name: data.Name, + WorktreeMode: mode, + AutoYes: data.AutoYes, + Branch: data.Branch, + Path: data.Path, + CreatedAt: data.CreatedAt, + Notes: data.Notes, + Tasks: data.Tasks, + started: true, } - if data.SharedWorktree && data.Worktree.WorktreePath != "" { + if mode == TopicWorktreeModeShared && data.Worktree.WorktreePath != "" { topic.gitWorktree = git.NewGitWorktreeFromStorage( data.Worktree.RepoPath, data.Worktree.WorktreePath, diff --git a/session/topic_test.go b/session/topic_test.go new file mode 100644 index 0000000..75f6e7a --- /dev/null +++ b/session/topic_test.go @@ -0,0 +1,33 @@ +package session + +import ( + "testing" +) + +func TestTopicWorktreeMode_Helpers(t *testing.T) { + tests := []struct { + mode TopicWorktreeMode + wantShared bool + wantMain bool + }{ + {TopicWorktreeModePerInstance, false, false}, + {TopicWorktreeModeShared, true, false}, + {TopicWorktreeModeMainRepo, false, true}, + } + for _, tc := range tests { + topic := &Topic{WorktreeMode: tc.mode} + if got := topic.IsSharedWorktree(); got != tc.wantShared { + t.Errorf("mode %q IsSharedWorktree(): got %v, want %v", tc.mode, got, tc.wantShared) + } + if got := topic.IsMainRepo(); got != tc.wantMain { + t.Errorf("mode %q IsMainRepo(): got %v, want %v", tc.mode, got, tc.wantMain) + } + } +} + +func TestNewTopic_DefaultMode(t *testing.T) { + topic := NewTopic(TopicOptions{Name: "t", Path: "/repo"}) + if topic.WorktreeMode != TopicWorktreeModePerInstance { + t.Errorf("default WorktreeMode: got %q, want %q", topic.WorktreeMode, TopicWorktreeModePerInstance) + } +} From 93bc5c8bd3a9552b97002551c9a99fed11ac1ae0 Mon Sep 17 00:00:00 2001 From: Fabian Urbanek Date: Mon, 23 Feb 2026 17:05:23 +0100 Subject: [PATCH 08/15] test: add topic_storage migration and round-trip tests --- session/topic_storage_test.go | 64 +++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 session/topic_storage_test.go diff --git a/session/topic_storage_test.go b/session/topic_storage_test.go new file mode 100644 index 0000000..ba1008f --- /dev/null +++ b/session/topic_storage_test.go @@ -0,0 +1,64 @@ +package session + +import ( + "testing" + "time" +) + +func TestFromTopicData_MigratesLegacySharedWorktree(t *testing.T) { + // Old JSON had shared_worktree:true — should become Shared mode + data := TopicData{ + Name: "my-topic", + SharedWorktree: true, + Path: "/repo", + CreatedAt: time.Now(), + } + topic := FromTopicData(data) + if topic.WorktreeMode != TopicWorktreeModeShared { + t.Errorf("legacy shared_worktree:true → got %q, want %q", topic.WorktreeMode, TopicWorktreeModeShared) + } +} + +func TestFromTopicData_MigratesLegacyNonShared(t *testing.T) { + // Old JSON had shared_worktree:false — should become PerInstance mode + data := TopicData{ + Name: "my-topic", + SharedWorktree: false, + Path: "/repo", + CreatedAt: time.Now(), + } + topic := FromTopicData(data) + if topic.WorktreeMode != TopicWorktreeModePerInstance { + t.Errorf("legacy shared_worktree:false → got %q, want %q", topic.WorktreeMode, TopicWorktreeModePerInstance) + } +} + +func TestFromTopicData_UsesExplicitWorktreeMode(t *testing.T) { + // New JSON has worktree_mode set — should be used directly, ignoring shared_worktree + data := TopicData{ + Name: "my-topic", + WorktreeMode: TopicWorktreeModeMainRepo, + Path: "/repo", + CreatedAt: time.Now(), + } + topic := FromTopicData(data) + if topic.WorktreeMode != TopicWorktreeModeMainRepo { + t.Errorf("explicit worktree_mode → got %q, want %q", topic.WorktreeMode, TopicWorktreeModeMainRepo) + } +} + +func TestToTopicData_RoundTrip(t *testing.T) { + original := NewTopic(TopicOptions{ + Name: "round-trip", + WorktreeMode: TopicWorktreeModeMainRepo, + Path: "/repo", + }) + data := original.ToTopicData() + if data.WorktreeMode != TopicWorktreeModeMainRepo { + t.Errorf("ToTopicData WorktreeMode: got %q, want %q", data.WorktreeMode, TopicWorktreeModeMainRepo) + } + restored := FromTopicData(data) + if restored.WorktreeMode != TopicWorktreeModeMainRepo { + t.Errorf("round-trip WorktreeMode: got %q, want %q", restored.WorktreeMode, TopicWorktreeModeMainRepo) + } +} From e80bb85faba95794be91710e2055af83ad367762 Mon Sep 17 00:00:00 2001 From: Fabian Urbanek Date: Mon, 23 Feb 2026 17:06:29 +0100 Subject: [PATCH 09/15] feat: add mainRepo flag and GetWorkingPath() to Instance --- session/instance.go | 2 ++ session/instance_session.go | 10 ++++++++++ session/instance_test.go | 10 ++++++++++ 3 files changed, 22 insertions(+) diff --git a/session/instance.go b/session/instance.go index 8dd1186..4222ecf 100644 --- a/session/instance.go +++ b/session/instance.go @@ -81,6 +81,8 @@ type Instance struct { // sharedWorktree is true if this instance uses a topic's shared worktree (should not clean it up). sharedWorktree bool + // mainRepo is true if this instance runs directly in the repo directory (no worktree). + mainRepo bool // LoadingStage tracks the current startup progress. Exported so the UI can read it. LoadingStage int // LoadingTotal is the total number of startup stages. diff --git a/session/instance_session.go b/session/instance_session.go index c07dcc9..b09d8a5 100644 --- a/session/instance_session.go +++ b/session/instance_session.go @@ -184,6 +184,16 @@ func (i *Instance) GetGitWorktree() (*git.GitWorktree, error) { return i.gitWorktree, nil } +// GetWorkingPath returns the working directory for this instance. +// For instances with a git worktree, this is the worktree path. +// For main-repo instances, this is the repo path (i.Path). +func (i *Instance) GetWorkingPath() string { + if i.gitWorktree != nil { + return i.gitWorktree.GetWorktreePath() + } + return i.Path +} + // SendPrompt sends a prompt to the tmux session func (i *Instance) SendPrompt(prompt string) error { if !i.started.Load() { diff --git a/session/instance_test.go b/session/instance_test.go index c47b30a..bcfee56 100644 --- a/session/instance_test.go +++ b/session/instance_test.go @@ -64,3 +64,13 @@ func TestSetStatus_NoReviewForManualInstance(t *testing.T) { t.Error("PendingReview should be false for manual instance") } } + +func TestInstance_GetWorkingPath(t *testing.T) { + t.Run("returns repo path when no worktree", func(t *testing.T) { + inst := &Instance{Path: "/my/repo"} + inst.started.Store(true) + if got := inst.GetWorkingPath(); got != "/my/repo" { + t.Errorf("got %q, want /my/repo", got) + } + }) +} From 6ef30b655405a830217784dc2d1208046ede564a Mon Sep 17 00:00:00 2001 From: Fabian Urbanek Date: Mon, 23 Feb 2026 17:07:44 +0100 Subject: [PATCH 10/15] feat: add StartInMainRepo() and guard Pause/Resume against nil worktree --- session/instance_lifecycle.go | 82 +++++++++++++++++++++++++++++++++-- 1 file changed, 79 insertions(+), 3 deletions(-) diff --git a/session/instance_lifecycle.go b/session/instance_lifecycle.go index 9b72394..64022ae 100644 --- a/session/instance_lifecycle.go +++ b/session/instance_lifecycle.go @@ -202,6 +202,60 @@ func (i *Instance) StartInSharedWorktree(worktree *git.GitWorktree, branch strin i.SetStatus(Running) return nil } +// StartInMainRepo starts the instance directly in the repository directory. +// Unlike Start(), this does NOT create a git worktree or a new branch. +func (i *Instance) StartInMainRepo() error { + if i.Title == "" { + return ErrTitleEmpty + } + + i.LoadingTotal = 5 + i.LoadingMessage = "Initializing..." + i.setLoadingProgress(1, "Preparing session...") + + i.mainRepo = true + + var tmuxSession *tmux.TmuxSession + if i.tmuxSession != nil { + tmuxSession = i.tmuxSession + } else { + tmuxSession = tmux.NewTmuxSession(i.Title, i.Program, i.SkipPermissions) + } + tmuxSession.ProgressFunc = func(stage int, desc string) { + i.setLoadingProgress(1+stage, desc) + } + i.tmuxSession = tmuxSession + + if isClaudeProgram(i.Program) { + repoPath := i.Path + title := i.Title + go func() { + if err := registerMCPServer(repoPath, repoPath, title); err != nil { + log.WarningLog.Printf("failed to write MCP config: %v", err) + } + }() + } + + var setupErr error + defer func() { + if setupErr != nil { + if cleanupErr := i.Kill(); cleanupErr != nil { + setupErr = fmt.Errorf("%v (cleanup error: %v)", setupErr, cleanupErr) + } + } else { + i.started.Store(true) + } + }() + + i.setLoadingProgress(3, "Starting tmux session...") + if err := i.tmuxSession.Start(i.Path); err != nil { + setupErr = fmt.Errorf("failed to start session in main repo: %w", err) + return setupErr + } + + i.SetStatus(Running) + return nil +} // Kill terminates the instance and cleans up all resources func (i *Instance) Kill() error { @@ -241,7 +295,7 @@ func (i *Instance) Pause() error { var errs []error - if !i.sharedWorktree { + if !i.sharedWorktree && !i.mainRepo { // Check if there are any changes to commit if dirty, err := i.gitWorktree.IsDirty(); err != nil { errs = append(errs, fmt.Errorf("failed to check if worktree is dirty: %w", err)) @@ -265,7 +319,7 @@ func (i *Instance) Pause() error { // Continue with pause process even if detach fails } - if !i.sharedWorktree { + if !i.sharedWorktree && !i.mainRepo { // Check if worktree exists before trying to remove it if _, err := os.Stat(i.gitWorktree.GetWorktreePath()); err == nil { // Remove worktree but keep branch @@ -290,7 +344,9 @@ func (i *Instance) Pause() error { } i.SetStatus(Paused) - _ = clipboard.WriteAll(i.gitWorktree.GetBranchName()) + if i.gitWorktree != nil { + _ = clipboard.WriteAll(i.gitWorktree.GetBranchName()) + } return nil } @@ -306,6 +362,26 @@ func (i *Instance) Resume() error { // Reset the dead flag so the resumed session will be polled normally. i.tmuxDead.Store(false) + // Main-repo instances have no worktree to set up; just restart the tmux session. + if i.mainRepo { + i.LoadingTotal = 2 + i.setLoadingProgress(1, "Restoring session...") + if i.tmuxSession.DoesSessionExist() { + if err := i.tmuxSession.Restore(); err != nil { + if startErr := i.tmuxSession.Start(i.Path); startErr != nil { + return fmt.Errorf("failed to restart main-repo session: %w", startErr) + } + } + } else { + if err := i.tmuxSession.Start(i.Path); err != nil { + return fmt.Errorf("failed to restart main-repo session: %w", err) + } + } + i.setLoadingProgress(2, "Ready") + i.SetStatus(Running) + return nil + } + i.LoadingTotal = 4 i.setLoadingProgress(1, "Checking branch...") From cd3e5c00daa65d9b2a58f1dc0483bfadb4fb2218 Mon Sep 17 00:00:00 2001 From: Fabian Urbanek Date: Mon, 23 Feb 2026 17:10:47 +0100 Subject: [PATCH 11/15] refactor: use IsSharedWorktree() and GetWorkingPath() in app_state --- app/app_state.go | 37 ++++++++----------------------------- 1 file changed, 8 insertions(+), 29 deletions(-) diff --git a/app/app_state.go b/app/app_state.go index b5eb749..e9f5af4 100644 --- a/app/app_state.go +++ b/app/app_state.go @@ -30,7 +30,7 @@ func topicMeta(topics []*session.Topic) (names []string, shared map[string]bool, autoYes = make(map[string]bool) for i, t := range topics { names[i] = t.Name - if t.SharedWorktree { + if t.IsSharedWorktree() { shared[t.Name] = true } if t.AutoYes { @@ -168,11 +168,7 @@ func (m *home) enterGitFocusMode() tea.Cmd { gitPane := m.tabbedWindow.GetGitPane() if !gitPane.IsRunning() { - worktree, err := selected.GetGitWorktree() - if err != nil { - return m.handleError(err) - } - gitPane.Attach(worktree.GetWorktreePath(), selected.Title) + gitPane.Attach(selected.GetWorkingPath(), selected.Title) } m.state = stateFocusAgent @@ -194,11 +190,7 @@ func (m *home) enterTerminalFocusMode() tea.Cmd { } termPane := m.tabbedWindow.GetTerminalPane() - worktree, err := selected.GetGitWorktree() - if err != nil { - return m.handleError(err) - } - termPane.Attach(worktree.GetWorktreePath(), selected.Title) + termPane.Attach(selected.GetWorkingPath(), selected.Title) m.state = stateFocusAgent m.tabbedWindow.SetFocusMode(true) @@ -228,11 +220,8 @@ func (m *home) openFileInTerminal(relativePath string) (tea.Model, tea.Cmd) { if selected == nil || !selected.Started() || selected.Paused() { return m, nil } - worktree, err := selected.GetGitWorktree() - if err != nil { - return m, m.handleError(err) - } - fullPath := filepath.Join(worktree.GetWorktreePath(), relativePath) + workingPath := selected.GetWorkingPath() + fullPath := filepath.Join(workingPath, relativePath) // Exit current focus mode m.exitFocusMode() @@ -243,7 +232,7 @@ func (m *home) openFileInTerminal(relativePath string) (tea.Model, tea.Cmd) { // Attach terminal termPane := m.tabbedWindow.GetTerminalPane() - termPane.Attach(worktree.GetWorktreePath(), selected.Title) + termPane.Attach(workingPath, selected.Title) m.state = stateFocusAgent m.tabbedWindow.SetFocusMode(true) @@ -847,13 +836,8 @@ func (m *home) attachGitTab() tea.Cmd { return nil } - worktree, err := selected.GetGitWorktree() - if err != nil { - return m.handleError(err) - } - gitPane := m.tabbedWindow.GetGitPane() - gitPane.Attach(worktree.GetWorktreePath(), selected.Title) + gitPane.Attach(selected.GetWorkingPath(), selected.Title) return func() tea.Msg { return gitTabTickMsg{} @@ -867,13 +851,8 @@ func (m *home) spawnTerminalTab() tea.Cmd { return nil } - worktree, err := selected.GetGitWorktree() - if err != nil { - return m.handleError(err) - } - termPane := m.tabbedWindow.GetTerminalPane() - termPane.Attach(worktree.GetWorktreePath(), selected.Title) + termPane.Attach(selected.GetWorkingPath(), selected.Title) return func() tea.Msg { return terminalTabTickMsg{} From 2c34f5226429f629a8432d551a300599314c57b1 Mon Sep 17 00:00:00 2001 From: Fabian Urbanek Date: Mon, 23 Feb 2026 17:12:52 +0100 Subject: [PATCH 12/15] feat: replace Y/N worktree confirm with 3-way mode picker, wire MainRepo dispatch --- app/app_input.go | 60 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 42 insertions(+), 18 deletions(-) diff --git a/app/app_input.go b/app/app_input.go index f283e49..6c97f9d 100644 --- a/app/app_input.go +++ b/app/app_input.go @@ -264,7 +264,7 @@ func (m *home) handleRightClick(x, y, contentY int) (tea.Model, tea.Cmd) { {Label: "Delete topic (ungroup only)", Action: "delete_topic"}, {Label: "Rename topic", Action: "rename_topic"}, } - if topic.SharedWorktree { + if topic.IsSharedWorktree() { items = append(items, overlay.ContextMenuItem{Label: "Push branch", Action: "push_topic"}) } m.contextMenu = overlay.NewContextMenu(x, y, items) @@ -487,9 +487,12 @@ func (m *home) handleNewInstanceKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Start instance asynchronously startCmd := func() tea.Msg { var startErr error - if topic != nil && topic.SharedWorktree && topic.Started() { + switch { + case topic != nil && topic.IsSharedWorktree() && topic.Started(): startErr = instance.StartInSharedWorktree(topic.GetGitWorktree(), topic.Branch) - } else { + case topic != nil && topic.IsMainRepo(): + startErr = instance.StartInMainRepo() + default: startErr = instance.Start(true) } if startErr != nil { @@ -983,12 +986,16 @@ func (m *home) handleNewTopicKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.textInputOverlay = nil return m, m.handleError(fmt.Errorf("topic name cannot be empty")) } - // Show shared worktree confirmation + // Show worktree mode picker m.textInputOverlay = nil - m.confirmationOverlay = overlay.NewConfirmationOverlay( - fmt.Sprintf("Create shared worktree for topic '%s'?\nAll instances will share one branch and directory.", m.pendingTopicName), + m.pickerOverlay = overlay.NewPickerOverlay( + fmt.Sprintf("Worktree mode for '%s'", m.pendingTopicName), + []string{ + "Per-instance worktrees", + "Shared worktree", + "Main repo (no worktree)", + }, ) - m.confirmationOverlay.SetWidth(60) m.state = stateNewTopicConfirm return m, nil } @@ -1003,29 +1010,47 @@ func (m *home) handleNewTopicKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } func (m *home) handleNewTopicConfirmKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - if m.confirmationOverlay == nil { + if m.pickerOverlay == nil { m.state = stateDefault return m, nil } - shouldClose := m.confirmationOverlay.HandleKeyPress(msg) + shouldClose := m.pickerOverlay.HandleKeyPress(msg) if !shouldClose { - return m, nil // No decision yet + return m, nil + } + + if !m.pickerOverlay.IsSubmitted() { + // Cancelled + m.pickerOverlay = nil + m.pendingTopicName = "" + m.pendingTopicRepoPath = "" + m.state = stateDefault + m.menu.SetState(ui.StateDefault) + return m, tea.WindowSize() } - // Determine if confirmed (y) or cancelled (n/esc) based on which key was pressed - shared := msg.String() == m.confirmationOverlay.ConfirmKey + var mode session.TopicWorktreeMode + switch m.pickerOverlay.Value() { + case "Shared worktree": + mode = session.TopicWorktreeModeShared + case "Main repo (no worktree)": + mode = session.TopicWorktreeModeMainRepo + default: + mode = session.TopicWorktreeModePerInstance + } + m.pickerOverlay = nil + topicRepoPath := m.pendingTopicRepoPath if topicRepoPath == "" { topicRepoPath = m.activeRepoPaths[0] } topic := session.NewTopic(session.TopicOptions{ - Name: m.pendingTopicName, - SharedWorktree: shared, - Path: topicRepoPath, + Name: m.pendingTopicName, + WorktreeMode: mode, + Path: topicRepoPath, }) if err := topic.Setup(); err != nil { m.pendingTopicName = "" - m.confirmationOverlay = nil m.state = stateDefault m.menu.SetState(ui.StateDefault) return m, m.handleError(err) @@ -1038,7 +1063,6 @@ func (m *home) handleNewTopicConfirmKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } m.pendingTopicName = "" m.pendingTopicRepoPath = "" - m.confirmationOverlay = nil m.state = stateDefault m.menu.SetState(ui.StateDefault) return m, tea.WindowSize() @@ -1553,7 +1577,7 @@ func (m *home) handleDefaultKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Can't move shared-worktree instances (they're tied to their topic's worktree) if selected.TopicName != "" { for _, t := range m.topics { - if t.Name == selected.TopicName && t.SharedWorktree { + if t.Name == selected.TopicName && t.IsSharedWorktree() { return m, m.handleError(fmt.Errorf("cannot move instances in shared-worktree topics")) } } From cef5577dbd402325daa8322bbffcc698d070be00 Mon Sep 17 00:00:00 2001 From: Fabian Urbanek Date: Mon, 23 Feb 2026 17:14:33 +0100 Subject: [PATCH 13/15] refactor: update remaining SharedWorktree refs in actions and brain --- app/app_actions.go | 2 +- app/app_brain.go | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/app_actions.go b/app/app_actions.go index 4e7a368..239a89d 100644 --- a/app/app_actions.go +++ b/app/app_actions.go @@ -299,7 +299,7 @@ func (m *home) openContextMenu() (tea.Model, tea.Cmd) { {Label: "Delete topic (ungroup only)", Action: "delete_topic"}, {Label: "Rename topic", Action: "rename_topic"}, } - if topic.SharedWorktree { + if topic.IsSharedWorktree() { items = append(items, overlay.ContextMenuItem{Label: "Push branch", Action: "push_topic"}) } // Position next to the selected sidebar item diff --git a/app/app_brain.go b/app/app_brain.go index c6067dc..27ef128 100644 --- a/app/app_brain.go +++ b/app/app_brain.go @@ -131,9 +131,12 @@ func (m *home) handleActionCreateInstance(action brain.ActionRequest) (tea.Model } var startErr error - if topicObj != nil && topicObj.SharedWorktree && topicObj.Started() { + switch { + case topicObj != nil && topicObj.IsSharedWorktree() && topicObj.Started(): startErr = instance.StartInSharedWorktree(topicObj.GetGitWorktree(), topicObj.Branch) - } else { + case topicObj != nil && topicObj.IsMainRepo(): + startErr = instance.StartInMainRepo() + default: startErr = instance.Start(true) } From bd99679b7882fdb5dfeaf2bbb7dda0a0c17aba37 Mon Sep 17 00:00:00 2001 From: Fabian Urbanek Date: Mon, 23 Feb 2026 17:18:54 +0100 Subject: [PATCH 14/15] fix: render pickerOverlay for stateNewTopicConfirm in View --- app/app.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/app.go b/app/app.go index 27fee2e..c5a3efe 100644 --- a/app/app.go +++ b/app/app.go @@ -732,7 +732,9 @@ func (m *home) View() string { log.ErrorLog.Printf("text overlay is nil") } result = overlay.PlaceOverlay(0, 0, m.textOverlay.Render(), mainView, true, true) - case m.state == stateConfirm || m.state == stateNewTopicConfirm: + case m.state == stateNewTopicConfirm && m.pickerOverlay != nil: + result = overlay.PlaceOverlay(0, 0, m.pickerOverlay.Render(), mainView, true, true) + case m.state == stateConfirm: if m.confirmationOverlay == nil { log.ErrorLog.Printf("confirmation overlay is nil") } From 39ff28b01421d488401d31c4cc3aa7349f6bfa6e Mon Sep 17 00:00:00 2001 From: Fabian Urbanek Date: Mon, 23 Feb 2026 18:01:29 +0100 Subject: [PATCH 15/15] fix: apply gofmt formatting to app.go and instance_lifecycle.go --- app/app.go | 5 ++--- session/instance_lifecycle.go | 1 + 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/app.go b/app/app.go index c5a3efe..bec98db 100644 --- a/app/app.go +++ b/app/app.go @@ -195,9 +195,8 @@ type home struct { // Automations automations []*config.Automation autoSelectedIdx int - autoForm *ui.AutomationForm - autoEditIdx int // -1 = new, >=0 = index of automation being edited - + autoForm *ui.AutomationForm + autoEditIdx int // -1 = new, >=0 = index of automation being edited // embeddedTerminal is the VT emulator for focus mode (nil when not in focus mode) embeddedTerminal *session.EmbeddedTerminal diff --git a/session/instance_lifecycle.go b/session/instance_lifecycle.go index 64022ae..768977d 100644 --- a/session/instance_lifecycle.go +++ b/session/instance_lifecycle.go @@ -202,6 +202,7 @@ func (i *Instance) StartInSharedWorktree(worktree *git.GitWorktree, branch strin i.SetStatus(Running) return nil } + // StartInMainRepo starts the instance directly in the repository directory. // Unlike Start(), this does NOT create a git worktree or a new branch. func (i *Instance) StartInMainRepo() error {