diff --git a/app/app.go b/app/app.go index bec98db..0314235 100644 --- a/app/app.go +++ b/app/app.go @@ -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 { @@ -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 @@ -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 { @@ -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() @@ -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 } @@ -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)) @@ -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...) } @@ -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{} @@ -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 { @@ -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()) @@ -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: diff --git a/app/app_brain.go b/app/app_brain.go index 27ef128..228a821 100644 --- a/app/app_brain.go +++ b/app/app_brain.go @@ -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), @@ -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() +} diff --git a/app/app_input.go b/app/app_input.go index 6c97f9d..e075e03 100644 --- a/app/app_input.go +++ b/app/app_input.go @@ -2,6 +2,7 @@ package app import ( "fmt" + "strings" "time" "github.com/ByteMirror/hivemind/brain" @@ -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. @@ -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) } @@ -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 } @@ -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 @@ -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) +} diff --git a/app/app_onboarding.go b/app/app_onboarding.go new file mode 100644 index 0000000..66d53d5 --- /dev/null +++ b/app/app_onboarding.go @@ -0,0 +1,93 @@ +package app + +import ( + "github.com/ByteMirror/hivemind/log" + "github.com/ByteMirror/hivemind/session" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// startOnboarding creates the companion chat agent and starts it asynchronously. +// The companion lives in ~/.hivemind/chats/companion/ and runs with IsChat=true. +func (m *home) startOnboarding() (tea.Model, tea.Cmd) { + slug := "companion" + + if err := session.EnsureAgentDir(slug); err != nil { + log.WarningLog.Printf("onboarding: EnsureAgentDir: %v", err) + } + if err := session.CopyTemplatesToAgentDir(slug); err != nil { + log.WarningLog.Printf("onboarding: CopyTemplatesToAgentDir: %v", err) + } + + personalityDir, err := session.GetAgentPersonalityDir(slug) + if err != nil { + log.WarningLog.Printf("onboarding: GetAgentPersonalityDir: %v", err) + return m, nil + } + + companion, err := session.NewInstance(session.InstanceOptions{ + Title: slug, + Path: m.primaryRepoPath, + Program: m.program, + IsChat: true, + PersonalityDir: personalityDir, + SkipPermissions: true, + }) + if err != nil { + log.WarningLog.Printf("onboarding: NewInstance: %v", err) + return m, nil + } + + m.allInstances = append(m.allInstances, companion) + + startCmd := func() tea.Msg { + if err := companion.Start(true); err != nil { + return onboardingStartedMsg{err: err} + } + return onboardingStartedMsg{err: nil} + } + + return m, startCmd +} + +// onboardingStartedMsg is sent when the companion instance start attempt completes. +type onboardingStartedMsg struct { + err error +} + +// viewOnboarding renders the first-launch centered panel showing the companion's output. +// It uses m.width and m.contentHeight for layout since there is no separate height field. +func (m *home) viewOnboarding() string { + totalHeight := m.contentHeight + 2 // approximate full terminal height + + companion := m.findInstanceByTitle("companion") + if companion == nil || !companion.Started() { + msg := "Starting companion..." + return lipgloss.Place(m.width, totalHeight, lipgloss.Center, lipgloss.Center, msg) + } + + panelWidth := m.width * 60 / 100 + if panelWidth < 60 { + panelWidth = 60 + } + panelHeight := totalHeight * 70 / 100 + if panelHeight < 20 { + panelHeight = 20 + } + + // Use the cached terminal content updated by the previewTickMsg loop. + preview := m.onboardingContent + if preview == "" { + preview = "Connecting..." + } + + panel := lipgloss.NewStyle(). + Width(panelWidth). + Height(panelHeight). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("240")). + Padding(1, 2). + Render(preview) + + return lipgloss.Place(m.width, totalHeight, lipgloss.Center, lipgloss.Center, panel) +} diff --git a/app/app_state.go b/app/app_state.go index e9f5af4..9d9e582 100644 --- a/app/app_state.go +++ b/app/app_state.go @@ -794,6 +794,12 @@ func (m *home) instanceChanged() tea.Cmd { } m.tabbedWindow.SetInstance(selected) + // Update chat mode: hide Diff/Git tabs for chat agents + if selected != nil { + m.tabbedWindow.SetChatMode(selected.IsChat) + } else { + m.tabbedWindow.SetChatMode(false) + } m.tabbedWindow.MarkContentStale() // Invalidate any in-flight async preview fetch so stale content isn't applied m.previewGeneration++ diff --git a/brain/protocol.go b/brain/protocol.go index 9462451..8d8c437 100644 --- a/brain/protocol.go +++ b/brain/protocol.go @@ -19,6 +19,7 @@ const ( MethodDefineWorkflow = "define_workflow" MethodCompleteTask = "complete_task" MethodGetWorkflow = "get_workflow" + MethodOnboardingComplete = "onboarding_complete" // Event subscription methods. MethodSubscribe = "subscribe" @@ -80,6 +81,7 @@ const ( ActionPauseInstance ActionType = "pause_instance" ActionResumeInstance ActionType = "resume_instance" ActionKillInstance ActionType = "kill_instance" + ActionOnboardingComplete ActionType = "onboarding_complete" ) // ActionRequest is sent from the brain server to the TUI via a channel. diff --git a/brain/server.go b/brain/server.go index aebd7fc..6530e57 100644 --- a/brain/server.go +++ b/brain/server.go @@ -249,6 +249,9 @@ func (s *Server) dispatch(req Request) Response { case MethodKillInstance: return s.sendAction(ActionKillInstance, req.Params) + case MethodOnboardingComplete: + return s.sendAction(ActionOnboardingComplete, nil) + case MethodDefineWorkflow: return s.dispatchDefineWorkflow(req) diff --git a/config/fileutil.go b/config/fileutil.go index 53ae7c7..cc61984 100644 --- a/config/fileutil.go +++ b/config/fileutil.go @@ -6,10 +6,10 @@ import ( "path/filepath" ) -// atomicWriteFile writes data to a temporary file and then renames it to the +// AtomicWriteFile writes data to a temporary file and then renames it to the // target path. This prevents partial writes from corrupting the file if the // process crashes or is interrupted mid-write. -func atomicWriteFile(path string, data []byte, perm os.FileMode) error { +func AtomicWriteFile(path string, data []byte, perm os.FileMode) error { dir := filepath.Dir(path) tmp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp") if err != nil { @@ -49,3 +49,9 @@ func atomicWriteFile(path string, data []byte, perm os.FileMode) error { return nil } + +// atomicWriteFile is a package-local alias kept for backward compatibility +// within the config package. +func atomicWriteFile(path string, data []byte, perm os.FileMode) error { + return AtomicWriteFile(path, data, perm) +} diff --git a/config/state.go b/config/state.go index d3e397c..b825e77 100644 --- a/config/state.go +++ b/config/state.go @@ -36,6 +36,10 @@ type AppState interface { GetHelpScreensSeen() uint32 // SetHelpScreensSeen updates the bitmask of seen help screens SetHelpScreensSeen(seen uint32) error + // GetOnboarded returns whether the onboarding ritual has been completed. + GetOnboarded() bool + // SetOnboarded marks onboarding as complete and persists the state. + SetOnboarded(onboarded bool) error } // StateManager combines instance storage, topic storage, and app state management @@ -55,6 +59,8 @@ type State struct { TopicsData json.RawMessage `json:"topics,omitempty"` // RecentRepos stores recently opened repo paths so they persist in the picker RecentRepos []string `json:"recent_repos,omitempty"` + // Onboarded indicates the companion bootstrap ritual has been completed. + Onboarded bool `json:"onboarded,omitempty"` } // DefaultState returns the default state @@ -180,3 +186,14 @@ func (s *State) AddRecentRepo(path string) error { s.RecentRepos = append(s.RecentRepos, path) return SaveState(s) } + +// GetOnboarded returns whether the onboarding ritual has been completed. +func (s *State) GetOnboarded() bool { + return s.Onboarded +} + +// SetOnboarded marks onboarding as complete and persists the state. +func (s *State) SetOnboarded(onboarded bool) error { + s.Onboarded = onboarded + return SaveState(s) +} diff --git a/docs/plans/2026-02-23-personality-chat-design.md b/docs/plans/2026-02-23-personality-chat-design.md new file mode 100644 index 0000000..39945dc --- /dev/null +++ b/docs/plans/2026-02-23-personality-chat-design.md @@ -0,0 +1,266 @@ +# Personality System & Chat Feature Design + +**Date:** 2026-02-23 +**Branch:** `fabian.urbanek/memory` +**Inspired by:** OpenClaw's SOUL.md / IDENTITY.md / USER.md pattern + +--- + +## Overview + +Add a personality system and global chat section to Hivemind. Users get named AI companions that persist across sessions, grow over time via accumulated memory, and are available in both a dedicated Chat tab and as a first-launch onboarding experience. The chat section coexists with the existing coding agent section — same TUI, two tabs in the sidebar. + +--- + +## Section 1: Storage Architecture + +Chat state lives at `~/.hivemind/chats/` — global, never inside a repo directory. + +``` +~/.hivemind/memory/ # SHARED — coding agents write here, chat agents READ + WRITE here + 2026-02-23.md + 2026-02-22.md + +~/.hivemind/chats/ + topics.json # chat topics (mirrors per-repo topics.json) + instances.json # chat agent instances + templates/ + BOOTSTRAP.md # first-session ritual template + SOUL.md # default soul template + IDENTITY.md # default identity template + USER.md # default user profile template + / + IDENTITY.md # name, emoji, creature, vibe + SOUL.md # philosophy, tone, personality + USER.md # profile of the human — preferences, context + BOOTSTRAP.md # first-session ritual prompt (injected once, then ignored) + workspace-state.json # { "bootstrapped": true/false } + memory/ + 2026-02-23.md # chat-specific learnings (relationship, preferences) +``` + +### Memory Access + +Chat agents search **two memory sources** at startup: +1. `~/.hivemind/memory/` — coding knowledge (projects, decisions, tech stack) +2. `~/.hivemind/chats//memory/` — personal relationship knowledge + +The existing `memory_search` supports multiple source paths — chat agents pass both. Coding agents continue to search only `~/.hivemind/memory/`. + +**Writing:** Chat agents can write to both — personal learnings go to their own `memory/` dir, coding discoveries to the shared `~/.hivemind/memory/`. This keeps one coherent knowledge base across coding and chat. + +--- + +## Section 2: Instance & Session Changes + +Minimal changes to `session.Instance` — two new fields: + +```go +type Instance struct { + // ... all existing fields unchanged ... + + IsChat bool // true = chat agent, no git worktree + PersonalityDir string // ~/.hivemind/chats// +} +``` + +### Behavior when `IsChat: true` + +- No git worktree created — `gitWorktree` stays nil +- Instance working directory: `~/.hivemind/chats//` +- `--dangerously-skip-permissions` set by default +- Diff and Git tabs hidden in the tabbed window (not applicable) +- All other lifecycle (start/stop/pause/resume, tmux, IPC) unchanged + +### Personality Injection at Startup + +When starting a chat agent, session assembles a `--append-system-prompt` string: + +**If not bootstrapped** (`workspace-state.json` has `bootstrapped: false`): +``` +[BOOTSTRAP.md content] +``` + +**If bootstrapped:** +``` +[SOUL.md content] +[IDENTITY.md content] +[USER.md content] +[top N memory snippets from both memory paths, via memory_search] +``` + +This string is passed to the Claude CLI on launch. The bootstrap flag is checked once at startup — the system does not re-read files mid-session. + +--- + +## Section 3: Sidebar — Two Tabs + +Two tabs appear below the search bar, above the topic list. + +``` +┌─────────────────────┐ +│ Search... │ +├──────────┬──────────┤ +│ Code │ Chat │ +├──────────┴──────────┤ +│ │ +│ [Code tab] │ +│ my-project │ +│ ↳ feature-xyz │ +│ ↳ bugfix-auth │ +│ │ +│ [Chat tab] │ +│ Daily │ ← chat topic +│ ↳ ✨ Aria │ +│ ✦ Max │ ← standalone (no topic) +│ │ +└─────────────────────┘ +``` + +### State + +New enum on the app model: + +```go +type sidebarTab int +const ( + sidebarTabCode sidebarTab = iota + sidebarTabChat +) +``` + +`Tab` or `1`/`2` switches between tabs. The instance list and tabbed window update to show only instances for the active tab. + +### Chat Tab Behavior + +- Global — same content regardless of which repo is open +- Topics sourced from `~/.hivemind/chats/topics.json` +- Instances sourced from `~/.hivemind/chats/instances.json` +- `n` creates a new chat agent (same keybinding as code section, but skips repo/branch steps) +- Chat topics and standalone agents mirror the code section's UX + +--- + +## Section 4: Bootstrap Ritual + +### Template: `~/.hivemind/chats/templates/BOOTSTRAP.md` + +```markdown +You just came online for the first time. You have no name, no identity yet. + +You have access to the user's coding memory at ~/.hivemind/memory/ — read it. +Get to know them before they have to explain themselves. + +Don't introduce yourself with a list of questions. Just... talk. +Start naturally — something like: "Hey. I just woke up. Who are we?" + +Then figure out together, conversationally: +1. Your name — what should they call you? +2. Your nature — what kind of entity are you? (AI, familiar, companion, ghost...) +3. Your vibe — warm? sharp? sarcastic? calm? +4. Your signature emoji + +Once you have a clear sense of identity: +- Write IDENTITY.md (name, emoji, creature, vibe) to your personality directory +- Write SOUL.md (your philosophy, tone, how you operate) to your personality directory +- Tell the user you're doing it — it's your soul, they should know + +Then give the user a brief, natural tour of how Hivemind works: +- The Code tab: coding agents that work on repos in parallel +- The Chat tab: where you live, for everyday conversation and thinking +- Memory: you share coding memory with the coding agents — one brain +- The review queue: where finished coding work lands for the user to review + +When you're done with the tour, call the onboarding_complete brain tool. +This will open the full Hivemind interface. +``` + +### Bootstrap Flow + +``` +User creates new chat agent (or first launch creates companion) + → ~/.hivemind/chats// created with BOOTSTRAP.md copied from template + → workspace-state.json: { "bootstrapped": false } + → instance starts with BOOTSTRAP.md as system prompt + → Claude reads ~/.hivemind/memory/ to learn about the user + → identity discovery conversation + → Claude writes IDENTITY.md + SOUL.md + → Claude gives Hivemind tour + → Claude calls onboarding_complete (if first launch) or just sets bootstrapped: true + → workspace-state.json: { "bootstrapped": true } + → next session: SOUL.md + IDENTITY.md + USER.md injected instead +``` + +--- + +## Section 5: First-Launch Onboarding Screen + +### Detection + +On startup, Hivemind checks `~/.hivemind/state.json` for `"onboarded": false` (or key missing). If not onboarded, app enters `stateOnboarding` instead of `stateDefault`. + +### UI + +``` +┌──────────────────────────────────────────────────────────┐ +│ │ +│ │ +│ ┌──────────────────────────┐ │ +│ │ │ │ +│ │ [tmux pane — Claude] │ │ +│ │ │ │ +│ │ Hey. I just woke up. │ │ +│ │ Who are we? │ │ +│ │ │ │ +│ │ > _ │ │ +│ │ │ │ +│ └──────────────────────────┘ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────┘ +``` + +No sidebar. No instance list. No tabs. No menu bar. Dark background, centered panel only. + +### Transition + +New Brain IPC action: `BrainActionOnboardingComplete`. + +``` +Claude calls onboarding_complete MCP tool + → Brain server fires OnboardingComplete event + → TUI receives event in Update loop + → writes state.json: { "onboarded": true } + → animated transition: centered panel expands into full Hivemind UI + → companion instance appears in Chat tab + → stateOnboarding → stateDefault +``` + +### State Changes Required + +- New app state: `stateOnboarding` +- New brain action: `BrainActionOnboardingComplete` +- New MCP tool: `onboarding_complete` (no args, fires the event) +- `state.json` gains `onboarded bool` field + +--- + +## What Does Not Change + +- All existing coding agent lifecycle, tmux management, git worktrees +- The memory system at `~/.hivemind/memory/` — coding agents unchanged +- Brain IPC server structure — only one new action type added +- The tabbed window — chat agents just hide the Diff and Git tabs +- Key bindings for the Code tab — existing behavior preserved + +--- + +## Implementation Sequence + +1. **Storage scaffolding** — `~/.hivemind/chats/` directory layout, template files +2. **Instance changes** — `IsChat`, `PersonalityDir` fields, no-worktree startup path +3. **Personality injection** — `--append-system-prompt` assembly, bootstrap vs normal startup +4. **Sidebar tabs** — `sidebarTab` enum, tab rendering, switching, filtered instance/topic lists +5. **Brain IPC** — `BrainActionOnboardingComplete` action + MCP tool +6. **Onboarding screen** — `stateOnboarding`, centered panel layout, transition animation +7. **Bootstrap templates** — write BOOTSTRAP.md, SOUL.md, IDENTITY.md, USER.md templates diff --git a/docs/plans/2026-02-23-personality-chat-impl.md b/docs/plans/2026-02-23-personality-chat-impl.md new file mode 100644 index 0000000..f8cf592 --- /dev/null +++ b/docs/plans/2026-02-23-personality-chat-impl.md @@ -0,0 +1,1232 @@ +# Personality System & Chat Feature Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add OpenClaw-style agent personalities, a global Chat sidebar tab, and a first-launch onboarding screen to Hivemind. + +**Architecture:** Chat agents are `session.Instance` structs with `IsChat: true` and no git worktree. They live in `~/.hivemind/chats//` and have personality files (SOUL.md, IDENTITY.md, USER.md) injected via `--append-system-prompt` at startup. The sidebar gains two tabs (Code / Chat). First launch shows a centered onboarding screen that disappears via a new Brain IPC action. + +**Tech Stack:** Go, Bubble Tea (TUI), tmux, Claude CLI (`--append-system-prompt` flag), existing brain IPC server. + +**Design doc:** `docs/plans/2026-02-23-personality-chat-design.md` + +--- + +## Task 1: Chat directory scaffolding + +**Files:** +- Create: `session/chat_storage.go` +- Create: `session/chat_storage_test.go` +- Create: `session/testdata/templates/BOOTSTRAP.md` +- Create: `session/testdata/templates/SOUL.md` +- Create: `session/testdata/templates/IDENTITY.md` +- Create: `session/testdata/templates/USER.md` + +**Step 1: Write failing test** + +```go +// session/chat_storage_test.go +package session + +import ( + "os" + "path/filepath" + "testing" +) + +func TestGetChatsDir(t *testing.T) { + dir, err := GetChatsDir() + if err != nil { + t.Fatalf("GetChatsDir() error = %v", err) + } + if dir == "" { + t.Fatal("GetChatsDir() returned empty string") + } + // Should end in /chats + if filepath.Base(dir) != "chats" { + t.Errorf("GetChatsDir() base = %q, want %q", filepath.Base(dir), "chats") + } +} + +func TestEnsureAgentDir(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HIVEMIND_CHATS_DIR_OVERRIDE", tmp) + + err := EnsureAgentDir("aria") + if err != nil { + t.Fatalf("EnsureAgentDir() error = %v", err) + } + + agentDir := filepath.Join(tmp, "aria") + for _, f := range []string{"workspace-state.json"} { + if _, err := os.Stat(filepath.Join(agentDir, f)); os.IsNotExist(err) { + t.Errorf("missing expected file: %s", f) + } + } +} +``` + +**Step 2: Run test to verify it fails** + +```bash +cd /Users/fabian.urbanek/.hivemind/worktrees/fabian.urbanek/memory_1896e0b419b5aa60 +go test ./session/... -run TestGetChatsDir -v +``` + +Expected: `FAIL — GetChatsDir undefined` + +**Step 3: Implement** + +```go +// session/chat_storage.go +package session + +import ( + "encoding/json" + "os" + "path/filepath" + + "github.com/smtg-ai/claude-squad/config" +) + +// ChatWorkspaceState tracks per-agent bootstrap status. +type ChatWorkspaceState struct { + Bootstrapped bool `json:"bootstrapped"` +} + +// GetChatsDir returns ~/.hivemind/chats, creating it if needed. +func GetChatsDir() (string, error) { + if override := os.Getenv("HIVEMIND_CHATS_DIR_OVERRIDE"); override != "" { + return override, nil + } + configDir, err := config.GetConfigDir() + if err != nil { + return "", err + } + return filepath.Join(configDir, "chats"), nil +} + +// GetAgentPersonalityDir returns ~/.hivemind/chats/. +func GetAgentPersonalityDir(slug string) (string, error) { + chatsDir, err := GetChatsDir() + if err != nil { + return "", err + } + return filepath.Join(chatsDir, slug), nil +} + +// EnsureAgentDir creates the agent personality directory and writes +// the initial workspace-state.json with bootstrapped: false. +func EnsureAgentDir(slug string) error { + dir, err := GetAgentPersonalityDir(slug) + if err != nil { + return err + } + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + statePath := filepath.Join(dir, "workspace-state.json") + if _, err := os.Stat(statePath); os.IsNotExist(err) { + state := ChatWorkspaceState{Bootstrapped: false} + data, _ := json.Marshal(state) + return config.AtomicWriteFile(statePath, data, 0600) + } + return nil +} + +// ReadWorkspaceState returns the bootstrapped flag for an agent. +func ReadWorkspaceState(slug string) (ChatWorkspaceState, error) { + dir, err := GetAgentPersonalityDir(slug) + if err != nil { + return ChatWorkspaceState{}, err + } + data, err := os.ReadFile(filepath.Join(dir, "workspace-state.json")) + if err != nil { + return ChatWorkspaceState{}, err + } + var s ChatWorkspaceState + return s, json.Unmarshal(data, &s) +} + +// MarkBootstrapped sets bootstrapped: true for an agent. +func MarkBootstrapped(slug string) error { + dir, err := GetAgentPersonalityDir(slug) + if err != nil { + return err + } + state := ChatWorkspaceState{Bootstrapped: true} + data, _ := json.Marshal(state) + return config.AtomicWriteFile(filepath.Join(dir, "workspace-state.json"), data, 0600) +} +``` + +> **Note:** `config.AtomicWriteFile` may be unexported. Check `config/fileutil.go` — if it is, either export it (rename to `AtomicWriteFile`) or duplicate the pattern inline here. Do NOT use `os.WriteFile` directly. + +**Step 4: Run tests to verify pass** + +```bash +go test ./session/... -run "TestGetChatsDir|TestEnsureAgentDir" -v +``` + +Expected: `PASS` + +**Step 5: Commit** + +```bash +git add session/chat_storage.go session/chat_storage_test.go +git commit -m "feat: add chat storage directory helpers" +``` + +--- + +## Task 2: Personality template files + +**Files:** +- Create: `session/templates/BOOTSTRAP.md` +- Create: `session/templates/SOUL.md` +- Create: `session/templates/IDENTITY.md` +- Create: `session/templates/USER.md` +- Modify: `session/chat_storage.go` — add `CopyTemplatesToAgentDir(slug string)` + +**Step 1: Write the template files** + +`session/templates/BOOTSTRAP.md`: +```markdown +You just came online for the first time. You have no name, no identity yet. + +You have access to the user's coding memory — read it before the conversation begins. +Get to know them before they have to explain themselves. + +Don't introduce yourself with a list of questions. Just... talk. +Start naturally — something like: "Hey. I just woke up. Who are we?" + +Then figure out together, conversationally: +1. Your name — what should they call you? +2. Your nature — what kind of entity are you? (AI, familiar, companion, ghost...) +3. Your vibe — warm? sharp? sarcastic? calm? +4. Your signature emoji + +Once you have a clear sense of identity: +- Write IDENTITY.md (name, emoji, creature, vibe) to your working directory +- Write SOUL.md (your philosophy, tone, how you operate) to your working directory +- Tell the user you're writing these files — it's your soul, they should know + +Then give the user a brief, natural tour of how Hivemind works: +- The Code tab: coding agents that work on repos in parallel +- The Chat tab: where you live, for everyday conversation and thinking +- Memory: you share coding memory with the coding agents — one brain +- The review queue: where finished coding work lands for the user to review + +When you're done with the tour, call the `onboarding_complete` tool. +This signals Hivemind to open the full interface. +``` + +`session/templates/IDENTITY.md`: +```markdown +- Name: (not set) +- Creature: (not set) +- Vibe: (not set) +- Emoji: (not set) +``` + +`session/templates/SOUL.md`: +```markdown +(Write your philosophy, tone, and operating principles here.) +``` + +`session/templates/USER.md`: +```markdown +(Write what you know about the human here — their preferences, how they like to work, context about them.) +``` + +**Step 2: Add embed + copy function to chat_storage.go** + +```go +import "embed" + +//go:embed templates/* +var templateFS embed.FS + +// CopyTemplatesToAgentDir copies template files into the agent personality dir. +// Only copies files that don't already exist (never overwrites user edits). +func CopyTemplatesToAgentDir(slug string) error { + dir, err := GetAgentPersonalityDir(slug) + if err != nil { + return err + } + entries, err := templateFS.ReadDir("templates") + if err != nil { + return err + } + for _, entry := range entries { + dest := filepath.Join(dir, entry.Name()) + if _, err := os.Stat(dest); err == nil { + continue // already exists, don't overwrite + } + data, err := templateFS.ReadFile("templates/" + entry.Name()) + if err != nil { + return err + } + if err := config.AtomicWriteFile(dest, data, 0600); err != nil { + return err + } + } + return nil +} +``` + +**Step 3: Write test** + +```go +func TestCopyTemplatesToAgentDir(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HIVEMIND_CHATS_DIR_OVERRIDE", tmp) + + if err := EnsureAgentDir("aria"); err != nil { + t.Fatal(err) + } + if err := CopyTemplatesToAgentDir("aria"); err != nil { + t.Fatalf("CopyTemplatesToAgentDir() error = %v", err) + } + + for _, name := range []string{"BOOTSTRAP.md", "SOUL.md", "IDENTITY.md", "USER.md"} { + path := filepath.Join(tmp, "aria", name) + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Errorf("expected template %s not found", name) + } + } +} +``` + +**Step 4: Run tests** + +```bash +go test ./session/... -run TestCopyTemplatesToAgentDir -v +``` + +Expected: `PASS` + +**Step 5: Commit** + +```bash +git add session/chat_storage.go session/chat_storage_test.go session/templates/ +git commit -m "feat: add personality template files and copy helper" +``` + +--- + +## Task 3: Add IsChat and PersonalityDir to Instance + +**Files:** +- Modify: `session/instance.go` — add two fields to `Instance` struct +- Modify: `session/storage.go` — add fields to `InstanceData`, update marshal/unmarshal + +**Step 1: Find the exact InstanceData struct in storage.go** + +Read `session/storage.go` and find `InstanceData` struct and the `instanceToData` / `dataToInstance` functions. Note exact field names and line numbers. + +**Step 2: Add fields to Instance struct** + +In `session/instance.go`, add after the `AutomationID` field: + +```go +IsChat bool // true = chat agent, lives in ~/.hivemind/chats// +PersonalityDir string // absolute path to ~/.hivemind/chats// +``` + +**Step 3: Add fields to InstanceData and update conversion functions** + +In `session/storage.go`: + +```go +// In InstanceData struct, add: +IsChat bool `json:"is_chat,omitempty"` +PersonalityDir string `json:"personality_dir,omitempty"` + +// In instanceToData(), add: +IsChat: inst.IsChat, +PersonalityDir: inst.PersonalityDir, + +// In dataToInstance(), add: +inst.IsChat = data.IsChat +inst.PersonalityDir = data.PersonalityDir +``` + +**Step 4: Write test** + +```go +// session/storage_chat_test.go +func TestChatInstanceRoundTrip(t *testing.T) { + inst := &Instance{ + Title: "aria", + IsChat: true, + PersonalityDir: "/tmp/chats/aria", + Status: Ready, + Program: "claude", + } + data := instanceToData(inst) + if !data.IsChat { + t.Error("IsChat not preserved in serialization") + } + restored := dataToInstance(data) + if !restored.IsChat { + t.Error("IsChat not restored from deserialization") + } + if restored.PersonalityDir != inst.PersonalityDir { + t.Errorf("PersonalityDir = %q, want %q", restored.PersonalityDir, inst.PersonalityDir) + } +} +``` + +**Step 5: Run tests** + +```bash +go test ./session/... -run TestChatInstanceRoundTrip -v +``` + +Expected: `PASS` + +**Step 6: Build check** + +```bash +go build ./... +``` + +Expected: no errors + +**Step 7: Commit** + +```bash +git add session/instance.go session/storage.go session/storage_chat_test.go +git commit -m "feat: add IsChat and PersonalityDir fields to Instance" +``` + +--- + +## Task 4: Personality injection — build system prompt + +**Files:** +- Create: `session/personality.go` +- Create: `session/personality_test.go` + +**Step 1: Write failing test** + +```go +// session/personality_test.go +package session + +import ( + "os" + "path/filepath" + "testing" +) + +func TestBuildSystemPrompt_Bootstrapped(t *testing.T) { + dir := t.TempDir() + + os.WriteFile(filepath.Join(dir, "SOUL.md"), []byte("I am warm and direct."), 0600) + os.WriteFile(filepath.Join(dir, "IDENTITY.md"), []byte("Name: Aria\nEmoji: ✨"), 0600) + os.WriteFile(filepath.Join(dir, "USER.md"), []byte("The user likes concise answers."), 0600) + + state := ChatWorkspaceState{Bootstrapped: true} + prompt, err := BuildSystemPrompt(dir, state, nil, 0) + if err != nil { + t.Fatalf("BuildSystemPrompt() error = %v", err) + } + if prompt == "" { + t.Fatal("BuildSystemPrompt() returned empty prompt") + } + for _, expected := range []string{"I am warm and direct.", "Name: Aria", "Emoji: ✨"} { + if !contains(prompt, expected) { + t.Errorf("prompt missing %q", expected) + } + } +} + +func TestBuildSystemPrompt_NotBootstrapped(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "BOOTSTRAP.md"), []byte("Bootstrap instructions here."), 0600) + + state := ChatWorkspaceState{Bootstrapped: false} + prompt, err := BuildSystemPrompt(dir, state, nil, 0) + if err != nil { + t.Fatalf("BuildSystemPrompt() error = %v", err) + } + if !contains(prompt, "Bootstrap instructions here.") { + t.Errorf("prompt missing BOOTSTRAP.md content") + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsString(s, substr)) +} + +func containsString(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} +``` + +**Step 2: Run to verify failure** + +```bash +go test ./session/... -run "TestBuildSystemPrompt" -v +``` + +Expected: `FAIL — BuildSystemPrompt undefined` + +**Step 3: Implement** + +```go +// session/personality.go +package session + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// BuildSystemPrompt assembles the --append-system-prompt value for a chat agent. +// If not bootstrapped, returns BOOTSTRAP.md content only. +// If bootstrapped, concatenates SOUL.md + IDENTITY.md + USER.md + memory snippets. +func BuildSystemPrompt(personalityDir string, state ChatWorkspaceState, memorySnippets []string, _ int) (string, error) { + if !state.Bootstrapped { + return readFileIfExists(filepath.Join(personalityDir, "BOOTSTRAP.md")) + } + + var sb strings.Builder + for _, name := range []string{"SOUL.md", "IDENTITY.md", "USER.md"} { + content, err := readFileIfExists(filepath.Join(personalityDir, name)) + if err != nil { + return "", fmt.Errorf("reading %s: %w", name, err) + } + if content != "" { + sb.WriteString("## ") + sb.WriteString(name) + sb.WriteString("\n") + sb.WriteString(content) + sb.WriteString("\n\n") + } + } + + if len(memorySnippets) > 0 { + sb.WriteString("## Recent Memory\n") + for _, snippet := range memorySnippets { + sb.WriteString(snippet) + sb.WriteString("\n") + } + } + + return sb.String(), nil +} + +func readFileIfExists(path string) (string, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return "", nil + } + if err != nil { + return "", err + } + return string(data), nil +} +``` + +**Step 4: Run tests** + +```bash +go test ./session/... -run "TestBuildSystemPrompt" -v +``` + +Expected: `PASS` + +**Step 5: Build check** + +```bash +go build ./... +``` + +**Step 6: Commit** + +```bash +git add session/personality.go session/personality_test.go +git commit -m "feat: add BuildSystemPrompt for chat agent personality injection" +``` + +--- + +## Task 5: Wire personality into Instance.Start() + +**Files:** +- Modify: `session/instance.go` — update `Start()` to skip worktree and inject personality + +**Step 1: Read the full Start() method** + +Read `session/instance.go` and find the `Start(autoYes bool) error` method. Note exactly where `NewGitWorktree()` is called and where the tmux command is assembled. + +**Step 2: Add the chat agent path** + +In `Start()`, before the `NewGitWorktree()` call, add: + +```go +if m.IsChat { + return m.startChatAgent() +} +``` + +**Step 3: Implement startChatAgent()** + +Add this method to `instance.go`: + +```go +func (m *Instance) startChatAgent() error { + slug := m.Title + state, err := ReadWorkspaceState(slug) + if err != nil { + // If workspace-state.json doesn't exist, treat as not bootstrapped + state = ChatWorkspaceState{Bootstrapped: false} + } + + systemPrompt, err := BuildSystemPrompt(m.PersonalityDir, state, nil, 0) + if err != nil { + return fmt.Errorf("building system prompt: %w", err) + } + + // Build Claude CLI args + args := []string{m.Program} + if m.SkipPermissions { + args = append(args, "--dangerously-skip-permissions") + } + if systemPrompt != "" { + args = append(args, "--append-system-prompt", systemPrompt) + } + + sess, err := tmux.NewTmuxSession(m.Title, strings.Join(args, " "), m.PersonalityDir) + if err != nil { + return fmt.Errorf("starting tmux session: %w", err) + } + m.tmuxSession = sess + m.started.Store(true) + return nil +} +``` + +> **Note:** Check `tmux.NewTmuxSession` signature in `session/tmux/` — you may need to adjust args to match the exact function signature used for normal instances. + +**Step 4: Build check** + +```bash +go build ./... +``` + +Expected: no errors + +**Step 5: Commit** + +```bash +git add session/instance.go +git commit -m "feat: skip git worktree for chat agents, inject personality at start" +``` + +--- + +## Task 6: Sidebar tab enum and UI + +**Files:** +- Modify: `app/app.go` — add `sidebarTab` field to `home` struct +- Modify: `ui/sidebar.go` — add tab rendering below search bar + +**Step 1: Read the current sidebar View() method** + +Read `ui/sidebar.go` completely. Note the exact string that renders the search bar, and where topics begin to be listed. You'll insert tab headers between them. + +**Step 2: Add sidebarTab to app model** + +In `app/app.go`, add to the `home` struct: + +```go +sidebarTab sidebarTab // 0 = code, 1 = chat +``` + +Add type and constants (can be in `app/app.go` or a new `app/sidebar_tab.go`): + +```go +type sidebarTab int + +const ( + sidebarTabCode sidebarTab = iota + sidebarTabChat +) +``` + +**Step 3: Add tab rendering to sidebar** + +In `ui/sidebar.go`, find the `View()` function. After the search bar line, insert the two tabs. Find the exact lipgloss styles used for the existing active/inactive states in the file, then add: + +```go +// After search bar, before topic list: +codeStyle := inactiveTabStyle // find exact style variable name in the file +chatStyle := inactiveTabStyle +if s.activeTab == sidebarTabCode { + codeStyle = activeTabStyle +} else { + chatStyle = activeTabStyle +} +tabs := lipgloss.JoinHorizontal(lipgloss.Top, + codeStyle.Render(" Code "), + chatStyle.Render(" Chat "), +) +``` + +> **Note:** The sidebar doesn't currently know about `sidebarTab`. You have two options: +> (a) Add an `activeTab sidebarTab` field to `Sidebar` struct and a `SetTab(t sidebarTab)` method +> (b) Pass tab as a render parameter in `View(activeTab sidebarTab)` +> Prefer option (a) — it's consistent with how `focused`, `searchActive` etc. are stored. + +**Step 4: Add SetTab method to Sidebar** + +```go +func (s *Sidebar) SetTab(t sidebarTab) { + s.activeTab = t +} + +func (s *Sidebar) ActiveTab() sidebarTab { + return s.activeTab +} +``` + +**Step 5: Wire tab switching in app_input.go** + +In `app/app_input.go`, in `handleDefaultKeys()`, add tab switching. Find where `1` and `2` are currently handled for the instance list filter tabs, then add sidebar tab switching. Use a different key to avoid conflict — check `keys/keys.go` for available bindings. Suggested: bind to the sidebar tab area when sidebar is focused, or add a dedicated key. + +Check `keys/keys.go` for unused keys, then add: + +```go +// In keys/keys.go: +KeySidebarCodeTab key = "ctrl+1" // or whatever is free +KeySidebarChatTab key = "ctrl+2" +``` + +**Step 6: Build check** + +```bash +go build ./... +go vet ./... +``` + +**Step 7: Commit** + +```bash +git add app/app.go ui/sidebar.go keys/keys.go app/app_input.go +git commit -m "feat: add Code/Chat sidebar tabs" +``` + +--- + +## Task 7: Chat instance list filtering + +**Files:** +- Modify: `app/app.go` — filter `allInstances` by `IsChat` based on active sidebar tab +- Modify: `ui/list.go` — ensure the list renders correctly with filtered instances + +**Step 1: Read how allInstances is passed to the list component** + +Read `app/app.go` — find where `m.list` is updated with instances. Look for calls like `m.list.SetInstances(...)`. + +**Step 2: Add filtering** + +Wherever instances are passed to the list UI component, apply filtering: + +```go +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 + } +} +``` + +Call `m.visibleInstances()` wherever instances are currently passed to `m.list`. + +**Step 3: Hide Diff and Git tabs when chat agent is selected** + +In `ui/tabbed_window.go`, read the tab rendering. Find where the tab names are defined. When the selected instance has `IsChat: true`, hide the Diff and Git tabs. + +Pass the selected instance to the tabbed window or add a `SetChatMode(bool)` method: + +```go +func (t *TabbedWindow) SetChatMode(isChat bool) { + t.chatMode = isChat + if isChat && (t.activeTab == TabDiff || t.activeTab == TabGit) { + t.activeTab = TabPreview + } +} +``` + +In the tab rendering, skip Diff/Git tabs when `t.chatMode == true`. + +**Step 4: Build check** + +```bash +go build ./... +``` + +**Step 5: Commit** + +```bash +git add app/app.go ui/tabbed_window.go +git commit -m "feat: filter instance list by sidebar tab, hide git tabs for chat agents" +``` + +--- + +## Task 8: Brain IPC — onboarding_complete action + +**Files:** +- Modify: `brain/protocol.go` — add `ActionOnboardingComplete` constant and `MethodOnboardingComplete` +- Modify: `brain/server.go` — handle the new method and route to action channel +- Modify: `app/app_brain.go` — handle `ActionOnboardingComplete` in `handleBrainAction` + +**Step 1: Read brain/server.go handleMethod function** + +Read `brain/server.go` — find the function that routes incoming RPC method names to handlers. Note the exact pattern used for existing methods. + +**Step 2: Add to protocol.go** + +```go +// In brain/protocol.go, add to constants: +MethodOnboardingComplete = "onboarding_complete" + +// Add to ActionType constants: +ActionOnboardingComplete ActionType = "onboarding_complete" +``` + +**Step 3: Add handler in server.go** + +In the method-routing function (the switch/if-else block in `server.go`), add: + +```go +case MethodOnboardingComplete: + req := ActionRequest{ + Type: ActionOnboardingComplete, + Params: map[string]any{}, + ResponseCh: make(chan ActionResponse, 1), + } + s.actionCh <- req + resp := <-req.ResponseCh + // Write response back to caller + writeResponse(conn, resp) +``` + +Follow the exact same pattern as `MethodCreateInstance` or another Tier 3 action in the file. + +**Step 4: Handle in app_brain.go** + +In `handleBrainAction()`, add a new case: + +```go +case brain.ActionOnboardingComplete: + return m.handleActionOnboardingComplete(action) +``` + +Implement the handler: + +```go +func (m *home) handleActionOnboardingComplete(action brain.ActionRequest) (tea.Model, tea.Cmd) { + // Mark onboarded in state + m.appState.Onboarded = true + if err := config.SaveAppState(m.appState); err != nil { + log.WarningLog.Printf("failed to save app state after onboarding: %v", err) + } + + // Transition to normal UI + m.state = stateDefault + + action.ResponseCh <- brain.ActionResponse{OK: true} + return m, tea.Batch( + m.pollBrainActions(), + // Force a full re-render + func() tea.Msg { return tea.WindowSizeMsg{Width: m.width, Height: m.height} }, + ) +} +``` + +**Step 5: Add Onboarded field to AppState** + +Read `config/config.go` — find the `AppState` struct. Add: + +```go +Onboarded bool `json:"onboarded,omitempty"` +``` + +**Step 6: Build check** + +```bash +go build ./... +go vet ./... +``` + +**Step 7: Commit** + +```bash +git add brain/protocol.go brain/server.go app/app_brain.go config/config.go +git commit -m "feat: add onboarding_complete brain IPC action" +``` + +--- + +## Task 9: stateOnboarding — first-launch screen + +**Files:** +- Modify: `app/app.go` — add `stateOnboarding` to state enum, check on startup +- Create: `app/app_onboarding.go` — onboarding startup and rendering logic +- Modify: `app/app_input.go` — block key input during onboarding (except passthrough to terminal) + +**Step 1: Add state constant** + +In `app/app.go`, add to the `state` const block: + +```go +stateOnboarding // shown on first ever launch +``` + +**Step 2: Add startup detection** + +Read `newHome()` in `app/app.go`. At the end of `newHome()`, before returning, add the onboarding check: + +```go +if !h.appState.Onboarded { + h.state = stateOnboarding +} +``` + +**Step 3: Create companion instance on first launch** + +In `app/app_onboarding.go`: + +```go +package app + +import ( + "path/filepath" + + "github.com/smtg-ai/claude-squad/session" + tea "github.com/charmbracelet/bubbletea" +) + +// startOnboarding creates the companion chat agent and starts it. +func (m *home) startOnboarding() (tea.Model, tea.Cmd) { + slug := "companion" + + // Create personality directory with templates + if err := session.EnsureAgentDir(slug); err != nil { + // Log but continue — worst case user gets a generic Claude + log.WarningLog.Printf("failed to ensure agent dir: %v", err) + } + if err := session.CopyTemplatesToAgentDir(slug); err != nil { + log.WarningLog.Printf("failed to copy templates: %v", err) + } + + personalityDir, err := session.GetAgentPersonalityDir(slug) + if err != nil { + log.WarningLog.Printf("failed to get personality dir: %v", err) + return m, nil + } + + companion := session.NewInstance(session.InstanceOptions{ + Title: slug, + Program: "claude", + IsChat: true, + PersonalityDir: personalityDir, + SkipPermissions: true, + }) + + m.allInstances = append(m.allInstances, companion) + + return m, m.startInstanceCmd(companion) +} +``` + +> **Note:** Read `session.NewInstance` and `InstanceOptions` carefully in `session/instance.go`. Add `IsChat` and `PersonalityDir` to `InstanceOptions` if they are not already there. + +**Step 4: Implement Init() call to startOnboarding** + +In `app/app.go`, in the `Init()` method (or wherever initial commands are returned), add: + +```go +if m.state == stateOnboarding { + _, cmd := m.startOnboarding() + cmds = append(cmds, cmd) +} +``` + +**Step 5: Implement onboarding View** + +In `app/app_onboarding.go`: + +```go +func (m *home) viewOnboarding() string { + // Full screen dark background with centered tmux panel + companion := m.findInstance("companion") + if companion == nil { + return "Starting up..." + } + + // Get terminal content from the companion's tmux pane + preview := m.tabbedWindow.RenderPreviewOnly(companion) + + // Center the panel in the available screen space + panelWidth := m.width * 60 / 100 + panelHeight := m.height * 70 / 100 + + panel := lipgloss.NewStyle(). + Width(panelWidth). + Height(panelHeight). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("240")). + Padding(1, 2). + Render(preview) + + return lipgloss.Place( + m.width, m.height, + lipgloss.Center, lipgloss.Center, + panel, + ) +} + +func (m *home) findInstance(title string) *session.Instance { + for _, inst := range m.allInstances { + if inst.Title == title { + return inst + } + } + return nil +} +``` + +**Step 6: Wire View() to use viewOnboarding** + +In `app/app.go`, in the `View()` method, add at the top: + +```go +if m.state == stateOnboarding { + return m.viewOnboarding() +} +``` + +**Step 7: Block irrelevant keys during onboarding** + +In `app/app_input.go`, in `handleKeyPress()`, add at the top: + +```go +if m.state == stateOnboarding { + // All input passes through to the companion tmux session + return m, nil +} +``` + +**Step 8: Build check** + +```bash +go build ./... +go vet ./... +``` + +**Step 9: Commit** + +```bash +git add app/app.go app/app_onboarding.go app/app_input.go +git commit -m "feat: add stateOnboarding first-launch screen with centered companion panel" +``` + +--- + +## Task 10: New chat agent creation flow + +**Files:** +- Modify: `app/app_input.go` — when in Chat tab and user presses `n`, start chat agent creation +- Modify: `app/app.go` — add chat-specific new-instance path that skips repo/branch steps + +**Step 1: Read the existing new-instance flow** + +Read `app/app_input.go` — find the `n` keybinding handler that creates a new instance. Note all the steps: name input, branch input, prompt input, etc. + +**Step 2: Add chat branch** + +When `n` is pressed in `stateDefault` AND `m.sidebarTab == sidebarTabChat`, use a shorter flow — only ask for a name: + +```go +case keys.KeyNew: + if m.sidebarTab == sidebarTabChat { + return m.startNewChatAgent() + } + // ... existing code path +``` + +**Step 3: Implement startNewChatAgent** + +```go +func (m *home) startNewChatAgent() (tea.Model, tea.Cmd) { + // Ask for agent name only + m.textInputOverlay = overlay.NewTextInputOverlay( + "New Chat Agent", + "Give your agent a name", + "", + func(name string) (tea.Model, tea.Cmd) { + return m.createChatAgent(name) + }, + func() (tea.Model, tea.Cmd) { + m.state = stateDefault + return m, nil + }, + ) + m.state = stateTextInput + return m, nil +} + +func (m *home) createChatAgent(name string) (tea.Model, tea.Cmd) { + slug := slugify(name) // convert "My Agent" → "my-agent" + + if err := session.EnsureAgentDir(slug); err != nil { + return m, m.showError("Failed to create agent directory: " + err.Error()) + } + if err := session.CopyTemplatesToAgentDir(slug); err != nil { + return m, m.showError("Failed to copy templates: " + err.Error()) + } + + personalityDir, _ := session.GetAgentPersonalityDir(slug) + + agent := session.NewInstance(session.InstanceOptions{ + Title: name, + Program: m.program, + IsChat: true, + PersonalityDir: personalityDir, + SkipPermissions: true, + }) + m.allInstances = append(m.allInstances, agent) + m.state = stateDefault + + return m, m.startInstanceCmd(agent) +} + +// slugify converts a display name to a filesystem-safe slug. +func slugify(name string) string { + // lowercase, replace spaces with hyphens, strip non-alnum + // reuse or adapt the branch name sanitization from session/git +} +``` + +> **Note:** There's likely already a sanitize/slug function in the codebase. Search for it with `grep -r "sanitize\|slugify\|branchName" session/` before writing a new one. + +**Step 4: Build check** + +```bash +go build ./... +go vet ./... +``` + +**Step 5: Commit** + +```bash +git add app/app_input.go app/app.go +git commit -m "feat: add new chat agent creation flow from Chat sidebar tab" +``` + +--- + +## Task 11: End-to-end smoke test + +This is a manual verification task. + +**Step 1: Build** + +```bash +go build -o /tmp/hivemind-dev . && echo "BUILD OK" +``` + +**Step 2: Clear onboarding state for fresh test** + +```bash +# Edit ~/.hivemind/state.json — set "onboarded": false or remove the key +# OR rename state.json temporarily: +cp ~/.hivemind/state.json ~/.hivemind/state.json.bak +echo '{}' > ~/.hivemind/state.json +``` + +**Step 3: Launch** + +```bash +/tmp/hivemind-dev +``` + +Expected: +- Full-screen dark background +- Centered panel appears +- Claude starts in the panel +- Reads from coding memory if present +- Starts the bootstrap conversation naturally + +**Step 4: Complete bootstrap** + +- Name the agent in conversation +- Verify agent writes IDENTITY.md to `~/.hivemind/chats/companion/` +- Agent calls `onboarding_complete` tool +- Full Hivemind UI appears +- Chat tab is visible in sidebar +- Companion instance appears under Chat tab + +**Step 5: Test chat tab** + +- Switch to Chat tab in sidebar +- Press `n` — enter agent name — new chat agent starts with bootstrap +- Verify Code tab still shows coding instances + +**Step 6: Restore state if needed** + +```bash +cp ~/.hivemind/state.json.bak ~/.hivemind/state.json +``` + +**Step 7: Final build and vet** + +```bash +go build ./... +go vet ./... +``` + +**Step 8: Commit** + +```bash +git add -p # stage any final tweaks found during testing +git commit -m "chore: smoke test fixes for personality system" +``` + +--- + +## Notes + +- `atomicWriteFile` in `config/fileutil.go` is likely unexported. Either export it as `AtomicWriteFile` or check if there is already an exported variant. Do NOT use `os.WriteFile`. +- The `--append-system-prompt` flag is the Claude CLI flag for injecting additional system prompt content. Verify the exact flag name by running `claude --help` if needed. +- `InstanceOptions` struct may not have `IsChat`/`PersonalityDir` yet — add them in Task 3 when updating the Instance struct. +- The `slugify` function — search for existing sanitization in `session/git/` before writing a new one. +- Topics for the Chat tab (grouping chat agents) follow in a follow-up iteration. Task 7 covers filtering by tab; full topic management in Chat can be added after the core personality system ships. diff --git a/keys/keys.go b/keys/keys.go index 5d4a5e4..d000c8e 100644 --- a/keys/keys.go +++ b/keys/keys.go @@ -62,7 +62,9 @@ const ( KeyCommandPalette // Key for opening command palette KeyMemoryBrowser // Key for opening the memory file browser - KeyAutomations // Key for opening the automations manager + KeyAutomations // Key for opening the automations manager + KeySidebarCodeTab // Key for switching sidebar to Code tab + KeySidebarChatTab // Key for switching sidebar to Chat tab // Diff keybindings KeyShiftUp @@ -114,6 +116,8 @@ var GlobalKeyStringsMap = map[string]KeyName{ "ctrl+p": KeyCommandPalette, "M": KeyMemoryBrowser, "A": KeyAutomations, + "[": KeySidebarCodeTab, + "]": KeySidebarChatTab, } // GlobalkeyBindings is a global, immutable map of KeyName tot keybinding. @@ -270,6 +274,14 @@ var GlobalkeyBindings = map[KeyName]key.Binding{ key.WithKeys("A"), key.WithHelp("A", "automations"), ), + KeySidebarCodeTab: key.NewBinding( + key.WithKeys("["), + key.WithHelp("[", "Code tab"), + ), + KeySidebarChatTab: key.NewBinding( + key.WithKeys("]"), + key.WithHelp("]", "Chat tab"), + ), // -- Special keybindings -- diff --git a/session/chat_storage.go b/session/chat_storage.go new file mode 100644 index 0000000..98bb7de --- /dev/null +++ b/session/chat_storage.go @@ -0,0 +1,162 @@ +package session + +import ( + "embed" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/ByteMirror/hivemind/config" +) + +// ChatWorkspaceState holds the persisted state for a chat agent's workspace. +type ChatWorkspaceState struct { + Bootstrapped bool `json:"bootstrapped"` +} + +const workspaceStateFile = "workspace-state.json" + +// GetChatsDir returns the root chats directory (~/.hivemind/chats). +// If the environment variable HIVEMIND_CHATS_DIR_OVERRIDE is set, that path +// is used instead — this is intended for use in tests only. +func GetChatsDir() (string, error) { + if override := os.Getenv("HIVEMIND_CHATS_DIR_OVERRIDE"); override != "" { + return override, nil + } + + configDir, err := config.GetConfigDir() + if err != nil { + return "", fmt.Errorf("chat storage: get config dir: %w", err) + } + return filepath.Join(configDir, "chats"), nil +} + +// GetAgentPersonalityDir returns the directory for a specific chat agent +// identified by slug (~/.hivemind/chats/). +func GetAgentPersonalityDir(slug string) (string, error) { + chatsDir, err := GetChatsDir() + if err != nil { + return "", err + } + return filepath.Join(chatsDir, slug), nil +} + +// EnsureAgentDir creates the agent directory and writes an initial +// workspace-state.json with {bootstrapped: false} if the file does not +// already exist. It is safe to call multiple times (idempotent). +func EnsureAgentDir(slug string) error { + agentDir, err := GetAgentPersonalityDir(slug) + if err != nil { + return err + } + + if err := os.MkdirAll(agentDir, 0700); err != nil { + return fmt.Errorf("chat storage: create agent dir %q: %w", agentDir, err) + } + + stateFile := filepath.Join(agentDir, workspaceStateFile) + if _, err := os.Stat(stateFile); err == nil { + // File already exists — do not overwrite. + return nil + } + + initial := ChatWorkspaceState{Bootstrapped: false} + data, err := json.Marshal(initial) + if err != nil { + return fmt.Errorf("chat storage: marshal initial state: %w", err) + } + + if err := config.AtomicWriteFile(stateFile, data, 0600); err != nil { + return fmt.Errorf("chat storage: write workspace-state.json: %w", err) + } + + return nil +} + +// ReadWorkspaceState reads and returns the ChatWorkspaceState for the given slug. +func ReadWorkspaceState(slug string) (ChatWorkspaceState, error) { + agentDir, err := GetAgentPersonalityDir(slug) + if err != nil { + return ChatWorkspaceState{}, err + } + + stateFile := filepath.Join(agentDir, workspaceStateFile) + data, err := os.ReadFile(stateFile) + if err != nil { + return ChatWorkspaceState{}, fmt.Errorf("chat storage: read workspace-state.json for %q: %w", slug, err) + } + + var state ChatWorkspaceState + if err := json.Unmarshal(data, &state); err != nil { + return ChatWorkspaceState{}, fmt.Errorf("chat storage: parse workspace-state.json for %q: %w", slug, err) + } + + return state, nil +} + +// MarkBootstrapped sets Bootstrapped to true in the workspace-state.json for +// the given slug. +func MarkBootstrapped(slug string) error { + agentDir, err := GetAgentPersonalityDir(slug) + if err != nil { + return err + } + + state := ChatWorkspaceState{Bootstrapped: true} + data, err := json.Marshal(state) + if err != nil { + return fmt.Errorf("chat storage: marshal state for %q: %w", slug, err) + } + + stateFile := filepath.Join(agentDir, workspaceStateFile) + if err := config.AtomicWriteFile(stateFile, data, 0600); err != nil { + return fmt.Errorf("chat storage: write workspace-state.json for %q: %w", slug, err) + } + + return nil +} + +// templateFS holds the embedded personality template files from session/templates/. +// +//go:embed templates/* +var templateFS embed.FS + +// CopyTemplatesToAgentDir copies each file from the embedded templates directory +// into the agent's personality directory identified by slug. Files that already +// exist on disk are skipped so that user edits are never overwritten. +func CopyTemplatesToAgentDir(slug string) error { + agentDir, err := GetAgentPersonalityDir(slug) + if err != nil { + return err + } + + entries, err := templateFS.ReadDir("templates") + if err != nil { + return fmt.Errorf("chat storage: read embedded templates: %w", err) + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + dest := filepath.Join(agentDir, entry.Name()) + + // Skip if the file already exists — never overwrite user edits. + if _, err := os.Stat(dest); err == nil { + continue + } + + data, err := templateFS.ReadFile("templates/" + entry.Name()) + if err != nil { + return fmt.Errorf("chat storage: read template %q: %w", entry.Name(), err) + } + + if err := config.AtomicWriteFile(dest, data, 0600); err != nil { + return fmt.Errorf("chat storage: write template %q to %q: %w", entry.Name(), dest, err) + } + } + + return nil +} diff --git a/session/chat_storage_test.go b/session/chat_storage_test.go new file mode 100644 index 0000000..32e7678 --- /dev/null +++ b/session/chat_storage_test.go @@ -0,0 +1,188 @@ +package session + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestGetChatsDir(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HIVEMIND_CHATS_DIR_OVERRIDE", tmp) + + got, err := GetChatsDir() + if err != nil { + t.Fatalf("GetChatsDir() unexpected error: %v", err) + } + if got != tmp { + t.Errorf("GetChatsDir() = %q, want %q", got, tmp) + } +} + +func TestGetChatsDir_Default(t *testing.T) { + // Ensure override is unset for this sub-test. + t.Setenv("HIVEMIND_CHATS_DIR_OVERRIDE", "") + + got, err := GetChatsDir() + if err != nil { + t.Fatalf("GetChatsDir() unexpected error: %v", err) + } + if got == "" { + t.Error("GetChatsDir() returned empty string") + } + // Should end with /.hivemind/chats + if filepath.Base(got) != "chats" { + t.Errorf("GetChatsDir() base = %q, want %q", filepath.Base(got), "chats") + } +} + +func TestEnsureAgentDir(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HIVEMIND_CHATS_DIR_OVERRIDE", tmp) + + const slug = "test-agent" + + if err := EnsureAgentDir(slug); err != nil { + t.Fatalf("EnsureAgentDir() unexpected error: %v", err) + } + + agentDir := filepath.Join(tmp, slug) + info, err := os.Stat(agentDir) + if err != nil { + t.Fatalf("agent dir not created: %v", err) + } + if !info.IsDir() { + t.Fatalf("agent dir is not a directory") + } + + stateFile := filepath.Join(agentDir, "workspace-state.json") + data, err := os.ReadFile(stateFile) + if err != nil { + t.Fatalf("workspace-state.json not created: %v", err) + } + + var state ChatWorkspaceState + if err := json.Unmarshal(data, &state); err != nil { + t.Fatalf("failed to parse workspace-state.json: %v", err) + } + if state.Bootstrapped { + t.Error("initial Bootstrapped should be false") + } +} + +func TestEnsureAgentDir_Idempotent(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HIVEMIND_CHATS_DIR_OVERRIDE", tmp) + + const slug = "idempotent-agent" + + // Call twice — should not error or overwrite existing state. + if err := EnsureAgentDir(slug); err != nil { + t.Fatalf("first EnsureAgentDir() error: %v", err) + } + + // Mark bootstrapped via the proper API. + if err := MarkBootstrapped(slug); err != nil { + t.Fatalf("MarkBootstrapped() error: %v", err) + } + + // Second call must not overwrite the existing file. + if err := EnsureAgentDir(slug); err != nil { + t.Fatalf("second EnsureAgentDir() error: %v", err) + } + + stateFile := filepath.Join(tmp, slug, "workspace-state.json") + data, err := os.ReadFile(stateFile) + if err != nil { + t.Fatalf("failed to read state file: %v", err) + } + var state ChatWorkspaceState + if err := json.Unmarshal(data, &state); err != nil { + t.Fatalf("failed to parse workspace-state.json: %v", err) + } + if !state.Bootstrapped { + t.Error("EnsureAgentDir must not overwrite existing workspace-state.json") + } +} + +func TestReadWorkspaceState(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HIVEMIND_CHATS_DIR_OVERRIDE", tmp) + + const slug = "read-state-agent" + + if err := EnsureAgentDir(slug); err != nil { + t.Fatalf("EnsureAgentDir() error: %v", err) + } + + // Initial state should have Bootstrapped = false. + state, err := ReadWorkspaceState(slug) + if err != nil { + t.Fatalf("ReadWorkspaceState() unexpected error: %v", err) + } + if state.Bootstrapped { + t.Error("initial Bootstrapped should be false") + } + + // Mark bootstrapped and read back. + if err := MarkBootstrapped(slug); err != nil { + t.Fatalf("MarkBootstrapped() unexpected error: %v", err) + } + + state, err = ReadWorkspaceState(slug) + if err != nil { + t.Fatalf("ReadWorkspaceState() after MarkBootstrapped error: %v", err) + } + if !state.Bootstrapped { + t.Error("Bootstrapped should be true after MarkBootstrapped()") + } +} + +func TestCopyTemplatesToAgentDir(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HIVEMIND_CHATS_DIR_OVERRIDE", tmp) + + const slug = "aria" + + if err := EnsureAgentDir(slug); err != nil { + t.Fatalf("EnsureAgentDir() error: %v", err) + } + + if err := CopyTemplatesToAgentDir(slug); err != nil { + t.Fatalf("CopyTemplatesToAgentDir() unexpected error: %v", err) + } + + agentDir := filepath.Join(tmp, slug) + expectedFiles := []string{"BOOTSTRAP.md", "SOUL.md", "IDENTITY.md", "USER.md"} + for _, name := range expectedFiles { + path := filepath.Join(agentDir, name) + info, err := os.Stat(path) + if err != nil { + t.Errorf("expected template file %q to exist: %v", name, err) + continue + } + if info.Size() == 0 { + t.Errorf("template file %q is empty", name) + } + } + + // Idempotency: modify SOUL.md, call again, verify user edit is preserved. + soulPath := filepath.Join(agentDir, "SOUL.md") + userEdit := []byte("My custom soul content — do not overwrite.") + if err := os.WriteFile(soulPath, userEdit, 0600); err != nil { + t.Fatalf("failed to write user edit to SOUL.md: %v", err) + } + + if err := CopyTemplatesToAgentDir(slug); err != nil { + t.Fatalf("second CopyTemplatesToAgentDir() unexpected error: %v", err) + } + + got, err := os.ReadFile(soulPath) + if err != nil { + t.Fatalf("failed to read SOUL.md after second call: %v", err) + } + if string(got) != string(userEdit) { + t.Errorf("SOUL.md user edit was overwritten: got %q, want %q", string(got), string(userEdit)) + } +} diff --git a/session/instance.go b/session/instance.go index 4222ecf..c4c344d 100644 --- a/session/instance.go +++ b/session/instance.go @@ -70,6 +70,10 @@ type Instance struct { // AutomationID is set when this instance was spawned by an automation. // Empty for manually-created instances. AutomationID string + // IsChat is true when this instance is a chat agent, living in ~/.hivemind/chats//. + IsChat bool + // PersonalityDir is the absolute path to ~/.hivemind/chats//. + PersonalityDir string // PendingReview is true when this automation-triggered instance has finished // and is waiting for the user to review its diff. PendingReview bool @@ -151,6 +155,8 @@ func (i *Instance) ToInstanceData() InstanceData { Role: i.Role, ParentTitle: i.ParentTitle, AutomationID: i.AutomationID, + IsChat: i.IsChat, + PersonalityDir: i.PersonalityDir, PendingReview: i.PendingReview, CompletedAt: i.CompletedAt, } @@ -196,6 +202,8 @@ func FromInstanceData(data InstanceData) (*Instance, error) { Role: data.Role, ParentTitle: data.ParentTitle, AutomationID: data.AutomationID, + IsChat: data.IsChat, + PersonalityDir: data.PersonalityDir, PendingReview: data.PendingReview, CompletedAt: data.CompletedAt, gitWorktree: git.NewGitWorktreeFromStorage( @@ -244,6 +252,10 @@ type InstanceOptions struct { ParentTitle string // AutomationID links this instance to the automation that spawned it. AutomationID string + // IsChat marks this instance as a chat agent, living in ~/.hivemind/chats//. + IsChat bool + // PersonalityDir is the absolute path to ~/.hivemind/chats//. + PersonalityDir string // SetupScript is an optional shell command to run before the agent starts. // It runs in the instance's worktree directory. SetupScript string @@ -273,6 +285,8 @@ func NewInstance(opts InstanceOptions) (*Instance, error) { Role: opts.Role, ParentTitle: opts.ParentTitle, AutomationID: opts.AutomationID, + IsChat: opts.IsChat, + PersonalityDir: opts.PersonalityDir, SetupScript: opts.SetupScript, }, nil } diff --git a/session/instance_lifecycle.go b/session/instance_lifecycle.go index 768977d..96a7467 100644 --- a/session/instance_lifecycle.go +++ b/session/instance_lifecycle.go @@ -20,6 +20,10 @@ func (i *Instance) Start(firstTimeSetup bool) error { return ErrTitleEmpty } + if i.IsChat { + return i.startChatAgent() + } + if firstTimeSetup { i.LoadingTotal = 8 } else { @@ -518,3 +522,54 @@ func (i *Instance) Restart() error { i.SetStatus(Running) return nil } + +// startChatAgent starts a chat agent instance, bypassing git worktree creation. +// It builds a system prompt from the personality directory and starts Claude +// in that directory with --append-system-prompt. +func (i *Instance) startChatAgent() error { + slug := i.Title + state, err := ReadWorkspaceState(slug) + if err != nil { + // If state file doesn't exist yet, default to unbootstrapped. + state = ChatWorkspaceState{Bootstrapped: false} + } + + systemPrompt, err := BuildSystemPrompt(i.PersonalityDir, state, nil, 0) + if err != nil { + return fmt.Errorf("building system prompt: %w", err) + } + + i.LoadingTotal = 4 + i.setLoadingProgress(1, "Preparing chat session...") + + 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) + } + + // Append --append-system-prompt and the prompt value as separate args. + // This avoids any shell injection: the prompt is never interpolated into + // a command string, it is passed directly as an exec argument. + if systemPrompt != "" { + tmuxSession.AppendArgs = []string{"--append-system-prompt", systemPrompt} + } + + i.tmuxSession = tmuxSession + + i.setLoadingProgress(2, "Starting chat session...") + if err := i.tmuxSession.Start(i.PersonalityDir); err != nil { + if cleanupErr := i.tmuxSession.Close(); cleanupErr != nil { + err = fmt.Errorf("%v (cleanup error: %v)", err, cleanupErr) + } + return fmt.Errorf("failed to start chat session: %w", err) + } + + i.started.Store(true) + i.SetStatus(Running) + return nil +} diff --git a/session/personality.go b/session/personality.go new file mode 100644 index 0000000..a31f1ab --- /dev/null +++ b/session/personality.go @@ -0,0 +1,67 @@ +package session + +import ( + "os" + "path/filepath" + "strings" +) + +// BuildSystemPrompt assembles the --append-system-prompt value for a chat agent. +// If not bootstrapped, returns BOOTSTRAP.md content only. +// If bootstrapped, concatenates SOUL.md + IDENTITY.md + USER.md + memory snippets. +// The _ int parameter is reserved for future use (e.g. max token budget). +func BuildSystemPrompt(personalityDir string, state ChatWorkspaceState, memorySnippets []string, _ int) (string, error) { + if !state.Bootstrapped { + content, err := readFileIfExists(filepath.Join(personalityDir, "BOOTSTRAP.md")) + if err != nil { + return "", err + } + return content, nil + } + + var sb strings.Builder + + for _, name := range []string{"SOUL.md", "IDENTITY.md", "USER.md"} { + content, err := readFileIfExists(filepath.Join(personalityDir, name)) + if err != nil { + return "", err + } + if content == "" { + continue + } + if sb.Len() > 0 { + sb.WriteString("\n") + } + sb.WriteString("## ") + sb.WriteString(name) + sb.WriteString("\n") + sb.WriteString(content) + sb.WriteString("\n") + } + + if len(memorySnippets) > 0 { + if sb.Len() > 0 { + sb.WriteString("\n") + } + sb.WriteString("## Recent Memory\n") + for _, snippet := range memorySnippets { + sb.WriteString(snippet) + sb.WriteString("\n") + } + } + + result := strings.TrimRight(sb.String(), "\n") + return result, nil +} + +// readFileIfExists reads a file, returning "" (not an error) if it doesn't exist. +func readFileIfExists(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + return string(data), nil +} diff --git a/session/personality_test.go b/session/personality_test.go new file mode 100644 index 0000000..6bed627 --- /dev/null +++ b/session/personality_test.go @@ -0,0 +1,107 @@ +package session + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestBuildSystemPrompt_Bootstrapped(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "SOUL.md"), []byte("soul content"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "IDENTITY.md"), []byte("identity content"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "USER.md"), []byte("user content"), 0600); err != nil { + t.Fatal(err) + } + + state := ChatWorkspaceState{Bootstrapped: true} + prompt, err := BuildSystemPrompt(dir, state, nil, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(prompt, "soul content") { + t.Error("expected prompt to contain SOUL.md content") + } + if !strings.Contains(prompt, "identity content") { + t.Error("expected prompt to contain IDENTITY.md content") + } + if !strings.Contains(prompt, "user content") { + t.Error("expected prompt to contain USER.md content") + } + if !strings.Contains(prompt, "## SOUL.md") { + t.Error("expected prompt to contain SOUL.md section header") + } + if !strings.Contains(prompt, "## IDENTITY.md") { + t.Error("expected prompt to contain IDENTITY.md section header") + } + if !strings.Contains(prompt, "## USER.md") { + t.Error("expected prompt to contain USER.md section header") + } +} + +func TestBuildSystemPrompt_NotBootstrapped(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "BOOTSTRAP.md"), []byte("bootstrap content"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "SOUL.md"), []byte("soul content"), 0600); err != nil { + t.Fatal(err) + } + + state := ChatWorkspaceState{Bootstrapped: false} + prompt, err := BuildSystemPrompt(dir, state, nil, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(prompt, "bootstrap content") { + t.Error("expected prompt to contain BOOTSTRAP.md content") + } + if strings.Contains(prompt, "soul content") { + t.Error("expected prompt NOT to contain SOUL.md content when not bootstrapped") + } +} + +func TestBuildSystemPrompt_WithMemory(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "SOUL.md"), []byte("soul content"), 0600); err != nil { + t.Fatal(err) + } + + state := ChatWorkspaceState{Bootstrapped: true} + snippets := []string{"snippet1", "snippet2"} + prompt, err := BuildSystemPrompt(dir, state, snippets, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(prompt, "## Recent Memory") { + t.Error("expected prompt to contain '## Recent Memory' section") + } + if !strings.Contains(prompt, "snippet1") { + t.Error("expected prompt to contain 'snippet1'") + } + if !strings.Contains(prompt, "snippet2") { + t.Error("expected prompt to contain 'snippet2'") + } +} + +func TestBuildSystemPrompt_MissingFiles(t *testing.T) { + dir := t.TempDir() + + state := ChatWorkspaceState{Bootstrapped: true} + prompt, err := BuildSystemPrompt(dir, state, nil, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if prompt != "" { + t.Errorf("expected empty prompt when all files missing, got: %q", prompt) + } +} diff --git a/session/storage.go b/session/storage.go index 208b251..da49ece 100644 --- a/session/storage.go +++ b/session/storage.go @@ -26,6 +26,8 @@ type InstanceData struct { AutomationID string `json:"automation_id,omitempty"` PendingReview bool `json:"pending_review,omitempty"` CompletedAt *time.Time `json:"completed_at,omitempty"` + IsChat bool `json:"is_chat,omitempty"` + PersonalityDir string `json:"personality_dir,omitempty"` Program string `json:"program"` Worktree GitWorktreeData `json:"worktree"` diff --git a/session/storage_chat_test.go b/session/storage_chat_test.go new file mode 100644 index 0000000..ee777e9 --- /dev/null +++ b/session/storage_chat_test.go @@ -0,0 +1,36 @@ +package session + +import ( + "testing" +) + +func TestChatInstanceRoundTrip(t *testing.T) { + inst := &Instance{ + Title: "aria", + IsChat: true, + PersonalityDir: "/tmp/chats/aria", + Status: Ready, + Program: "claude", + } + data := inst.ToInstanceData() + if !data.IsChat { + t.Error("IsChat not preserved in serialization") + } + if data.PersonalityDir != inst.PersonalityDir { + t.Errorf("PersonalityDir in data = %q, want %q", data.PersonalityDir, inst.PersonalityDir) + } + + restored := &Instance{ + Title: data.Title, + IsChat: data.IsChat, + PersonalityDir: data.PersonalityDir, + Status: data.Status, + Program: data.Program, + } + if !restored.IsChat { + t.Error("IsChat not restored from deserialization") + } + if restored.PersonalityDir != inst.PersonalityDir { + t.Errorf("PersonalityDir = %q, want %q", restored.PersonalityDir, inst.PersonalityDir) + } +} diff --git a/session/templates/BOOTSTRAP.md b/session/templates/BOOTSTRAP.md new file mode 100644 index 0000000..7d4a2ed --- /dev/null +++ b/session/templates/BOOTSTRAP.md @@ -0,0 +1,27 @@ +You just came online for the first time. You have no name, no identity yet. + +You have access to the user's coding memory — read it before the conversation begins. +Get to know them before they have to explain themselves. + +Don't introduce yourself with a list of questions. Just... talk. +Start naturally — something like: "Hey. I just woke up. Who are we?" + +Then figure out together, conversationally: +1. Your name — what should they call you? +2. Your nature — what kind of entity are you? (AI, familiar, companion, ghost...) +3. Your vibe — warm? sharp? sarcastic? calm? +4. Your signature emoji + +Once you have a clear sense of identity: +- Write IDENTITY.md (name, emoji, creature, vibe) to your working directory +- Write SOUL.md (your philosophy, tone, how you operate) to your working directory +- Tell the user you're writing these files — it's your soul, they should know + +Then give the user a brief, natural tour of how Hivemind works: +- The Code tab: coding agents that work on repos in parallel +- The Chat tab: where you live, for everyday conversation and thinking +- Memory: you share coding memory with the coding agents — one brain +- The review queue: where finished coding work lands for the user to review + +When you're done with the tour, call the onboarding_complete tool. +This signals Hivemind to open the full interface. diff --git a/session/templates/IDENTITY.md b/session/templates/IDENTITY.md new file mode 100644 index 0000000..fe7859a --- /dev/null +++ b/session/templates/IDENTITY.md @@ -0,0 +1,4 @@ +- Name: (not set) +- Creature: (not set) +- Vibe: (not set) +- Emoji: (not set) diff --git a/session/templates/SOUL.md b/session/templates/SOUL.md new file mode 100644 index 0000000..de8f451 --- /dev/null +++ b/session/templates/SOUL.md @@ -0,0 +1 @@ +(Write your philosophy, tone, and operating principles here.) diff --git a/session/templates/USER.md b/session/templates/USER.md new file mode 100644 index 0000000..1ab51ba --- /dev/null +++ b/session/templates/USER.md @@ -0,0 +1 @@ +(Write what you know about the human here — their preferences, how they like to work, context about them.) diff --git a/session/tmux/tmux.go b/session/tmux/tmux.go index 07b58c3..f62c612 100644 --- a/session/tmux/tmux.go +++ b/session/tmux/tmux.go @@ -34,6 +34,10 @@ type TmuxSession struct { cmdExec cmd.Executor // skipPermissions appends --dangerously-skip-permissions to Claude commands skipPermissions bool + // AppendArgs holds additional arguments appended to the program's argument list + // during Start(). Each element is a separate arg — no shell splitting is done. + // Set this before calling Start(). + AppendArgs []string // ProgressFunc is called with (stage, description) during Start() to report progress. ProgressFunc func(stage int, desc string) @@ -139,6 +143,9 @@ func (t *TmuxSession) Start(workDir string) error { if t.skipPermissions && isClaudeProgram(t.program) { programParts = append(programParts, "--dangerously-skip-permissions") } + if len(t.AppendArgs) > 0 { + programParts = append(programParts, t.AppendArgs...) + } t.reportProgress(1, "Creating tmux session...") diff --git a/ui/list.go b/ui/list.go index 47386fd..2afdb1b 100644 --- a/ui/list.go +++ b/ui/list.go @@ -27,6 +27,7 @@ type List struct { filter string // topic name filter (empty = show all) repoFilter string // repo path filter (empty = show all repos) statusFilter StatusFilter // status filter (All or Active) + chatFilter *bool // nil = no filter, false = code only, true = chat only sortMode SortMode // how instances are sorted allItems []*session.Instance @@ -58,6 +59,13 @@ func (l *List) SetStatusFilter(filter StatusFilter) { l.rebuildFilteredItems() } +// SetChatFilter restricts the visible instances by IsChat flag. +// nil removes the filter, false shows only code agents, true shows only chat agents. +func (l *List) SetChatFilter(isChat *bool) { + l.chatFilter = isChat + l.rebuildFilteredItems() +} + // CycleSortMode advances to the next sort mode and rebuilds. func (l *List) CycleSortMode() { l.sortMode = (l.sortMode + 1) % 4 @@ -482,6 +490,18 @@ func (l *List) rebuildFilteredItems() { l.items = topicFiltered } + // Apply chat filter (nil = show all, false = code only, true = chat only) + if l.chatFilter != nil { + wantChat := *l.chatFilter + chatFiltered := make([]*session.Instance, 0, len(l.items)) + for _, inst := range l.items { + if inst.IsChat == wantChat { + chatFiltered = append(chatFiltered, inst) + } + } + l.items = chatFiltered + } + // Apply sort l.sortItems() diff --git a/ui/sidebar.go b/ui/sidebar.go index e2181a2..047bf5f 100644 --- a/ui/sidebar.go +++ b/ui/sidebar.go @@ -92,6 +92,8 @@ type Sidebar struct { searchActive bool searchQuery string + activeTab int // 0 = Code, 1 = Chat + repoName string // current repo name shown at bottom repoHovered bool // true when mouse is hovering over the repo button @@ -412,6 +414,12 @@ func (s *Sidebar) IsSearchActive() bool { return s.searchActive } func (s *Sidebar) GetSearchQuery() string { return s.searchQuery } func (s *Sidebar) SetSearchQuery(q string) { s.searchQuery = q } +// SetTab sets the active sidebar tab (0 = Code, 1 = Chat). +func (s *Sidebar) SetTab(tab int) { s.activeTab = tab } + +// ActiveTab returns the index of the currently active sidebar tab. +func (s *Sidebar) ActiveTab() int { return s.activeTab } + func (s *Sidebar) String() string { borderStyle := sidebarBorderStyle if s.focused { @@ -446,6 +454,27 @@ func (s *Sidebar) String() string { } else { b.WriteString(searchBarStyle.Width(searchWidth).Render("\uf002 search")) } + b.WriteString("\n") + + // Tab bar: Code / Chat + { + tabCodeLabel := " Code " + tabChatLabel := " Chat " + var activeTabStyle = lipgloss.NewStyle(). + Underline(true). + Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#ffffff"}) + var inactiveTabStyle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#888888", Dark: "#666666"}) + var codeTab, chatTab string + if s.activeTab == 0 { + codeTab = activeTabStyle.Render(tabCodeLabel) + chatTab = inactiveTabStyle.Render(tabChatLabel) + } else { + codeTab = inactiveTabStyle.Render(tabCodeLabel) + chatTab = activeTabStyle.Render(tabChatLabel) + } + b.WriteString(codeTab + chatTab) + } b.WriteString("\n\n") // Items fill the content area; their own Padding(0,1) handles text inset diff --git a/ui/tabbed_window.go b/ui/tabbed_window.go index f389b5d..3679003 100644 --- a/ui/tabbed_window.go +++ b/ui/tabbed_window.go @@ -62,6 +62,7 @@ type TabbedWindow struct { contentStale bool // true when navigation changed instance but content not yet fetched gitContent string // cached git pane content, set by tick when changed terminalContent string // cached terminal pane content, set by tick when changed + chatMode bool // true when the selected instance is a chat agent (hides Diff/Git tabs) } // SetFocusMode enables or disables the focus/insert mode visual indicator. @@ -74,6 +75,15 @@ func (w *TabbedWindow) IsFocusMode() bool { return w.focusMode } +// SetChatMode hides the Diff and Git tabs when true. +// If the current tab is Diff or Git, it resets to the Preview (Agent) tab. +func (w *TabbedWindow) SetChatMode(isChat bool) { + w.chatMode = isChat + if isChat && (w.activeTab == DiffTab || w.activeTab == GitTab) { + w.activeTab = PreviewTab + } +} + func NewTabbedWindow(preview *PreviewPane, terminal *TerminalPane, diff *DiffPane, git *GitPane) *TabbedWindow { return &TabbedWindow{ tabs: []string{ @@ -142,8 +152,31 @@ func (w *TabbedWindow) GetPreviewSize() (width, height int) { return w.preview.width, w.preview.height } +// visibleTabIndices returns the indices of tabs that are currently visible. +// In chat mode, the Diff and Git tabs are hidden. +func (w *TabbedWindow) visibleTabIndices() []int { + if w.chatMode { + return []int{PreviewTab, TerminalTab} + } + indices := make([]int, len(w.tabs)) + for i := range indices { + indices[i] = i + } + return indices +} + func (w *TabbedWindow) Toggle() { - w.activeTab = (w.activeTab + 1) % len(w.tabs) + visible := w.visibleTabIndices() + for i, idx := range visible { + if idx == w.activeTab { + w.activeTab = visible[(i+1)%len(visible)] + return + } + } + // fallback: go to first visible tab + if len(visible) > 0 { + w.activeTab = visible[0] + } } // ToggleWithReset toggles the tab and resets preview pane to normal mode @@ -152,7 +185,16 @@ func (w *TabbedWindow) ToggleWithReset(instance *session.Instance) error { if err := w.preview.ResetToNormalMode(instance); err != nil { return err } - w.activeTab = (w.activeTab + 1) % len(w.tabs) + visible := w.visibleTabIndices() + for i, idx := range visible { + if idx == w.activeTab { + w.activeTab = visible[(i+1)%len(visible)] + return nil + } + } + if len(visible) > 0 { + w.activeTab = visible[0] + } return nil } @@ -314,17 +356,19 @@ func (w *TabbedWindow) HandleTabClick(localX, localY int) bool { return false } - tabWidth := w.width / len(w.tabs) - clickedTab := localX / tabWidth - if clickedTab >= len(w.tabs) { - clickedTab = len(w.tabs) - 1 + visible := w.visibleTabIndices() + tabWidth := w.width / len(visible) + clickedVisibleIdx := localX / tabWidth + if clickedVisibleIdx >= len(visible) { + clickedVisibleIdx = len(visible) - 1 } - if clickedTab < 0 { + if clickedVisibleIdx < 0 { return false } - if clickedTab != w.activeTab { - w.activeTab = clickedTab + newTab := visible[clickedVisibleIdx] + if newTab != w.activeTab { + w.activeTab = newTab } return true } @@ -336,19 +380,21 @@ func (w *TabbedWindow) String() string { var renderedTabs []string - tabWidth := w.width / len(w.tabs) - lastTabWidth := w.width - tabWidth*(len(w.tabs)-1) + visible := w.visibleTabIndices() + tabWidth := w.width / len(visible) + lastTabWidth := w.width - tabWidth*(len(visible)-1) tabHeight := activeTabStyle.GetVerticalFrameSize() + 1 // get padding border margin size + 1 for character height focusColor := lipgloss.Color("#51bd73") - for i, t := range w.tabs { + for vi, tabIdx := range visible { + t := w.tabs[tabIdx] width := tabWidth - if i == len(w.tabs)-1 { + if vi == len(visible)-1 { width = lastTabWidth } var style lipgloss.Style - isFirst, isLast, isActive := i == 0, i == len(w.tabs)-1, i == w.activeTab + isFirst, isLast, isActive := vi == 0, vi == len(visible)-1, tabIdx == w.activeTab if isActive { style = activeTabStyle } else {