Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,13 +189,14 @@ 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
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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -729,7 +731,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")
}
Expand All @@ -741,11 +745,11 @@ 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)
autoView := ui.RenderAutomationsList(m.automations, m.autoSelectedIdx, m.width-4, m.height-4, m.autoForm)
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:
Expand Down
2 changes: 1 addition & 1 deletion app/app_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions app/app_brain.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
167 changes: 90 additions & 77 deletions app/app_input.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand All @@ -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()
}

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

// Determine if confirmed (y) or cancelled (n/esc) based on which key was pressed
shared := msg.String() == m.confirmationOverlay.ConfirmKey
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)
Expand All @@ -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()
Expand Down Expand Up @@ -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"))
}
}
Expand Down Expand Up @@ -1641,13 +1665,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
}
}
Expand Down Expand Up @@ -1981,14 +2002,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
Expand Down Expand Up @@ -2048,80 +2078,63 @@ 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)
m.autoSelectedIdx = len(m.automations) - 1
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) {
Expand Down
Loading
Loading