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
87 changes: 87 additions & 0 deletions app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,18 @@ const (
stateNewAutomation
// stateMemoryBrowser is the state when the memory file browser is open.
stateMemoryBrowser
// stateOnboarding is shown on first ever launch until the companion completes setup.
stateOnboarding
// stateNewChatAgent is the state when the user is naming a new chat agent.
stateNewChatAgent
)

// sidebarTab identifies which tab is active in the sidebar.
type sidebarTab int

const (
sidebarTabCode sidebarTab = iota
sidebarTabChat
)

type home struct {
Expand Down Expand Up @@ -159,6 +171,8 @@ type home struct {
allTopics []*session.Topic
// focusedPanel tracks which panel has keyboard focus (0=sidebar, 1=instance list)
focusedPanel int
// sidebarTab tracks which tab is active in the sidebar (Code or Chat)
sidebarTab sidebarTab
// pendingTopicName stores the topic name during the two-step creation flow
pendingTopicName string
// pendingTopicRepoPath stores the repo path during multi-repo topic creation
Expand Down Expand Up @@ -217,6 +231,9 @@ type home struct {

// brainServer is the IPC server for coordinating brain state between MCP agents
brainServer *brain.Server

// onboardingContent caches the companion instance terminal output for viewOnboarding.
onboardingContent string
}

func newHome(ctx context.Context, program string, autoYes bool) *home {
Expand Down Expand Up @@ -293,6 +310,8 @@ func newHome(ctx context.Context, program string, autoYes bool) *home {
}
}
}
// Apply initial chat filter (default tab is Code, so hide chat agents)
h.refreshListChatFilter()

// Load topics
topics, err := storage.LoadTopics()
Expand Down Expand Up @@ -334,6 +353,11 @@ func newHome(ctx context.Context, program string, autoYes bool) *home {
}
}

// First-launch detection: start in stateOnboarding if companion setup not done.
if !h.appState.GetOnboarded() {
h.state = stateOnboarding
}

return h
}

Expand All @@ -347,6 +371,42 @@ func (m *home) instanceMatchesActiveRepos(inst *session.Instance) bool {
return m.activeRepoSet()[repoPath]
}

// visibleInstances returns instances filtered by the active sidebar tab.
// Code tab shows only non-chat agents; Chat tab shows only chat agents.
func (m *home) visibleInstances() []*session.Instance {
switch m.sidebarTab {
case sidebarTabChat:
out := make([]*session.Instance, 0)
for _, inst := range m.allInstances {
if inst.IsChat {
out = append(out, inst)
}
}
return out
default: // sidebarTabCode
out := make([]*session.Instance, 0)
for _, inst := range m.allInstances {
if !inst.IsChat {
out = append(out, inst)
}
}
return out
}
}

// refreshListChatFilter updates the list chat filter based on the active sidebar tab.
// This is called whenever the sidebar tab changes.
func (m *home) refreshListChatFilter() {
switch m.sidebarTab {
case sidebarTabChat:
isChat := true
m.list.SetChatFilter(&isChat)
default: // sidebarTabCode
isChat := false
m.list.SetChatFilter(&isChat)
}
}

// activeRepoSet returns an O(1) lookup set of active repo paths.
func (m *home) activeRepoSet() map[string]bool {
set := make(map[string]bool, len(m.activeRepoPaths))
Expand Down Expand Up @@ -426,6 +486,12 @@ func (m *home) Init() tea.Cmd {
cmds = append(cmds, m.pollBrainActions())
}

// If first launch, start the onboarding companion.
if m.state == stateOnboarding {
_, cmd := m.startOnboarding()
cmds = append(cmds, cmd)
}

return tea.Batch(cmds...)
}

Expand All @@ -452,6 +518,14 @@ func (m *home) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.tabbedWindow.UpdateDiff(selected)
// Preview: cheap states handled synchronously, running instances fetched async
previewCmd := m.asyncUpdatePreview(selected)
// During onboarding, also refresh the companion terminal content.
if m.state == stateOnboarding {
if companion := m.findInstanceByTitle("companion"); companion != nil && companion.Started() {
if c, err := companion.Preview(); err == nil {
m.onboardingContent = c
}
}
}
return m, tea.Batch(previewCmd, func() tea.Msg {
time.Sleep(100 * time.Millisecond)
return previewTickMsg{}
Expand Down Expand Up @@ -628,6 +702,12 @@ func (m *home) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.list.KillInstanceByTitle(msg.title)
m.updateSidebarItems()
return m, m.instanceChanged()
case onboardingStartedMsg:
if msg.err != nil {
log.WarningLog.Printf("onboarding: companion failed to start: %v", msg.err)
}
// Trigger a window size update so layout is recalculated for the companion view.
return m, tea.WindowSize()
case instanceResumedMsg:
if msg.err != nil {
if msg.wasDead {
Expand Down Expand Up @@ -679,6 +759,11 @@ func (m *home) handleQuit() (tea.Model, tea.Cmd) {
}

func (m *home) View() string {
// Onboarding takes over the full screen.
if m.state == stateOnboarding {
return m.viewOnboarding()
}

// All columns use identical padding and height for uniform alignment.
colStyle := lipgloss.NewStyle().PaddingTop(1).Height(m.contentHeight + 1)
sidebarView := colStyle.Render(m.sidebar.String())
Expand Down Expand Up @@ -717,6 +802,8 @@ func (m *home) View() string {
pickerY = 2
}
result = overlay.PlaceOverlay(pickerX, pickerY, m.pickerOverlay.Render(), mainView, true, false)
case m.state == stateNewChatAgent && m.textInputOverlay != nil:
result = overlay.PlaceOverlay(0, 0, m.textInputOverlay.Render(), mainView, true, true)
case m.state == stateNewTopicRepo && m.pickerOverlay != nil:
result = overlay.PlaceOverlay(0, 0, m.pickerOverlay.Render(), mainView, true, true)
case m.state == stateNewTopic && m.textInputOverlay != nil:
Expand Down
12 changes: 12 additions & 0 deletions app/app_brain.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ func (m *home) handleBrainAction(action brain.ActionRequest) (tea.Model, tea.Cmd
return m.handleActionResumeInstance(action)
case brain.ActionKillInstance:
return m.handleActionKillInstance(action)
case brain.ActionOnboardingComplete:
return m.handleActionOnboardingComplete(action)
default:
action.ResponseCh <- brain.ActionResponse{
Error: fmt.Sprintf("unknown action type: %s", action.Type),
Expand Down Expand Up @@ -334,3 +336,13 @@ func (m *home) handleActionKillInstance(action brain.ActionRequest) (tea.Model,
action.ResponseCh <- brain.ActionResponse{OK: true}
return m, tea.Batch(m.pollBrainActions(), m.instanceChanged())
}

// handleActionOnboardingComplete marks onboarding as complete and transitions to stateDefault.
func (m *home) handleActionOnboardingComplete(action brain.ActionRequest) (tea.Model, tea.Cmd) {
if err := m.appState.SetOnboarded(true); err != nil {
log.ErrorLog.Printf("brain: failed to persist onboarded state: %v", err)
}
m.state = stateDefault
action.ResponseCh <- brain.ActionResponse{OK: true}
return m, m.pollBrainActions()
}
120 changes: 119 additions & 1 deletion app/app_input.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package app

import (
"fmt"
"strings"
"time"

"github.com/ByteMirror/hivemind/brain"
Expand All @@ -24,7 +25,7 @@ func (m *home) handleMenuHighlighting(msg tea.KeyMsg) (cmd tea.Cmd, returnEarly
m.keySent = false
return nil, false
}
if m.state == statePrompt || m.state == stateHelp || m.state == stateConfirm || m.state == stateNewTopic || m.state == stateNewTopicConfirm || m.state == stateSearch || m.state == stateMoveTo || m.state == stateContextMenu || m.state == statePRTitle || m.state == statePRBody || m.state == stateRenameInstance || m.state == stateRenameTopic || m.state == stateSendPrompt || m.state == stateFocusAgent || m.state == stateRepoSwitch || m.state == stateNewTopicRepo || m.state == stateCommandPalette || m.state == stateSettings || m.state == stateSkillPicker || m.state == stateInlineComment || m.state == stateAutomations || m.state == stateNewAutomation || m.state == stateMemoryBrowser {
if m.state == statePrompt || m.state == stateHelp || m.state == stateConfirm || m.state == stateNewTopic || m.state == stateNewTopicConfirm || m.state == stateSearch || m.state == stateMoveTo || m.state == stateContextMenu || m.state == statePRTitle || m.state == statePRBody || m.state == stateRenameInstance || m.state == stateRenameTopic || m.state == stateSendPrompt || m.state == stateFocusAgent || m.state == stateRepoSwitch || m.state == stateNewTopicRepo || m.state == stateCommandPalette || m.state == stateSettings || m.state == stateSkillPicker || m.state == stateInlineComment || m.state == stateAutomations || m.state == stateNewAutomation || m.state == stateMemoryBrowser || m.state == stateNewChatAgent || m.state == stateOnboarding {
return nil, false
}
// If it's in the global keymap, we should try to highlight it.
Expand Down Expand Up @@ -363,6 +364,11 @@ func (m *home) handleKeyPress(msg tea.KeyMsg) (mod tea.Model, cmd tea.Cmd) {
return m.handleNewAutomationKeys(msg)
case stateMemoryBrowser:
return m.handleMemoryBrowserKeys(msg)
case stateNewChatAgent:
return m.handleNewChatAgentKeys(msg)
case stateOnboarding:
// Block all keys during onboarding; the companion session drives interaction.
return m, nil
default:
return m.handleDefaultKeys(msg)
}
Expand Down Expand Up @@ -1236,6 +1242,9 @@ func (m *home) handleDefaultKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.promptAfterName = true
return m, nil
case keys.KeyNew:
if m.sidebarTab == sidebarTabChat {
return m.startNewChatAgent()
}
if _, errCmd := m.createNewInstance(false); errCmd != nil {
return m, errCmd
}
Expand Down Expand Up @@ -1536,6 +1545,16 @@ func (m *home) handleDefaultKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.state = stateDefault
})
return m, nil
case keys.KeySidebarCodeTab:
m.sidebarTab = sidebarTabCode
m.sidebar.SetTab(int(m.sidebarTab))
m.refreshListChatFilter()
return m, m.instanceChanged()
case keys.KeySidebarChatTab:
m.sidebarTab = sidebarTabChat
m.sidebar.SetTab(int(m.sidebarTab))
m.refreshListChatFilter()
return m, m.instanceChanged()
case keys.KeyLeft:
m.setFocus(0)
return m, nil
Expand Down Expand Up @@ -2150,3 +2169,102 @@ func (m *home) handleMemoryBrowserKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
return m, cmd
}

// slugify converts an arbitrary string into a slug suitable for use as a chat agent directory name.
// It lowercases, replaces spaces with hyphens, and strips non-alphanumeric characters.
func slugify(name string) string {
s := strings.ToLower(name)
s = strings.ReplaceAll(s, " ", "-")
var out strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
out.WriteRune(r)
}
}
result := out.String()
if result == "" {
result = "agent"
}
return result
}

// handleNewChatAgentKeys handles key events when the user is naming a new chat agent.
func (m *home) handleNewChatAgentKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.textInputOverlay == nil {
m.state = stateDefault
return m, nil
}
shouldClose := m.textInputOverlay.HandleKeyPress(msg)
if shouldClose {
if m.textInputOverlay.IsSubmitted() {
name := m.textInputOverlay.GetValue()
m.textInputOverlay = nil
if name == "" {
m.state = stateDefault
return m, m.handleError(fmt.Errorf("agent name cannot be empty"))
}
return m.createChatAgent(name)
}
// Cancelled
m.state = stateDefault
m.textInputOverlay = nil
return m, tea.WindowSize()
}
return m, nil
}

// startNewChatAgent opens a text input overlay for the user to name a new chat agent.
func (m *home) startNewChatAgent() (tea.Model, tea.Cmd) {
m.textInputOverlay = overlay.NewTextInputOverlay("New chat agent name", "")
m.textInputOverlay.SetSize(50, 5)
m.state = stateNewChatAgent
return m, nil
}

// createChatAgent creates and starts a new chat agent with the given display name.
func (m *home) createChatAgent(name string) (tea.Model, tea.Cmd) {
slug := slugify(name)

if err := session.EnsureAgentDir(slug); err != nil {
m.state = stateDefault
return m, m.handleError(fmt.Errorf("could not create agent directory: %w", err))
}
if err := session.CopyTemplatesToAgentDir(slug); err != nil {
m.state = stateDefault
return m, m.handleError(fmt.Errorf("could not copy agent templates: %w", err))
}

personalityDir, err := session.GetAgentPersonalityDir(slug)
if err != nil {
m.state = stateDefault
return m, m.handleError(fmt.Errorf("could not get agent personality dir: %w", err))
}

agent, err := session.NewInstance(session.InstanceOptions{
Title: name,
Program: m.program,
IsChat: true,
PersonalityDir: personalityDir,
SkipPermissions: true,
})
if err != nil {
m.state = stateDefault
return m, m.handleError(fmt.Errorf("could not create chat agent: %w", err))
}

m.newInstanceFinalizer = m.list.AddInstance(agent)
m.list.SelectInstanceByRef(agent)
m.state = stateDefault

// Refresh the list so the new agent appears in the Chat tab.
m.refreshListChatFilter()

startCmd := func() tea.Msg {
if err := agent.Start(true); err != nil {
return instanceStartedMsg{instance: agent, err: err}
}
return instanceStartedMsg{instance: agent, err: nil}
}

return m, tea.Batch(tea.WindowSize(), startCmd)
}
Loading
Loading