diff --git a/container/internal/services/commit_sync.go b/container/internal/services/commit_sync.go index d9a1a451..0987eefa 100644 --- a/container/internal/services/commit_sync.go +++ b/container/internal/services/commit_sync.go @@ -579,14 +579,21 @@ func (css *CommitSyncService) syncToNiceBranch(commitInfo *CommitInfo) error { return nil } - // Check if the nice branch exists - if !css.operations.BranchExists(commitInfo.WorktreePath, niceBranch, false) { - log.Printf("⚠️ Nice branch %s doesn't exist, skipping sync", niceBranch) + // Check if the nice branch exists in the main repository (not the worktree) + repo, err := css.findRepositoryForWorktree(commitInfo.WorktreePath) + if err != nil { + log.Printf("⚠️ Failed to find repository for worktree %s: %v", commitInfo.WorktreePath, err) + return nil + } + + if !css.operations.BranchExists(repo.Path, niceBranch, false) { + log.Printf("⚠️ Nice branch %s doesn't exist in main repo %s, skipping sync", niceBranch, repo.Path) return nil } // Update the nice branch to point to the same commit as the custom ref - _, err = css.operations.ExecuteGit(commitInfo.WorktreePath, "branch", "-f", niceBranch, commitInfo.CommitHash) + // We need to do this in the main repository since worktrees on custom refs can't directly manipulate regular branches + _, err = css.operations.ExecuteGit(repo.Path, "branch", "-f", niceBranch, commitInfo.CommitHash) if err != nil { return fmt.Errorf("failed to update nice branch %s to commit %s: %v", niceBranch, commitInfo.CommitHash[:8], err) } @@ -594,8 +601,7 @@ func (css *CommitSyncService) syncToNiceBranch(commitInfo *CommitInfo) error { log.Printf("🔄 Synced nice branch %s to commit %s from %s", niceBranch, commitInfo.CommitHash[:8], commitInfo.Branch) // For local repositories, also push the nice branch to the main repository - repo, err := css.findRepositoryForWorktree(commitInfo.WorktreePath) - if err == nil && strings.HasPrefix(repo.ID, "local/") { + if strings.HasPrefix(repo.ID, "local/") { // Push the nice branch to the catnip-live remote (which points to the main repo) _, pushErr := css.operations.ExecuteGit(commitInfo.WorktreePath, "push", "catnip-live", fmt.Sprintf("%s:%s", niceBranch, niceBranch), "--force-with-lease") if pushErr != nil { @@ -771,13 +777,18 @@ func (css *CommitSyncService) hasUnsyncedNiceBranch(commitInfo *CommitInfo) bool return false } - // Check if the nice branch exists - if !css.operations.BranchExists(commitInfo.WorktreePath, niceBranch, false) { + // Check if the nice branch exists in the main repository (not the worktree) + repo, err := css.findRepositoryForWorktree(commitInfo.WorktreePath) + if err != nil { + return false + } + + if !css.operations.BranchExists(repo.Path, niceBranch, false) { return false } - // Get commit hash of the nice branch - niceBranchHash, err := css.operations.GetCommitHash(commitInfo.WorktreePath, niceBranch) + // Get commit hash of the nice branch from the main repository + niceBranchHash, err := css.operations.GetCommitHash(repo.Path, niceBranch) if err != nil { return false } diff --git a/container/internal/tui/app.go b/container/internal/tui/app.go index 8be4518b..f46b1b9f 100644 --- a/container/internal/tui/app.go +++ b/container/internal/tui/app.go @@ -98,6 +98,8 @@ func (a *App) Run(ctx context.Context, workDir string, customPorts []string) err // Initialize viewports logsViewport := viewport.New(80, 20) shellViewport := viewport.New(80, 24) + workspaceClaudeTerminal := viewport.New(60, 18) + workspaceRegularTerminal := viewport.New(60, 6) // Initialize SSE client sseClient := NewSSEClient("http://localhost:8080/v1/events", nil) @@ -107,6 +109,8 @@ func (a *App) Run(ctx context.Context, workDir string, customPorts []string) err m.logsViewport = logsViewport m.searchInput = searchInput m.shellViewport = shellViewport + m.workspaceClaudeTerminal = workspaceClaudeTerminal + m.workspaceRegularTerminal = workspaceRegularTerminal m.shellSpinner = spinner.New() m.sseClient = sseClient m.sseStarted = true // SSE will be started immediately @@ -175,6 +179,12 @@ func (m Model) View() string { result = m.overlayOnContent(result, overlay) } + // Overlay workspace selector if active + if m.showWorkspaceSelector { + overlay := m.renderWorkspaceSelector() + result = m.overlayOnContent(result, overlay) + } + return result } @@ -189,13 +199,15 @@ func (m Model) renderFooter() string { } return footerStyle.Render("Initializing container... Press Ctrl+Q to quit") case OverviewView: - return footerStyle.Render("Ctrl+L: logs | Ctrl+T: terminal | Ctrl+B: browser | Ctrl+Q: quit") + return footerStyle.Render("Ctrl+L: logs | Ctrl+T: terminal | Ctrl+B: browser | Ctrl+W: workspaces | Ctrl+Q: quit") case ShellView: scrollKey := "Alt" if runtime.GOOS == "darwin" { scrollKey = "Option" } return footerStyle.Render(fmt.Sprintf("Ctrl+O: overview | Ctrl+L: logs | Ctrl+B: browser | Ctrl+Q: quit | %s+↑↓/PgUp/PgDn: scroll", scrollKey)) + case WorkspaceView: + return footerStyle.Render("Esc: back | Ctrl+O: overview | Ctrl+L: logs | Ctrl+B: browser | Ctrl+Q: quit") case LogsView: if m.searchMode { // Replace footer with search input @@ -297,6 +309,60 @@ func (m Model) renderPortSelector() string { return boxStyle.Render(title + "\n\n" + menuContent) } +// renderWorkspaceSelector renders the workspace selection overlay +func (m Model) renderWorkspaceSelector() string { + // Build the menu content + var menuItems []string + for i, workspace := range m.workspaces { + prefix := " " + if i == m.selectedWorkspaceIndex { + prefix = "▶ " + } + + // Create status indicator + statusIndicator := "○" + if workspace.IsActive { + statusIndicator = "●" + } + + // Format: status name (branch) - changed files count + changeCount := len(workspace.ChangedFiles) + changeText := "" + if changeCount > 0 { + changeText = fmt.Sprintf(" • %d changes", changeCount) + } + + item := fmt.Sprintf("%s %s (%s)%s", statusIndicator, workspace.Name, workspace.Branch, changeText) + menuItems = append(menuItems, prefix+item) + } + + // Add instructions + instructions := []string{ + "", + "↑↓/jk: Navigate • Enter/1-9: Select • Esc: Cancel", + } + + content := append(menuItems, instructions...) + menuContent := strings.Join(content, "\n") + + // Style the menu box + boxStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("62")). + Padding(1, 2). + Background(lipgloss.Color("235")). + Foreground(lipgloss.Color("15")) + + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("39")). + Align(lipgloss.Center) + + title := titleStyle.Render("📁 Select Workspace") + + return boxStyle.Render(title + "\n\n" + menuContent) +} + // overlayOnContent centers an overlay on top of the main content func (m Model) overlayOnContent(content, overlay string) string { // Use lipgloss.Place to properly center the overlay diff --git a/container/internal/tui/commands.go b/container/internal/tui/commands.go index 19fb58f9..e022911d 100644 --- a/container/internal/tui/commands.go +++ b/container/internal/tui/commands.go @@ -2,10 +2,13 @@ package tui import ( "context" + "encoding/json" + "net/http" "strings" "time" tea "github.com/charmbracelet/bubbletea" + "github.com/vanpelt/catnip/internal/models" ) // Ticker commands @@ -114,6 +117,23 @@ func (m *Model) fetchHealthStatus() tea.Cmd { } } +func (m *Model) fetchWorkspaces() tea.Cmd { + return func() tea.Msg { + // If quit was requested, don't execute this command + if m.quitRequested { + debugLog("fetchWorkspaces: quit requested, skipping") + return nil + } + + workspaces, err := fetchWorkspacesFromAPI() + if err != nil { + debugLog("fetchWorkspaces: error: %v", err) + return workspacesMsg{} // Return empty list on error + } + return workspacesMsg(workspaces) + } +} + // Batch commands for initialization func (m *Model) initCommands() tea.Cmd { var commands []tea.Cmd @@ -132,6 +152,7 @@ func (m *Model) initCommands() tea.Cmd { m.fetchRepositoryInfo(), m.fetchHealthStatus(), m.fetchPorts(), + m.fetchWorkspaces(), m.fetchContainerInfo(), m.shellSpinner.Tick, tick(), @@ -141,3 +162,45 @@ func (m *Model) initCommands() tea.Cmd { return tea.Batch(commands...) } + +// fetchWorkspacesFromAPI fetches workspaces from the container API +func fetchWorkspacesFromAPI() ([]WorkspaceInfo, error) { + client := &http.Client{Timeout: 3 * time.Second} + resp, err := client.Get("http://localhost:8080/v1/git/worktrees") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, err // Return empty if API call fails + } + + var worktrees []models.Worktree + if err := json.NewDecoder(resp.Body).Decode(&worktrees); err != nil { + return nil, err + } + + // Convert models.Worktree to WorkspaceInfo + var workspaces []WorkspaceInfo + for _, wt := range worktrees { + workspace := WorkspaceInfo{ + ID: wt.ID, + Name: wt.Name, + Path: wt.Path, + Branch: wt.Branch, + IsActive: string(wt.ClaudeActivityState) != "inactive", // Use Claude activity as active indicator + ChangedFiles: []string{}, // TODO: Get changed files from git status if needed + Ports: []PortInfo{}, // TODO: Map ports if available in worktree model + } + + // Add indicator if worktree is dirty (has uncommitted changes) + if wt.IsDirty { + workspace.ChangedFiles = []string{"(uncommitted changes)"} // Placeholder + } + + workspaces = append(workspaces, workspace) + } + + return workspaces, nil +} diff --git a/container/internal/tui/components/keys.go b/container/internal/tui/components/keys.go index 33c1dc93..fe3c84e2 100644 --- a/container/internal/tui/components/keys.go +++ b/container/internal/tui/components/keys.go @@ -19,6 +19,9 @@ const ( // Browser shortcuts KeyOpenBrowser = "ctrl+b" + // Workspace shortcuts + KeyWorkspace = "ctrl+w" + // Common keys KeyEscape = "esc" KeyEnter = "enter" @@ -84,7 +87,7 @@ const ( // IsGlobalNavigationKey checks if a key is a global navigation command func IsGlobalNavigationKey(key string) bool { switch key { - case KeyQuit, KeyQuitAlt, KeyOverview, KeyLogs, KeyShell, KeyOpenBrowser: + case KeyQuit, KeyQuitAlt, KeyOverview, KeyLogs, KeyShell, KeyOpenBrowser, KeyWorkspace: return true } return false diff --git a/container/internal/tui/messages.go b/container/internal/tui/messages.go index 066f136a..267b9643 100644 --- a/container/internal/tui/messages.go +++ b/container/internal/tui/messages.go @@ -16,6 +16,7 @@ type logsMsg []string type portsMsg []string type errMsg error type healthStatusMsg bool +type workspacesMsg []WorkspaceInfo // Shell-related messages type shellOutputMsg struct { @@ -26,6 +27,9 @@ type shellErrorMsg struct { sessionID string err error } +type shellConnectedMsg struct { + sessionID string +} // SSE event messages type sseConnectedMsg struct{} @@ -46,3 +50,4 @@ type sseContainerStatusMsg struct { status string message string } +type sseWorktreeUpdatedMsg struct{} diff --git a/container/internal/tui/model.go b/container/internal/tui/model.go index 9b9cf6ed..2868f953 100644 --- a/container/internal/tui/model.go +++ b/container/internal/tui/model.go @@ -23,6 +23,8 @@ const ( LogsView // ShellView represents the shell terminal interface ShellView + // WorkspaceView represents the workspace-specific view + WorkspaceView ) // View interface that all views must implement @@ -51,6 +53,17 @@ type PortInfo struct { Protocol string } +// WorkspaceInfo represents information about a workspace +type WorkspaceInfo struct { + ID string + Name string + Path string + Branch string + IsActive bool + ChangedFiles []string + Ports []PortInfo +} + // Model represents the main application state type Model struct { // Core dependencies @@ -115,6 +128,19 @@ type Model struct { showPortSelector bool selectedPortIndex int + // Workspace selector overlay + showWorkspaceSelector bool + selectedWorkspaceIndex int + currentWorkspace *WorkspaceInfo + workspaces []WorkspaceInfo + waitingToShowWorkspaces bool + + // Workspace view state + workspaceClaudeTerminal viewport.Model + workspaceRegularTerminal viewport.Model + workspaceClaudeTerminalEmulator *TerminalEmulator + workspaceLastOutputLength int + // SSE connection state sseConnected bool sseStarted bool @@ -155,6 +181,7 @@ func NewModelWithInitialization(containerService *services.ContainerService, con logs: []string{}, filteredLogs: []string{}, ports: []PortInfo{}, + workspaces: []WorkspaceInfo{}, lastUpdate: time.Now(), shellSessions: make(map[string]*PTYClient), views: make(map[ViewType]View), @@ -165,6 +192,7 @@ func NewModelWithInitialization(containerService *services.ContainerService, con m.views[OverviewView] = NewOverviewView() m.views[LogsView] = NewLogsView() m.views[ShellView] = NewShellView() + m.views[WorkspaceView] = NewWorkspaceView() return m } diff --git a/container/internal/tui/pty_client.go b/container/internal/tui/pty_client.go index eec04ce3..97e97215 100644 --- a/container/internal/tui/pty_client.go +++ b/container/internal/tui/pty_client.go @@ -77,10 +77,18 @@ func (p *PTYClient) readLoop() { return } - if messageType == websocket.BinaryMessage || messageType == websocket.TextMessage { + // Handle different message types properly + switch messageType { + case websocket.BinaryMessage: + // Binary messages are PTY output - pass to terminal if p.onMessage != nil { p.onMessage(message) } + case websocket.TextMessage: + // Text messages are JSON control messages - process them separately + debugLog("PTYClient received JSON control message: %s", string(message)) + // TODO: Handle JSON messages for state updates (read-only mode, etc.) + // For now, we just filter them out from terminal display } } } diff --git a/container/internal/tui/sse_client.go b/container/internal/tui/sse_client.go index 94f6e3d3..67789b40 100644 --- a/container/internal/tui/sse_client.go +++ b/container/internal/tui/sse_client.go @@ -41,6 +41,9 @@ const ( ProcessStoppedEvent = "process:stopped" ContainerStatusEvent = "container:status" HeartbeatEvent = "heartbeat" + WorktreeCreatedEvent = "worktree:created" + WorktreeDeletedEvent = "worktree:deleted" + WorktreeUpdatedEvent = "worktree:updated" ) // SSE event messages are defined in messages.go @@ -248,6 +251,12 @@ func (c *SSEClient) processEvent(data string) { // No need to log every heartbeat to avoid spam // debugLog("SSE heartbeat received") + case WorktreeCreatedEvent, WorktreeDeletedEvent, WorktreeUpdatedEvent: + // Send a message to trigger worktree refresh + if c.program != nil { + c.program.Send(sseWorktreeUpdatedMsg{}) + } + default: // Log other event types for now debugLog("SSE event received: %s", msg.Event.Type) diff --git a/container/internal/tui/update.go b/container/internal/tui/update.go index f217b644..30e3b541 100644 --- a/container/internal/tui/update.go +++ b/container/internal/tui/update.go @@ -47,6 +47,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.handlePorts(msg) case healthStatusMsg: return m.handleHealthStatus(msg) + case workspacesMsg: + return m.handleWorkspaces(msg) + case sseWorktreeUpdatedMsg: + return m.handleSSEWorktreeUpdated(msg) case errMsg: return m.handleError(msg) case quitMsg: @@ -164,6 +168,18 @@ func (m Model) handleGlobalKeys(msg tea.KeyMsg) (*Model, tea.Cmd, bool) { m.bootingBoldTimer = time.Now() } return &m, nil, true + + case components.KeyWorkspace: + // Show workspace selector overlay if we have workspaces + if len(m.workspaces) > 0 { + m.showWorkspaceSelector = true + m.selectedWorkspaceIndex = 0 // Default to first workspace + } else { + // Set flag to show selector when workspaces load and fetch workspaces from API + m.waitingToShowWorkspaces = true + return &m, m.fetchWorkspaces(), true + } + return &m, nil, true } // Handle port selector overlay if active @@ -171,6 +187,11 @@ func (m Model) handleGlobalKeys(msg tea.KeyMsg) (*Model, tea.Cmd, bool) { return m.handlePortSelectorKeys(msg) } + // Handle workspace selector overlay if active + if m.showWorkspaceSelector { + return m.handleWorkspaceSelectorKeys(msg) + } + // Key not handled globally return &m, nil, false } @@ -212,6 +233,12 @@ func (m Model) handleTick(msg tickMsg) (tea.Model, tea.Cmd) { cmds = append(cmds, m.fetchHealthStatus()) } + // Fetch workspaces periodically (every 5 ticks = 25 seconds) + // This is a fallback in case SSE events are missed + if int(m.lastUpdate.Unix())%25 == 0 { + cmds = append(cmds, m.fetchWorkspaces()) + } + return m, tea.Batch(cmds...) } @@ -445,21 +472,33 @@ func (m Model) handleSSEError(msg sseErrorMsg) (tea.Model, tea.Cmd) { // Shell message handlers func (m Model) handleShellOutput(msg shellOutputMsg) (tea.Model, tea.Cmd) { - if m.currentView == ShellView { + switch m.currentView { + case ShellView: shellView := m.views[ShellView].(*ShellViewImpl) newModel, cmd := shellView.handleShellOutput(&m, msg) return *newModel, cmd + case WorkspaceView: + workspaceView := m.views[WorkspaceView].(*WorkspaceViewImpl) + newModel, cmd := workspaceView.Update(&m, msg) + return *newModel, cmd + default: + return m, nil } - return m, nil } func (m Model) handleShellError(msg shellErrorMsg) (tea.Model, tea.Cmd) { - if m.currentView == ShellView { + switch m.currentView { + case ShellView: shellView := m.views[ShellView].(*ShellViewImpl) newModel, cmd := shellView.handleShellError(&m, msg) return *newModel, cmd + case WorkspaceView: + workspaceView := m.views[WorkspaceView].(*WorkspaceViewImpl) + newModel, cmd := workspaceView.Update(&m, msg) + return *newModel, cmd + default: + return m, nil } - return m, nil } // handlePortSelectorKeys handles key input for the port selector overlay @@ -586,6 +625,71 @@ func (m Model) handlePortSelectorKeys(msg tea.KeyMsg) (*Model, tea.Cmd, bool) { return &m, nil, true } +// handleWorkspaceSelectorKeys handles key input for the workspace selector overlay +func (m Model) handleWorkspaceSelectorKeys(msg tea.KeyMsg) (*Model, tea.Cmd, bool) { + keyStr := msg.String() + + switch keyStr { + case components.KeyEscape: + // Close workspace selector + m.showWorkspaceSelector = false + return &m, nil, true + + case components.KeyEnter: + // Select workspace and switch to workspace view + if m.selectedWorkspaceIndex < len(m.workspaces) { + workspace := &m.workspaces[m.selectedWorkspaceIndex] + m.currentWorkspace = workspace + m.SwitchToView(WorkspaceView) + + // Create workspace terminal sessions + workspaceView := m.views[WorkspaceView].(*WorkspaceViewImpl) + newModel, cmd := workspaceView.CreateWorkspaceSessions(&m, workspace) + m.showWorkspaceSelector = false + return newModel, cmd, true + } + m.showWorkspaceSelector = false + return &m, nil, true + + case components.KeyUp, "k": + // Move up in workspace list + if m.selectedWorkspaceIndex > 0 { + m.selectedWorkspaceIndex-- + } else { + m.selectedWorkspaceIndex = len(m.workspaces) - 1 // Wrap to bottom + } + return &m, nil, true + + case components.KeyDown, "j": + // Move down in workspace list + if m.selectedWorkspaceIndex < len(m.workspaces)-1 { + m.selectedWorkspaceIndex++ + } else { + m.selectedWorkspaceIndex = 0 // Wrap to top + } + return &m, nil, true + + default: + // Check for number keys 1-9 for direct selection + if len(keyStr) == 1 && keyStr >= "1" && keyStr <= "9" { + index := int(keyStr[0] - '1') // Convert to 0-based index + if index < len(m.workspaces) { + workspace := &m.workspaces[index] + m.currentWorkspace = workspace + m.SwitchToView(WorkspaceView) + + // Create workspace terminal sessions + workspaceView := m.views[WorkspaceView].(*WorkspaceViewImpl) + newModel, cmd := workspaceView.CreateWorkspaceSessions(&m, workspace) + m.showWorkspaceSelector = false + return newModel, cmd, true + } + } + } + + return &m, nil, true +} + // Version check handler func (m Model) handleVersionCheck(msg VersionCheckMsg) (tea.Model, tea.Cmd) { m.upgradeAvailable = msg.UpgradeAvailable @@ -596,3 +700,32 @@ func (m Model) handleVersionCheck(msg VersionCheckMsg) (tea.Model, tea.Cmd) { } return m, nil } + +// Workspaces message handler +func (m Model) handleWorkspaces(msg workspacesMsg) (tea.Model, tea.Cmd) { + m.workspaces = []WorkspaceInfo(msg) + debugLog("Updated workspaces: %d workspaces loaded", len(m.workspaces)) + + // If we were waiting to show workspaces and now have some, automatically select the first one + if len(m.workspaces) > 0 && m.waitingToShowWorkspaces { + m.waitingToShowWorkspaces = false + // Automatically select the first workspace instead of showing selector + workspace := &m.workspaces[0] + m.currentWorkspace = workspace + m.SwitchToView(WorkspaceView) + + // Create workspace terminal sessions + workspaceView := m.views[WorkspaceView].(*WorkspaceViewImpl) + newModel, cmd := workspaceView.CreateWorkspaceSessions(&m, workspace) + return newModel, cmd + } + + return m, nil +} + +// SSE worktree updated handler +func (m Model) handleSSEWorktreeUpdated(msg sseWorktreeUpdatedMsg) (tea.Model, tea.Cmd) { + debugLog("SSE worktree updated event received, refreshing workspaces") + // Refresh workspaces when SSE event is received + return m, m.fetchWorkspaces() +} diff --git a/container/internal/tui/view_overview.go b/container/internal/tui/view_overview.go index 4c76c5b4..3fa123da 100644 --- a/container/internal/tui/view_overview.go +++ b/container/internal/tui/view_overview.go @@ -98,20 +98,70 @@ func (v *OverviewViewImpl) Render(m *Model) string { } sections = append(sections, "") - // Ports + // Workspaces + if len(m.workspaces) > 0 { + sections = append(sections, components.SubHeaderStyle.Render("📁 Workspaces")) + + for i, workspace := range m.workspaces { + if i < 9 { // Only show first 9 workspaces for number shortcuts + workspaceKey := components.KeyHighlightStyle.Render(fmt.Sprintf("%d.", i+1)) + + // Status indicator + statusIndicator := "○" + if workspace.IsActive { + statusIndicator = "●" + } + + // Create change count indicator + changeText := "" + if len(workspace.ChangedFiles) > 0 { + changeText = fmt.Sprintf(" (%d changes)", len(workspace.ChangedFiles)) + } + + sections = append(sections, fmt.Sprintf(" %s %s %s (%s)%s", workspaceKey, statusIndicator, workspace.Name, workspace.Branch, changeText)) + } else { + statusIndicator := "○" + if workspace.IsActive { + statusIndicator = "●" + } + changeText := "" + if len(workspace.ChangedFiles) > 0 { + changeText = fmt.Sprintf(" (%d changes)", len(workspace.ChangedFiles)) + } + sections = append(sections, fmt.Sprintf(" %s %s (%s)%s", statusIndicator, workspace.Name, workspace.Branch, changeText)) + } + } + sections = append(sections, "") + sections = append(sections, fmt.Sprintf(" Press %s to select workspace", components.KeyHighlightStyle.Render("Ctrl+W"))) + } else { + sections = append(sections, components.SubHeaderStyle.Render("📁 Workspaces")) + sections = append(sections, fmt.Sprintf(" No workspaces available. Press %s to initialize.", components.KeyHighlightStyle.Render("Ctrl+W"))) + } + + sections = append(sections, "") + + // Detected Services (condensed) if len(m.ports) > 0 { sections = append(sections, components.SubHeaderStyle.Render("🌐 Detected Services")) - - for i, portInfo := range m.ports { - if i < 9 { // Only show first 9 ports for number shortcuts - portKey := components.KeyHighlightStyle.Render(fmt.Sprintf("%d.", i+1)) - sections = append(sections, fmt.Sprintf(" %s %s → http://localhost:8080/%s", portKey, portInfo.Title, portInfo.Port)) - } else { - sections = append(sections, fmt.Sprintf(" %s → http://localhost:8080/%s", portInfo.Title, portInfo.Port)) + + // Show up to 3 services in a compact format + serviceCount := 0 + for _, portInfo := range m.ports { + if serviceCount >= 3 { + break } + sections = append(sections, fmt.Sprintf(" %s → :%s", portInfo.Title, portInfo.Port)) + serviceCount++ } + + if len(m.ports) > 3 { + sections = append(sections, fmt.Sprintf(" ... and %d more services", len(m.ports)-3)) + } + + sections = append(sections, fmt.Sprintf(" Press %s to open service browser", components.KeyHighlightStyle.Render("Ctrl+B"))) } else { - sections = append(sections, "🌐 No services detected") + sections = append(sections, components.SubHeaderStyle.Render("🌐 Detected Services")) + sections = append(sections, " No services detected") } sections = append(sections, "") diff --git a/container/internal/tui/view_workspace.go b/container/internal/tui/view_workspace.go new file mode 100644 index 00000000..0b55a501 --- /dev/null +++ b/container/internal/tui/view_workspace.go @@ -0,0 +1,519 @@ +package tui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/vanpelt/catnip/internal/tui/components" +) + +// WorkspaceViewImpl handles the workspace view functionality +type WorkspaceViewImpl struct{} + +// NewWorkspaceView creates a new workspace view instance +func NewWorkspaceView() *WorkspaceViewImpl { + return &WorkspaceViewImpl{} +} + +// GetViewType returns the view type identifier +func (v *WorkspaceViewImpl) GetViewType() ViewType { + return WorkspaceView +} + +// Update handles workspace-specific message processing +func (v *WorkspaceViewImpl) Update(m *Model, msg tea.Msg) (*Model, tea.Cmd) { + // Handle shell output and error messages for Claude terminal only (simplified view) + switch msg := msg.(type) { + case shellOutputMsg: + debugLog("WorkspaceView Update - received shellOutputMsg for session: %s", msg.sessionID) + // Only handle Claude terminal messages (check if it matches our current workspace) + if m.currentWorkspace != nil && strings.HasSuffix(msg.sessionID, ":claude") && strings.HasPrefix(msg.sessionID, m.currentWorkspace.Name) { + debugLog("WorkspaceView Update - handling Claude output message") + return v.handleClaudeOutput(m, msg) + } + case shellErrorMsg: + debugLog("WorkspaceView Update - received shellErrorMsg for session: %s, error: %v", msg.sessionID, msg.err) + // Only handle Claude terminal errors (check if it matches our current workspace) + if m.currentWorkspace != nil && strings.HasSuffix(msg.sessionID, ":claude") && strings.HasPrefix(msg.sessionID, m.currentWorkspace.Name) { + debugLog("WorkspaceView Update - handling Claude error message") + return v.handleClaudeError(m, msg) + } + } + + // Update only Claude viewport model (simplified view) + var cmd tea.Cmd + m.workspaceClaudeTerminal, cmd = m.workspaceClaudeTerminal.Update(msg) + + return m, cmd +} + +// HandleKey processes key messages for the workspace view +func (v *WorkspaceViewImpl) HandleKey(m *Model, msg tea.KeyMsg) (*Model, tea.Cmd) { + // Handle escape key to return to overview + switch msg.String() { + case components.KeyEscape: + m.SwitchToView(OverviewView) + return m, nil + default: + // Forward all other input to the Claude terminal (simplified view) + v.forwardToClaudeTerminal(m, msg) + return m, nil + } +} + +// HandleResize processes window resize for the workspace view +func (v *WorkspaceViewImpl) HandleResize(m *Model, msg tea.WindowSizeMsg) (*Model, tea.Cmd) { + // Calculate layout dimensions with proper padding + headerHeight := 3 + padding := 2 // Overall padding + availableHeight := msg.Height - headerHeight - padding + availableWidth := msg.Width - padding + + // Ensure minimum dimensions + if availableHeight < 10 { + availableHeight = 10 + } + if availableWidth < 40 { + availableWidth = 40 + } + + // Simplified layout: Claude terminal (70% width) + right sidebar (30% width) + mainWidth := (availableWidth * 70) / 100 + + // Claude terminal takes full height (simplified view) + claudeHeight := availableHeight + + // Ensure minimum terminal height + if claudeHeight < 10 { + claudeHeight = 10 + } + + // Update viewport size (account for terminal borders) + terminalWidth := mainWidth - 4 // Account for terminal borders (2 per side) + if terminalWidth < 20 { + terminalWidth = 20 + } + + m.workspaceClaudeTerminal.Width = terminalWidth + m.workspaceClaudeTerminal.Height = claudeHeight - 2 // Account for terminal border + + // Resize terminal emulator if it exists + if m.workspaceClaudeTerminalEmulator != nil { + m.workspaceClaudeTerminalEmulator.Resize(terminalWidth, claudeHeight-2) + debugLog("HandleResize - resized terminal emulator: width=%d, height=%d", terminalWidth, claudeHeight-2) + } + + // Resize PTY session if it exists - only Claude terminal now + v.resizeClaudeTerminal(m, terminalWidth, claudeHeight-2) + + return m, nil +} + +// Render generates the workspace view content +func (v *WorkspaceViewImpl) Render(m *Model) string { + debugLog("WorkspaceView Render called - currentWorkspace: %+v", m.currentWorkspace) + debugLog("WorkspaceView Render - terminal dimensions: width=%d height=%d", m.width, m.height) + if m.currentWorkspace == nil { + debugLog("WorkspaceView Render - no current workspace, showing no workspace screen") + return v.renderNoWorkspace(m) + } + + // Calculate layout dimensions with proper padding + headerHeight := 3 + padding := 2 // Overall padding + availableHeight := m.height - headerHeight - padding + availableWidth := m.width - padding + + // Ensure minimum dimensions + if availableHeight < 10 { + availableHeight = 10 + } + if availableWidth < 40 { + availableWidth = 40 + } + + // Claude terminal takes full height (simplified view) + claudeHeight := availableHeight + + // Ensure minimum terminal height + if claudeHeight < 10 { + claudeHeight = 10 + } + + // Fixed sidebar width: 20-30 columns, terminal takes the rest + newSidebarWidth := 25 // Default to 25 columns + if newSidebarWidth > 30 { + newSidebarWidth = 30 + } + if newSidebarWidth < 20 { + newSidebarWidth = 20 + } + + terminalWidth := availableWidth - newSidebarWidth + + // Ensure minimum terminal width + if terminalWidth < 60 { + terminalWidth = 60 + newSidebarWidth = availableWidth - terminalWidth + // Clamp sidebar to valid range after adjustment + if newSidebarWidth > 30 { + newSidebarWidth = 30 + } + if newSidebarWidth < 20 { + newSidebarWidth = 20 + } + } + + claudeContent := v.renderClaudeTerminal(m, terminalWidth, claudeHeight) + sidebarContent := v.renderSimpleSidebar(m, newSidebarWidth, claudeHeight) + debugLog("WorkspaceView Render - Claude: %d chars, Sidebar: %d chars", len(claudeContent), len(sidebarContent)) + + // Use lipgloss JoinHorizontal with no borders + workspaceContent := lipgloss.JoinHorizontal(lipgloss.Top, claudeContent, sidebarContent) + debugLog("WorkspaceView Render - final content length: %d", len(workspaceContent)) + + return workspaceContent +} + +// Helper methods + +func (v *WorkspaceViewImpl) renderNoWorkspace(m *Model) string { + centerStyle := components.CenteredStyle. + Padding(2, 0). + Width(m.width - 2). + Height(m.height - 6) + + content := "No workspaces detected.\n\nPress Ctrl+W to select a workspace." + return centerStyle.Render(content) +} + +func (v *WorkspaceViewImpl) renderClaudeTerminal(m *Model, width, height int) string { + debugLog("renderClaudeTerminal called - width=%d, height=%d", width, height) + // FUCK THE STYLING - Just return raw terminal content + debugLog("renderClaudeTerminal - SIMPLIFIED: no lipgloss styling") + + // Set content for Claude terminal viewport + if m.currentWorkspace != nil { + claudeSessionID := m.currentWorkspace.Name + debugLog("renderClaudeTerminal - looking for session: %s", claudeSessionID) + if globalShellManager != nil { + debugLog("renderClaudeTerminal - globalShellManager exists, sessions count: %d", len(globalShellManager.sessions)) + // DEBUG: List all sessions to see what actually exists + for sessionID := range globalShellManager.sessions { + debugLog("renderClaudeTerminal - existing session: %s", sessionID) + } + if session := globalShellManager.GetSession(claudeSessionID); session != nil { + debugLog("renderClaudeTerminal - found session, output length: %d, connected: %v", len(session.Output), session.Connected) + + // Initialize terminal emulator if needed + if m.workspaceClaudeTerminalEmulator == nil { + terminalWidth := width - 4 // Some padding + debugLog("renderClaudeTerminal - initializing terminal emulator with width=%d, height=%d", terminalWidth, height) + m.workspaceClaudeTerminalEmulator = NewTerminalEmulator(terminalWidth, height) + } + + // Only process session output if it has changed + if len(session.Output) > 0 { + // Check if output length has changed since last render + if len(session.Output) != m.workspaceLastOutputLength { + debugLog("renderClaudeTerminal - output changed: %d -> %d bytes", m.workspaceLastOutputLength, len(session.Output)) + // Process PTY output directly (JSON filtering now handled at WebSocket level) + m.workspaceClaudeTerminalEmulator.Clear() + m.workspaceClaudeTerminalEmulator.Write(session.Output) + terminalOutput := m.workspaceClaudeTerminalEmulator.Render() + debugLog("renderClaudeTerminal - processed %d bytes through emulator, got %d chars", len(session.Output), len(terminalOutput)) + m.workspaceClaudeTerminal.SetContent(terminalOutput) + m.workspaceClaudeTerminal.GotoBottom() + m.workspaceLastOutputLength = len(session.Output) + } else { + // Content hasn't changed, don't reprocess + debugLog("renderClaudeTerminal - content unchanged (%d bytes), skipping emulator processing", len(session.Output)) + } + } else { + m.workspaceClaudeTerminal.SetContent("Connecting to Claude terminal...") + } + } else { + debugLog("renderClaudeTerminal - no session found, showing connecting message") + m.workspaceClaudeTerminal.SetContent("Connecting to Claude terminal...") + } + } else { + debugLog("renderClaudeTerminal - globalShellManager is nil") + m.workspaceClaudeTerminal.SetContent("Shell manager not available") + } + } else { + debugLog("renderClaudeTerminal - no current workspace") + m.workspaceClaudeTerminal.SetContent("No workspace") + } + + // Return just the raw terminal view - clean, no header + terminalView := m.workspaceClaudeTerminal.View() + debugLog("renderClaudeTerminal - terminal view length: %d", len(terminalView)) + return terminalView +} + +func (v *WorkspaceViewImpl) renderSimpleSidebar(m *Model, width, height int) string { + debugLog("renderSimpleSidebar called - width=%d, height=%d", width, height) + + var sections []string + + if m.currentWorkspace != nil { + // Workspace info - no borders, just clean text + sections = append(sections, "📁 "+m.currentWorkspace.Name) + sections = append(sections, "🌿 "+m.currentWorkspace.Branch) + sections = append(sections, "📂 "+m.currentWorkspace.Path) + sections = append(sections, "") + + // Git status + if len(m.currentWorkspace.ChangedFiles) > 0 { + sections = append(sections, fmt.Sprintf("📝 %d changes", len(m.currentWorkspace.ChangedFiles))) + for i, file := range m.currentWorkspace.ChangedFiles { + if i >= 3 { // Limit to first 3 files + sections = append(sections, fmt.Sprintf(" ...%d more", len(m.currentWorkspace.ChangedFiles)-3)) + break + } + // Extract just filename + filename := file + if lastSlash := strings.LastIndex(file, "/"); lastSlash != -1 { + filename = file[lastSlash+1:] + } + sections = append(sections, " • "+filename) + } + } else { + sections = append(sections, "📝 No changes") + } + sections = append(sections, "") + + // Ports + if len(m.currentWorkspace.Ports) > 0 { + sections = append(sections, "🌐 Active Ports") + for _, port := range m.currentWorkspace.Ports { + sections = append(sections, fmt.Sprintf(" :%s %s", port.Port, port.Title)) + } + } else { + sections = append(sections, "🌐 No ports") + } + } else { + sections = append(sections, "No workspace") + } + + // Join all sections and ensure it fits the width + content := strings.Join(sections, "\n") + + // Simple style with just width constraint, no borders + style := lipgloss.NewStyle().Width(width).Align(lipgloss.Left) + result := style.Render(content) + + debugLog("renderSimpleSidebar - final length: %d", len(result)) + return result +} + +func (v *WorkspaceViewImpl) handleClaudeOutput(m *Model, msg shellOutputMsg) (*Model, tea.Cmd) { + debugLog("handleClaudeOutput - received %d bytes of data", len(msg.data)) + + // Initialize terminal emulator if needed + if m.workspaceClaudeTerminalEmulator == nil { + terminalWidth := m.workspaceClaudeTerminal.Width - 2 + debugLog("handleClaudeOutput - initializing terminal emulator with width=%d, height=%d", terminalWidth, m.workspaceClaudeTerminal.Height) + m.workspaceClaudeTerminalEmulator = NewTerminalEmulator(terminalWidth, m.workspaceClaudeTerminal.Height) + } + + // Process PTY output directly (JSON filtering now handled at WebSocket level) + if len(msg.data) > 0 { + // Process output through terminal emulator + m.workspaceClaudeTerminalEmulator.Write(msg.data) + // Always use the terminal emulator for proper handling + terminalOutput := m.workspaceClaudeTerminalEmulator.Render() + debugLog("handleClaudeOutput - terminal emulator rendered %d chars", len(terminalOutput)) + m.workspaceClaudeTerminal.SetContent(terminalOutput) + // Auto-scroll to bottom for new output + m.workspaceClaudeTerminal.GotoBottom() + } + + return m, nil +} + +func (v *WorkspaceViewImpl) handleClaudeError(m *Model, msg shellErrorMsg) (*Model, tea.Cmd) { + debugLog("Claude terminal error for workspace %s: %v", m.currentWorkspace.ID, msg.err) + // Could add error display to Claude terminal + return m, nil +} + +func (v *WorkspaceViewImpl) forwardToClaudeTerminal(m *Model, msg tea.KeyMsg) { + if m.currentWorkspace == nil { + return + } + + // Send input to the Claude terminal PTY session (use base name for PTY lookup) + claudeSessionID := m.currentWorkspace.Name + debugLog("Workspace view forwarding key to Claude terminal: %s", msg.String()) + + if globalShellManager != nil { + if session := globalShellManager.GetSession(claudeSessionID); session != nil && session.Client != nil { + var data []byte + if len(msg.Runes) > 0 { + data = []byte(string(msg.Runes)) + } else { + // Handle special keys + switch msg.Type { + case tea.KeyEnter: + data = []byte("\r") + case tea.KeyBackspace: + data = []byte{127} + case tea.KeyTab: + data = []byte("\t") + case tea.KeyUp: + data = []byte("\x1b[A") + case tea.KeyDown: + data = []byte("\x1b[B") + case tea.KeyRight: + data = []byte("\x1b[C") + case tea.KeyLeft: + data = []byte("\x1b[D") + default: + // Handle Ctrl combinations + switch msg.String() { + case components.KeyCtrlC: + data = []byte{3} + case components.KeyCtrlD: + data = []byte{4} + case components.KeyCtrlZ: + data = []byte{26} + } + } + } + if len(data) > 0 { + go func(d []byte, sessionID string) { + if err := session.Client.Send(d); err != nil { + debugLog("Failed to send data to workspace Claude terminal: %v", err) + if globalShellManager != nil && globalShellManager.program != nil { + globalShellManager.program.Send(shellErrorMsg{ + sessionID: sessionID, + err: err, + }) + } + } + }(data, claudeSessionID) + } + } + } +} + +func (v *WorkspaceViewImpl) resizeClaudeTerminal(m *Model, width, height int) { + if m.currentWorkspace == nil { + return + } + + if globalShellManager != nil { + // Resize Claude terminal (use base name for PTY lookup) + claudeSessionID := m.currentWorkspace.Name + if session := globalShellManager.GetSession(claudeSessionID); session != nil && session.Client != nil { + go func() { + if err := session.Client.Resize(width, height); err != nil { + debugLog("Failed to resize Claude terminal: %v", err) + } + }() + } + } +} + +// CreateWorkspaceSessions creates both Claude and regular terminal sessions for a workspace +func (v *WorkspaceViewImpl) CreateWorkspaceSessions(m *Model, workspace *WorkspaceInfo) (*Model, tea.Cmd) { + debugLog("CreateWorkspaceSessions called for workspace: %+v", workspace) + if workspace == nil { + debugLog("CreateWorkspaceSessions - workspace is nil") + return m, nil + } + + claudeSessionID := workspace.Name + + // Calculate terminal dimensions using same logic as Render method (simplified) + headerHeight := 3 + padding := 2 + availableHeight := m.height - headerHeight - padding + availableWidth := m.width - padding + + // Ensure minimum dimensions + if availableHeight < 10 { + availableHeight = 10 + } + if availableWidth < 40 { + availableWidth = 40 + } + + mainWidth := (availableWidth * 70) / 100 + claudeHeight := availableHeight // Full height for simplified view + + // Ensure minimum terminal height + if claudeHeight < 10 { + claudeHeight = 10 + } + + terminalWidth := mainWidth - 4 // Account for terminal borders + if terminalWidth < 20 { + terminalWidth = 20 + } + + // Initialize viewport (account for terminal borders) + m.workspaceClaudeTerminal.Width = terminalWidth + m.workspaceClaudeTerminal.Height = claudeHeight - 2 // Account for terminal border + debugLog("CreateWorkspaceSessions - initialized viewport: width=%d, height=%d", m.workspaceClaudeTerminal.Width, m.workspaceClaudeTerminal.Height) + + // Initialize terminal emulator + if m.workspaceClaudeTerminalEmulator == nil { + m.workspaceClaudeTerminalEmulator = NewTerminalEmulator(terminalWidth, claudeHeight-2) + debugLog("CreateWorkspaceSessions - initialized terminal emulator: width=%d, height=%d", terminalWidth, claudeHeight-2) + } else { + m.workspaceClaudeTerminalEmulator.Clear() + m.workspaceClaudeTerminalEmulator.Resize(terminalWidth, claudeHeight-2) + debugLog("CreateWorkspaceSessions - resized existing terminal emulator: width=%d, height=%d", terminalWidth, claudeHeight-2) + } + + debugLog("CreateWorkspaceSessions - creating Claude session: %s, terminalWidth=%d, terminalHeight=%d", claudeSessionID, terminalWidth, claudeHeight-2) + // Create Claude terminal session + claudeCmd := createAndConnectWorkspaceShell(claudeSessionID, terminalWidth, claudeHeight-2, workspace.Path, "?agent=claude") + + return m, claudeCmd +} + +// createAndConnectWorkspaceShell creates a shell session for a workspace with specific parameters +func createAndConnectWorkspaceShell(sessionID string, width, height int, workspacePath, agentParam string) tea.Cmd { + return func() tea.Msg { + debugLog("createAndConnectWorkspaceShell called - sessionID: %s, width: %d, height: %d, path: %s, agent: %s", sessionID, width, height, workspacePath, agentParam) + if globalShellManager == nil { + debugLog("createAndConnectWorkspaceShell - globalShellManager is nil") + return shellErrorMsg{sessionID: sessionID, err: fmt.Errorf("shell manager not initialized")} + } + + // Create session using the existing shell manager pattern + session := globalShellManager.CreateSession(sessionID) + debugLog("createAndConnectWorkspaceShell - created session: %+v", session) + + // Connect in background and send initial size + go func() { + baseURL := "http://localhost:8080" + if agentParam != "" { + baseURL += agentParam + } + debugLog("createAndConnectWorkspaceShell - connecting to: %s", baseURL) + + err := session.Client.Connect(baseURL) + if err != nil { + debugLog("createAndConnectWorkspaceShell - Failed to connect workspace shell session %s: %v", sessionID, err) + return + } + debugLog("createAndConnectWorkspaceShell - connected successfully") + + // Send resize to set initial terminal size + if err := session.Client.Resize(width, height); err != nil { + debugLog("Failed to resize workspace terminal %s: %v", sessionID, err) + } + + // Note: Directory changing is handled by the backend automatically + // No need to inject cd commands that show up in the terminal + }() + + return shellConnectedMsg{sessionID: sessionID} + } +}