From e3d26ee5dc2f1b5671acf768bfda35a34045abcb Mon Sep 17 00:00:00 2001 From: vanpelt Date: Wed, 6 Aug 2025 17:52:20 +0000 Subject: [PATCH 01/25] TUI Workspace View checkpoint: 1 --- container/internal/tui/model.go | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/container/internal/tui/model.go b/container/internal/tui/model.go index 9b9cf6ed..df3cdeb4 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,16 @@ type Model struct { showPortSelector bool selectedPortIndex int + // Workspace selector overlay + showWorkspaceSelector bool + selectedWorkspaceIndex int + currentWorkspace *WorkspaceInfo + workspaces []WorkspaceInfo + + // Workspace view state + workspaceClaudeTerminal viewport.Model + workspaceRegularTerminal viewport.Model + // SSE connection state sseConnected bool sseStarted bool @@ -155,6 +178,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 +189,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 } From 969460ea908814079118e9d1b2060a5a3a60935f Mon Sep 17 00:00:00 2001 From: vanpelt Date: Wed, 6 Aug 2025 17:52:50 +0000 Subject: [PATCH 02/25] TUI Workspace View checkpoint: 2 --- container/internal/tui/components/keys.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 From a33831e10fc89f249a14afe05ebafca5613c6a11 Mon Sep 17 00:00:00 2001 From: vanpelt Date: Wed, 6 Aug 2025 17:53:50 +0000 Subject: [PATCH 03/25] TUI Workspace View checkpoint: 3 --- container/internal/tui/view_workspace.go | 433 +++++++++++++++++++++++ 1 file changed, 433 insertions(+) create mode 100644 container/internal/tui/view_workspace.go diff --git a/container/internal/tui/view_workspace.go b/container/internal/tui/view_workspace.go new file mode 100644 index 00000000..53511cc3 --- /dev/null +++ b/container/internal/tui/view_workspace.go @@ -0,0 +1,433 @@ +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 both terminals + switch msg := msg.(type) { + case shellOutputMsg: + // Determine which terminal this is for based on session ID + if strings.HasSuffix(msg.sessionID, "-claude") { + return v.handleClaudeOutput(m, msg) + } else if strings.HasSuffix(msg.sessionID, "-regular") { + return v.handleRegularOutput(m, msg) + } + case shellErrorMsg: + // Handle errors for both terminals + if strings.HasSuffix(msg.sessionID, "-claude") { + return v.handleClaudeError(m, msg) + } else if strings.HasSuffix(msg.sessionID, "-regular") { + return v.handleRegularError(m, msg) + } + } + + // Update both viewport models + var cmd1, cmd2 tea.Cmd + m.workspaceClaudeTerminal, cmd1 = m.workspaceClaudeTerminal.Update(msg) + m.workspaceRegularTerminal, cmd2 = m.workspaceRegularTerminal.Update(msg) + + return m, tea.Batch(cmd1, cmd2) +} + +// 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 regular terminal (bottom terminal) + v.forwardToRegularTerminal(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 + headerHeight := 3 + totalHeight := msg.Height - headerHeight + + // Main content area is 75% width, right sidebar is 25% + mainWidth := (msg.Width * 75) / 100 + sidebarWidth := msg.Width - mainWidth - 2 // Account for borders + + // Claude terminal gets 75% of main height, regular terminal gets 25% + claudeHeight := (totalHeight * 75) / 100 + regularHeight := totalHeight - claudeHeight - 1 // Account for separator + + // Update viewport sizes + m.workspaceClaudeTerminal.Width = mainWidth - 2 + m.workspaceClaudeTerminal.Height = claudeHeight + m.workspaceRegularTerminal.Width = mainWidth - 2 + m.workspaceRegularTerminal.Height = regularHeight + + // Resize PTY sessions if they exist + v.resizeWorkspaceTerminals(m, mainWidth-2, claudeHeight, regularHeight) + + return m, nil +} + +// Render generates the workspace view content +func (v *WorkspaceViewImpl) Render(m *Model) string { + if m.currentWorkspace == nil { + return v.renderNoWorkspace(m) + } + + // Calculate layout dimensions + headerHeight := 3 + totalHeight := m.height - headerHeight + + // Main content area is 75% width, right sidebar is 25% + mainWidth := (m.width * 75) / 100 + sidebarWidth := m.width - mainWidth - 2 // Account for borders + + // Claude terminal gets 75% of main height, regular terminal gets 25% + claudeHeight := (totalHeight * 75) / 100 + regularHeight := totalHeight - claudeHeight - 1 // Account for separator + + // Header for the workspace + headerStyle := components.ShellHeaderStyle.Width(m.width - 2) + header := headerStyle.Render(fmt.Sprintf("📁 Workspace: %s (%s) | Press Esc to return to overview", + m.currentWorkspace.Name, m.currentWorkspace.Branch)) + + // Main content area (left side) + mainContent := v.renderMainContent(m, mainWidth, claudeHeight, regularHeight) + + // Right sidebar content + sidebarContent := v.renderSidebar(m, sidebarWidth, totalHeight) + + // Combine main and sidebar horizontally + workspaceContent := lipgloss.JoinHorizontal( + lipgloss.Top, + mainContent, + sidebarContent, + ) + + return fmt.Sprintf("%s\n%s", header, 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 workspace selected.\n\nPress Ctrl+W to select a workspace." + return centerStyle.Render(content) +} + +func (v *WorkspaceViewImpl) renderMainContent(m *Model, width, claudeHeight, regularHeight int) string { + // Claude terminal (top 75%) + claudeStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("62")). + Width(width). + Height(claudeHeight + 2) // Account for border + + claudeHeader := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("39")). + Padding(0, 1). + Render("🤖 Claude Terminal (?agent=claude)") + + // Set content for Claude terminal viewport + if m.currentWorkspace != nil { + claudeSessionID := m.currentWorkspace.ID + "-claude" + if globalShellManager != nil { + if session := globalShellManager.GetSession(claudeSessionID); session != nil { + m.workspaceClaudeTerminal.SetContent(string(session.Output)) + } else { + m.workspaceClaudeTerminal.SetContent("Connecting to Claude terminal...") + } + } + } + + claudeContent := claudeStyle.Render(claudeHeader + "\n" + m.workspaceClaudeTerminal.View()) + + // Regular terminal (bottom 25%) + regularStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("62")). + Width(width). + Height(regularHeight + 2) // Account for border + + regularHeader := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("39")). + Padding(0, 1). + Render("💻 Regular Terminal") + + // Set content for regular terminal viewport + if m.currentWorkspace != nil { + regularSessionID := m.currentWorkspace.ID + "-regular" + if globalShellManager != nil { + if session := globalShellManager.GetSession(regularSessionID); session != nil { + m.workspaceRegularTerminal.SetContent(string(session.Output)) + } else { + m.workspaceRegularTerminal.SetContent("Connecting to terminal...") + } + } + } + + regularContent := regularStyle.Render(regularHeader + "\n" + m.workspaceRegularTerminal.View()) + + return lipgloss.JoinVertical( + lipgloss.Left, + claudeContent, + regularContent, + ) +} + +func (v *WorkspaceViewImpl) renderSidebar(m *Model, width, height int) string { + sidebarStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("62")). + Width(width). + Height(height + 2). // Account for border + Padding(1) + + var sections []string + + if m.currentWorkspace != nil { + // Git status section + sections = append(sections, lipgloss.NewStyle().Bold(true).Render("📝 Git Status")) + sections = append(sections, fmt.Sprintf("Branch: %s", m.currentWorkspace.Branch)) + sections = append(sections, "") + + // Changed files section + sections = append(sections, lipgloss.NewStyle().Bold(true).Render("📄 Changed Files")) + if len(m.currentWorkspace.ChangedFiles) > 0 { + for i, file := range m.currentWorkspace.ChangedFiles { + if i >= 5 { // Limit display to first 5 files + sections = append(sections, fmt.Sprintf("... and %d more", len(m.currentWorkspace.ChangedFiles)-5)) + break + } + // Extract just filename from path + filename := file + if lastSlash := strings.LastIndex(file, "/"); lastSlash != -1 { + filename = file[lastSlash+1:] + } + sections = append(sections, fmt.Sprintf("• %s", filename)) + } + } else { + sections = append(sections, "No changes") + } + sections = append(sections, "") + + // Ports section + sections = append(sections, lipgloss.NewStyle().Bold(true).Render("🌐 Active Ports")) + if len(m.currentWorkspace.Ports) > 0 { + for _, port := range m.currentWorkspace.Ports { + sections = append(sections, fmt.Sprintf(":%s %s", port.Port, port.Title)) + } + } else { + sections = append(sections, "No active ports") + } + } else { + sections = append(sections, "No workspace data available") + } + + content := strings.Join(sections, "\n") + return sidebarStyle.Render(content) +} + +func (v *WorkspaceViewImpl) handleClaudeOutput(m *Model, msg shellOutputMsg) (*Model, tea.Cmd) { + // Update the Claude terminal viewport + m.workspaceClaudeTerminal.SetContent(string(msg.data)) + m.workspaceClaudeTerminal.GotoBottom() + return m, nil +} + +func (v *WorkspaceViewImpl) handleRegularOutput(m *Model, msg shellOutputMsg) (*Model, tea.Cmd) { + // Update the regular terminal viewport + m.workspaceRegularTerminal.SetContent(string(msg.data)) + m.workspaceRegularTerminal.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) handleRegularError(m *Model, msg shellErrorMsg) (*Model, tea.Cmd) { + debugLog("Regular terminal error for workspace %s: %v", m.currentWorkspace.ID, msg.err) + // Could add error display to regular terminal + return m, nil +} + +func (v *WorkspaceViewImpl) forwardToRegularTerminal(m *Model, msg tea.KeyMsg) { + if m.currentWorkspace == nil { + return + } + + // Send input to the regular terminal PTY session + regularSessionID := m.currentWorkspace.ID + "-regular" + debugLog("Workspace view forwarding key to regular terminal: %s", msg.String()) + + if globalShellManager != nil { + if session := globalShellManager.GetSession(regularSessionID); 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 regular terminal: %v", err) + if globalShellManager != nil && globalShellManager.program != nil { + globalShellManager.program.Send(shellErrorMsg{ + sessionID: sessionID, + err: err, + }) + } + } + }(data, regularSessionID) + } + } + } +} + +func (v *WorkspaceViewImpl) resizeWorkspaceTerminals(m *Model, width, claudeHeight, regularHeight int) { + if m.currentWorkspace == nil { + return + } + + if globalShellManager != nil { + // Resize Claude terminal + claudeSessionID := m.currentWorkspace.ID + "-claude" + if session := globalShellManager.GetSession(claudeSessionID); session != nil && session.Client != nil { + go func() { + if err := session.Client.Resize(width, claudeHeight); err != nil { + debugLog("Failed to resize Claude terminal: %v", err) + } + }() + } + + // Resize regular terminal + regularSessionID := m.currentWorkspace.ID + "-regular" + if session := globalShellManager.GetSession(regularSessionID); session != nil && session.Client != nil { + go func() { + if err := session.Client.Resize(width, regularHeight); err != nil { + debugLog("Failed to resize regular 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) { + if workspace == nil { + return m, nil + } + + claudeSessionID := workspace.ID + "-claude" + regularSessionID := workspace.ID + "-regular" + + // Calculate terminal dimensions + headerHeight := 3 + totalHeight := m.height - headerHeight + mainWidth := (m.width * 75) / 100 + claudeHeight := (totalHeight * 75) / 100 + regularHeight := totalHeight - claudeHeight - 1 + + terminalWidth := mainWidth - 2 + + // Initialize viewports + m.workspaceClaudeTerminal.Width = terminalWidth + m.workspaceClaudeTerminal.Height = claudeHeight + m.workspaceRegularTerminal.Width = terminalWidth + m.workspaceRegularTerminal.Height = regularHeight + + // Create both terminal sessions + var cmds []tea.Cmd + + // Create Claude terminal session with ?agent=claude parameter + claudeCmd := createAndConnectWorkspaceShell(claudeSessionID, terminalWidth, claudeHeight, workspace.Path, "?agent=claude") + cmds = append(cmds, claudeCmd) + + // Create regular terminal session + regularCmd := createAndConnectWorkspaceShell(regularSessionID, terminalWidth, regularHeight, workspace.Path, "") + cmds = append(cmds, regularCmd) + + return m, tea.Batch(cmds...) +} + +// 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 { + if globalShellManager == nil { + return shellErrorMsg{sessionID: sessionID, err: fmt.Errorf("shell manager not initialized")} + } + + // Create PTY client with workspace-specific settings + client := NewPTYClient("http://localhost:8080/v1/pty/"+sessionID+agentParam, sessionID) + + // Set working directory to workspace path + client.workingDir = workspacePath + + if err := client.Connect(width, height); err != nil { + return shellErrorMsg{sessionID: sessionID, err: err} + } + + // Add session to manager + globalShellManager.AddSession(sessionID, client) + + return shellConnectedMsg{sessionID: sessionID} + } +} \ No newline at end of file From 5a2f7e2547186ff7abb8f305a18a884dc66b383b Mon Sep 17 00:00:00 2001 From: vanpelt Date: Wed, 6 Aug 2025 17:54:20 +0000 Subject: [PATCH 04/25] TUI Workspace View checkpoint: 4 --- container/internal/tui/update.go | 131 +++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/container/internal/tui/update.go b/container/internal/tui/update.go index f217b644..e18862dd 100644 --- a/container/internal/tui/update.go +++ b/container/internal/tui/update.go @@ -164,6 +164,21 @@ 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 { + // Initialize mock workspaces for now - TODO: fetch from API + m.workspaces = m.initializeMockWorkspaces() + if len(m.workspaces) > 0 { + m.showWorkspaceSelector = true + m.selectedWorkspaceIndex = 0 + } + } + return &m, nil, true } // Handle port selector overlay if active @@ -171,6 +186,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 } @@ -586,6 +606,117 @@ 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 +} + +// initializeMockWorkspaces creates mock workspace data for development +func (m Model) initializeMockWorkspaces() []WorkspaceInfo { + // TODO: Replace this with actual API call to fetch workspaces + return []WorkspaceInfo{ + { + ID: "workspace-1", + Name: "catnip-main", + Path: "/workspace/catnip", + Branch: "main", + IsActive: true, + ChangedFiles: []string{ + "container/internal/tui/view_workspace.go", + "container/internal/tui/model.go", + "src/components/WorkspaceRightSidebar.tsx", + }, + Ports: []PortInfo{ + {Port: "3000", Title: "React Dev Server", Service: "vite"}, + {Port: "8080", Title: "Main API", Service: "go-api"}, + }, + }, + { + ID: "workspace-2", + Name: "feature-branch", + Path: "/workspace/catnip-feature", + Branch: "feature/workspace-ui", + IsActive: false, + ChangedFiles: []string{ + "frontend/src/App.tsx", + "README.md", + }, + Ports: []PortInfo{ + {Port: "3001", Title: "Test Server", Service: "node"}, + }, + }, + { + ID: "workspace-3", + Name: "tom-repo", + Path: "/workspace/tom", + Branch: "main", + IsActive: false, + ChangedFiles: []string{}, + Ports: []PortInfo{}, + }, + } +} + // Version check handler func (m Model) handleVersionCheck(msg VersionCheckMsg) (tea.Model, tea.Cmd) { m.upgradeAvailable = msg.UpgradeAvailable From d9eed5003eb6ea2b4b25ac6b74f9d7d195376ca7 Mon Sep 17 00:00:00 2001 From: vanpelt Date: Wed, 6 Aug 2025 17:54:50 +0000 Subject: [PATCH 05/25] TUI Workspace View checkpoint: 5 --- container/internal/tui/app.go | 64 ++++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/container/internal/tui/app.go b/container/internal/tui/app.go index 8be4518b..9a768fd7 100644 --- a/container/internal/tui/app.go +++ b/container/internal/tui/app.go @@ -175,6 +175,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 +195,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 +305,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)%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 From dbbdc4bc42d8adc6ead09164ad0c28e9387e9447 Mon Sep 17 00:00:00 2001 From: vanpelt Date: Wed, 6 Aug 2025 17:55:21 +0000 Subject: [PATCH 06/25] TUI Workspace View checkpoint: 6 --- container/internal/tui/app.go | 4 +++ container/internal/tui/view_overview.go | 44 +++++++++++++++++++------ 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/container/internal/tui/app.go b/container/internal/tui/app.go index 9a768fd7..49b31c33 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 diff --git a/container/internal/tui/view_overview.go b/container/internal/tui/view_overview.go index 4c76c5b4..b441deff 100644 --- a/container/internal/tui/view_overview.go +++ b/container/internal/tui/view_overview.go @@ -98,20 +98,44 @@ func (v *OverviewViewImpl) Render(m *Model) string { } sections = append(sections, "") - // Ports - 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)) + // 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 { - sections = append(sections, fmt.Sprintf(" %s → http://localhost:8080/%s", portInfo.Title, portInfo.Port)) + 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, "🌐 No services detected") + 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, "") From 1e3b08cd09d784cc6282dac3100ad8ec9eb0cbe3 Mon Sep 17 00:00:00 2001 From: vanpelt Date: Wed, 6 Aug 2025 17:55:51 +0000 Subject: [PATCH 07/25] TUI Workspace View checkpoint: 7 --- container/internal/tui/app.go | 2 +- container/internal/tui/update.go | 8 ++++++++ container/internal/tui/view_overview.go | 26 +++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/container/internal/tui/app.go b/container/internal/tui/app.go index 49b31c33..f46b1b9f 100644 --- a/container/internal/tui/app.go +++ b/container/internal/tui/app.go @@ -332,7 +332,7 @@ func (m Model) renderWorkspaceSelector() string { changeText = fmt.Sprintf(" • %d changes", changeCount) } - item := fmt.Sprintf("%s %s %s (%s)%s", statusIndicator, workspace.Name, workspace.Branch, changeText) + item := fmt.Sprintf("%s %s (%s)%s", statusIndicator, workspace.Name, workspace.Branch, changeText) menuItems = append(menuItems, prefix+item) } diff --git a/container/internal/tui/update.go b/container/internal/tui/update.go index e18862dd..98e04537 100644 --- a/container/internal/tui/update.go +++ b/container/internal/tui/update.go @@ -469,6 +469,10 @@ func (m Model) handleShellOutput(msg shellOutputMsg) (tea.Model, tea.Cmd) { shellView := m.views[ShellView].(*ShellViewImpl) newModel, cmd := shellView.handleShellOutput(&m, msg) return *newModel, cmd + } else if m.currentView == WorkspaceView { + workspaceView := m.views[WorkspaceView].(*WorkspaceViewImpl) + newModel, cmd := workspaceView.Update(&m, msg) + return *newModel, cmd } return m, nil } @@ -478,6 +482,10 @@ func (m Model) handleShellError(msg shellErrorMsg) (tea.Model, tea.Cmd) { shellView := m.views[ShellView].(*ShellViewImpl) newModel, cmd := shellView.handleShellError(&m, msg) return *newModel, cmd + } else if m.currentView == WorkspaceView { + workspaceView := m.views[WorkspaceView].(*WorkspaceViewImpl) + newModel, cmd := workspaceView.Update(&m, msg) + return *newModel, cmd } return m, nil } diff --git a/container/internal/tui/view_overview.go b/container/internal/tui/view_overview.go index b441deff..3fa123da 100644 --- a/container/internal/tui/view_overview.go +++ b/container/internal/tui/view_overview.go @@ -140,6 +140,32 @@ func (v *OverviewViewImpl) Render(m *Model) string { sections = append(sections, "") + // Detected Services (condensed) + if len(m.ports) > 0 { + sections = append(sections, components.SubHeaderStyle.Render("🌐 Detected Services")) + + // 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, components.SubHeaderStyle.Render("🌐 Detected Services")) + sections = append(sections, " No services detected") + } + + sections = append(sections, "") + // Repository Info sections = append(sections, components.SectionHeaderStyle.Render("📁 Repository Info")) From 3aa808d92c3418a8b25e917c32fcbd85c88c860f Mon Sep 17 00:00:00 2001 From: vanpelt Date: Wed, 6 Aug 2025 17:56:21 +0000 Subject: [PATCH 08/25] TUI Workspace View checkpoint: 8 --- container/internal/assets/dist/index.html | 81 ++--------------------- container/internal/tui/messages.go | 3 + 2 files changed, 10 insertions(+), 74 deletions(-) diff --git a/container/internal/assets/dist/index.html b/container/internal/assets/dist/index.html index 5e786f99..d884b3e0 100644 --- a/container/internal/assets/dist/index.html +++ b/container/internal/assets/dist/index.html @@ -1,81 +1,14 @@ - + + - Catnip - Frontend Not Built - + Catnip + + -
-

⚠️ Frontend Assets Not Built

- -
-

Development Mode: The frontend assets haven't been built yet.

-
- -

This is a placeholder page served from embedded fallback assets. To get the full Catnip frontend:

- -
- # Build frontend assets
- pnpm build -
- -

Or run in development mode with Vite:

- -
- # Start frontend dev server
- pnpm dev -
- -

Then restart the Go server to pick up the assets.

- -

API is still available:

- -
+
- \ No newline at end of file + diff --git a/container/internal/tui/messages.go b/container/internal/tui/messages.go index 066f136a..f70e990b 100644 --- a/container/internal/tui/messages.go +++ b/container/internal/tui/messages.go @@ -26,6 +26,9 @@ type shellErrorMsg struct { sessionID string err error } +type shellConnectedMsg struct { + sessionID string +} // SSE event messages type sseConnectedMsg struct{} From 0f3ac1f5543ea466099463b10f93d187c26d25cb Mon Sep 17 00:00:00 2001 From: vanpelt Date: Wed, 6 Aug 2025 17:56:51 +0000 Subject: [PATCH 09/25] TUI Workspace View checkpoint: 9 --- container/internal/tui/view_workspace.go | 39 +++++++++++++++++------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/container/internal/tui/view_workspace.go b/container/internal/tui/view_workspace.go index 53511cc3..96f9a3e8 100644 --- a/container/internal/tui/view_workspace.go +++ b/container/internal/tui/view_workspace.go @@ -72,7 +72,7 @@ func (v *WorkspaceViewImpl) HandleResize(m *Model, msg tea.WindowSizeMsg) (*Mode // Main content area is 75% width, right sidebar is 25% mainWidth := (msg.Width * 75) / 100 - sidebarWidth := msg.Width - mainWidth - 2 // Account for borders + _ = msg.Width - mainWidth - 2 // sidebarWidth - Account for borders // Claude terminal gets 75% of main height, regular terminal gets 25% claudeHeight := (totalHeight * 75) / 100 @@ -415,18 +415,35 @@ func createAndConnectWorkspaceShell(sessionID string, width, height int, workspa return shellErrorMsg{sessionID: sessionID, err: fmt.Errorf("shell manager not initialized")} } - // Create PTY client with workspace-specific settings - client := NewPTYClient("http://localhost:8080/v1/pty/"+sessionID+agentParam, sessionID) + // Create session using the existing shell manager pattern + session := globalShellManager.CreateSession(sessionID) - // Set working directory to workspace path - client.workingDir = workspacePath - - if err := client.Connect(width, height); err != nil { - return shellErrorMsg{sessionID: sessionID, err: err} - } + // Connect in background and send initial size + go func() { + baseURL := "http://localhost:8080" + if agentParam != "" { + baseURL += agentParam + } + + err := session.Client.Connect(baseURL) + if err != nil { + debugLog("Failed to connect workspace shell session %s: %v", sessionID, err) + return + } - // Add session to manager - globalShellManager.AddSession(sessionID, client) + // 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) + } + + // Change to workspace directory + if workspacePath != "" { + cdCmd := fmt.Sprintf("cd %s\n", workspacePath) + if err := session.Client.Send([]byte(cdCmd)); err != nil { + debugLog("Failed to change directory for workspace %s: %v", sessionID, err) + } + } + }() return shellConnectedMsg{sessionID: sessionID} } From a443269abfde2b3a7cea1e1b6af67280f64d4ceb Mon Sep 17 00:00:00 2001 From: vanpelt Date: Wed, 6 Aug 2025 17:57:21 +0000 Subject: [PATCH 10/25] TUI Workspace View checkpoint: 10 --- container/internal/assets/dist/index.html | 81 +++++++++++++++++++++-- 1 file changed, 74 insertions(+), 7 deletions(-) diff --git a/container/internal/assets/dist/index.html b/container/internal/assets/dist/index.html index d884b3e0..5e786f99 100644 --- a/container/internal/assets/dist/index.html +++ b/container/internal/assets/dist/index.html @@ -1,14 +1,81 @@ - + - - Catnip - - + Catnip - Frontend Not Built + -
+
+

⚠️ Frontend Assets Not Built

+ +
+

Development Mode: The frontend assets haven't been built yet.

+
+ +

This is a placeholder page served from embedded fallback assets. To get the full Catnip frontend:

+ +
+ # Build frontend assets
+ pnpm build +
+ +

Or run in development mode with Vite:

+ +
+ # Start frontend dev server
+ pnpm dev +
+ +

Then restart the Go server to pick up the assets.

+ +

API is still available:

+ +
- + \ No newline at end of file From 4538003c8fbab9421b133148d43d521c274726e8 Mon Sep 17 00:00:00 2001 From: vanpelt Date: Wed, 6 Aug 2025 21:29:22 -0400 Subject: [PATCH 11/25] Fix lint error --- container/internal/tui/update.go | 36 ++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/container/internal/tui/update.go b/container/internal/tui/update.go index 98e04537..c137f2d4 100644 --- a/container/internal/tui/update.go +++ b/container/internal/tui/update.go @@ -164,7 +164,7 @@ 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 { @@ -465,29 +465,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 - } else if m.currentView == WorkspaceView { + 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 - } else if m.currentView == WorkspaceView { + 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 @@ -630,7 +634,7 @@ func (m Model) handleWorkspaceSelectorKeys(msg tea.KeyMsg) (*Model, tea.Cmd, boo 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) @@ -666,7 +670,7 @@ func (m Model) handleWorkspaceSelectorKeys(msg tea.KeyMsg) (*Model, tea.Cmd, boo 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) @@ -700,7 +704,7 @@ func (m Model) initializeMockWorkspaces() []WorkspaceInfo { }, }, { - ID: "workspace-2", + ID: "workspace-2", Name: "feature-branch", Path: "/workspace/catnip-feature", Branch: "feature/workspace-ui", @@ -714,13 +718,13 @@ func (m Model) initializeMockWorkspaces() []WorkspaceInfo { }, }, { - ID: "workspace-3", - Name: "tom-repo", - Path: "/workspace/tom", - Branch: "main", - IsActive: false, + ID: "workspace-3", + Name: "tom-repo", + Path: "/workspace/tom", + Branch: "main", + IsActive: false, ChangedFiles: []string{}, - Ports: []PortInfo{}, + Ports: []PortInfo{}, }, } } From 2e8a5a2aff8e073ec7fc279f28f09d201059a1cc Mon Sep 17 00:00:00 2001 From: vanpelt Date: Thu, 7 Aug 2025 01:36:53 +0000 Subject: [PATCH 12/25] Workspace Sync checkpoint: 1 --- container/internal/tui/sse_client.go | 9 +++++++++ 1 file changed, 9 insertions(+) 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) From 0ec07b3b6f6c296b042ad519bdd26c2b7f3fc82a Mon Sep 17 00:00:00 2001 From: vanpelt Date: Thu, 7 Aug 2025 01:37:01 +0000 Subject: [PATCH 13/25] Workspace Sync checkpoint: 1 --- container/internal/tui/messages.go | 1 + 1 file changed, 1 insertion(+) diff --git a/container/internal/tui/messages.go b/container/internal/tui/messages.go index f70e990b..f34fb471 100644 --- a/container/internal/tui/messages.go +++ b/container/internal/tui/messages.go @@ -49,3 +49,4 @@ type sseContainerStatusMsg struct { status string message string } +type sseWorktreeUpdatedMsg struct{} From 775c20d0ae92480f12458ccd6ff43fb90c1d77bb Mon Sep 17 00:00:00 2001 From: vanpelt Date: Thu, 7 Aug 2025 01:37:53 +0000 Subject: [PATCH 14/25] Workspace Sync checkpoint: 2 --- container/internal/tui/commands.go | 20 ++++++++++++++++++++ container/internal/tui/messages.go | 1 + 2 files changed, 21 insertions(+) diff --git a/container/internal/tui/commands.go b/container/internal/tui/commands.go index 19fb58f9..8cf535f1 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 diff --git a/container/internal/tui/messages.go b/container/internal/tui/messages.go index f34fb471..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 { From e61712757065ec17ffd87376a0df004b3a87118e Mon Sep 17 00:00:00 2001 From: vanpelt Date: Thu, 7 Aug 2025 01:38:01 +0000 Subject: [PATCH 15/25] Workspace Sync checkpoint: 2 --- container/internal/tui/commands.go | 41 ++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/container/internal/tui/commands.go b/container/internal/tui/commands.go index 8cf535f1..bfde763c 100644 --- a/container/internal/tui/commands.go +++ b/container/internal/tui/commands.go @@ -152,6 +152,7 @@ func (m *Model) initCommands() tea.Cmd { m.fetchRepositoryInfo(), m.fetchHealthStatus(), m.fetchPorts(), + m.fetchWorkspaces(), m.fetchContainerInfo(), m.shellSpinner.Tick, tick(), @@ -161,3 +162,43 @@ 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: wt.IsActive, + ChangedFiles: make([]string, len(wt.ChangedFiles)), + Ports: []PortInfo{}, // TODO: Map ports if available in worktree model + } + + // Copy changed files + copy(workspace.ChangedFiles, wt.ChangedFiles) + + workspaces = append(workspaces, workspace) + } + + return workspaces, nil +} From ad3fee5e3442390bfec5835d1968f96b6f4e2b33 Mon Sep 17 00:00:00 2001 From: vanpelt Date: Thu, 7 Aug 2025 01:38:31 +0000 Subject: [PATCH 16/25] Workspace Sync checkpoint: 3 --- container/internal/tui/update.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/container/internal/tui/update.go b/container/internal/tui/update.go index c137f2d4..01c01aa8 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: @@ -739,3 +743,17 @@ 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)) + 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() +} From 2c6a25bd48d4497abf6857706579efd3ed83b6d0 Mon Sep 17 00:00:00 2001 From: vanpelt Date: Thu, 7 Aug 2025 01:38:53 +0000 Subject: [PATCH 17/25] Workspace Sync checkpoint: 3 --- container/internal/tui/update.go | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/container/internal/tui/update.go b/container/internal/tui/update.go index 01c01aa8..d8d7bab9 100644 --- a/container/internal/tui/update.go +++ b/container/internal/tui/update.go @@ -175,12 +175,8 @@ func (m Model) handleGlobalKeys(msg tea.KeyMsg) (*Model, tea.Cmd, bool) { m.showWorkspaceSelector = true m.selectedWorkspaceIndex = 0 // Default to first workspace } else { - // Initialize mock workspaces for now - TODO: fetch from API - m.workspaces = m.initializeMockWorkspaces() - if len(m.workspaces) > 0 { - m.showWorkspaceSelector = true - m.selectedWorkspaceIndex = 0 - } + // Fetch workspaces from API + return &m, m.fetchWorkspaces(), true } return &m, nil, true } @@ -236,6 +232,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...) } @@ -748,6 +750,13 @@ func (m Model) handleVersionCheck(msg VersionCheckMsg) (tea.Model, tea.Cmd) { 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 trying to show the workspace selector but had no workspaces, + // show it now if we have workspaces + if len(m.workspaces) > 0 && m.showWorkspaceSelector { + m.selectedWorkspaceIndex = 0 + } + return m, nil } From 344cfb2bdfaba34dd7eb959030872001f24e03ee Mon Sep 17 00:00:00 2001 From: vanpelt Date: Thu, 7 Aug 2025 01:39:31 +0000 Subject: [PATCH 18/25] Workspace Sync checkpoint: 4 --- container/internal/tui/model.go | 25 +++++++++++++------------ container/internal/tui/update.go | 10 ++++++---- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/container/internal/tui/model.go b/container/internal/tui/model.go index df3cdeb4..2bc7c351 100644 --- a/container/internal/tui/model.go +++ b/container/internal/tui/model.go @@ -55,13 +55,13 @@ type PortInfo struct { // WorkspaceInfo represents information about a workspace type WorkspaceInfo struct { - ID string - Name string - Path string - Branch string - IsActive bool + ID string + Name string + Path string + Branch string + IsActive bool ChangedFiles []string - Ports []PortInfo + Ports []PortInfo } // Model represents the main application state @@ -129,13 +129,14 @@ type Model struct { selectedPortIndex int // Workspace selector overlay - showWorkspaceSelector bool - selectedWorkspaceIndex int - currentWorkspace *WorkspaceInfo - workspaces []WorkspaceInfo - + showWorkspaceSelector bool + selectedWorkspaceIndex int + currentWorkspace *WorkspaceInfo + workspaces []WorkspaceInfo + waitingToShowWorkspaces bool + // Workspace view state - workspaceClaudeTerminal viewport.Model + workspaceClaudeTerminal viewport.Model workspaceRegularTerminal viewport.Model // SSE connection state diff --git a/container/internal/tui/update.go b/container/internal/tui/update.go index d8d7bab9..7b8a6835 100644 --- a/container/internal/tui/update.go +++ b/container/internal/tui/update.go @@ -175,7 +175,8 @@ func (m Model) handleGlobalKeys(msg tea.KeyMsg) (*Model, tea.Cmd, bool) { m.showWorkspaceSelector = true m.selectedWorkspaceIndex = 0 // Default to first workspace } else { - // Fetch workspaces from API + // 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 @@ -751,9 +752,10 @@ 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 trying to show the workspace selector but had no workspaces, - // show it now if we have workspaces - if len(m.workspaces) > 0 && m.showWorkspaceSelector { + // If we were waiting to show workspaces and now have some, show the selector + if len(m.workspaces) > 0 && m.waitingToShowWorkspaces { + m.waitingToShowWorkspaces = false + m.showWorkspaceSelector = true m.selectedWorkspaceIndex = 0 } From b18425c6db09300ae868eac20f9ccbf144d0e5a8 Mon Sep 17 00:00:00 2001 From: vanpelt Date: Thu, 7 Aug 2025 01:39:53 +0000 Subject: [PATCH 19/25] Workspace Sync checkpoint: 4 --- container/internal/assets/dist/index.html | 81 ++--------------------- 1 file changed, 7 insertions(+), 74 deletions(-) diff --git a/container/internal/assets/dist/index.html b/container/internal/assets/dist/index.html index 5e786f99..c66180f2 100644 --- a/container/internal/assets/dist/index.html +++ b/container/internal/assets/dist/index.html @@ -1,81 +1,14 @@ - + + - Catnip - Frontend Not Built - + Catnip + + -
-

⚠️ Frontend Assets Not Built

- -
-

Development Mode: The frontend assets haven't been built yet.

-
- -

This is a placeholder page served from embedded fallback assets. To get the full Catnip frontend:

- -
- # Build frontend assets
- pnpm build -
- -

Or run in development mode with Vite:

- -
- # Start frontend dev server
- pnpm dev -
- -

Then restart the Go server to pick up the assets.

- -

API is still available:

- -
+
- \ No newline at end of file + From d17d133b9482a8acb291ef39ef22e48db990fe99 Mon Sep 17 00:00:00 2001 From: vanpelt Date: Thu, 7 Aug 2025 01:40:31 +0000 Subject: [PATCH 20/25] Workspace Sync checkpoint: 5 --- container/internal/assets/dist/index.html | 81 +++++++++++++++++++++-- container/internal/tui/commands.go | 12 ++-- 2 files changed, 81 insertions(+), 12 deletions(-) diff --git a/container/internal/assets/dist/index.html b/container/internal/assets/dist/index.html index c66180f2..5e786f99 100644 --- a/container/internal/assets/dist/index.html +++ b/container/internal/assets/dist/index.html @@ -1,14 +1,81 @@ - + - - Catnip - - + Catnip - Frontend Not Built + -
+
+

⚠️ Frontend Assets Not Built

+ +
+

Development Mode: The frontend assets haven't been built yet.

+
+ +

This is a placeholder page served from embedded fallback assets. To get the full Catnip frontend:

+ +
+ # Build frontend assets
+ pnpm build +
+ +

Or run in development mode with Vite:

+ +
+ # Start frontend dev server
+ pnpm dev +
+ +

Then restart the Go server to pick up the assets.

+ +

API is still available:

+ +
- + \ No newline at end of file diff --git a/container/internal/tui/commands.go b/container/internal/tui/commands.go index bfde763c..e022911d 100644 --- a/container/internal/tui/commands.go +++ b/container/internal/tui/commands.go @@ -189,13 +189,15 @@ func fetchWorkspacesFromAPI() ([]WorkspaceInfo, error) { Name: wt.Name, Path: wt.Path, Branch: wt.Branch, - IsActive: wt.IsActive, - ChangedFiles: make([]string, len(wt.ChangedFiles)), - Ports: []PortInfo{}, // TODO: Map ports if available in worktree model + 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 } - // Copy changed files - copy(workspace.ChangedFiles, wt.ChangedFiles) + // Add indicator if worktree is dirty (has uncommitted changes) + if wt.IsDirty { + workspace.ChangedFiles = []string{"(uncommitted changes)"} // Placeholder + } workspaces = append(workspaces, workspace) } From e94a001262687d3105914838981b904eba555267 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 7 Aug 2025 01:51:22 +0000 Subject: [PATCH 21/25] Test sync fix --- test-sync.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 test-sync.txt diff --git a/test-sync.txt b/test-sync.txt new file mode 100644 index 00000000..6b5831d2 --- /dev/null +++ b/test-sync.txt @@ -0,0 +1 @@ +# Test sync fix From 2a34170d181f591b82ee0ec044176664e51cf780 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 7 Aug 2025 01:53:25 +0000 Subject: [PATCH 22/25] Test sync fix 2 --- test-sync.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/test-sync.txt b/test-sync.txt index 6b5831d2..9cbfb37c 100644 --- a/test-sync.txt +++ b/test-sync.txt @@ -1 +1,2 @@ # Test sync fix +# Test sync fix 2 From f013da57c56fb780f2ae81511d4d6492517ea461 Mon Sep 17 00:00:00 2001 From: "Chris Van Pelt (CVP)" Date: Wed, 6 Aug 2025 21:58:28 -0400 Subject: [PATCH 23/25] Delete test-sync.txt --- test-sync.txt | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 test-sync.txt diff --git a/test-sync.txt b/test-sync.txt deleted file mode 100644 index 9cbfb37c..00000000 --- a/test-sync.txt +++ /dev/null @@ -1,2 +0,0 @@ -# Test sync fix -# Test sync fix 2 From 66de04f6863e3134afe14ca5990260aba46ca982 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 6 Aug 2025 22:00:44 -0400 Subject: [PATCH 24/25] Fix branch syncing between worktree custom refs and nice branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sync mechanism was failing because it was checking for branch existence and updating branches in the worktree path instead of the main repository. Since worktrees use custom refs (refs/catnip/*), they don't have access to regular branches like 'fix/tui-workspace-live-updates'. Key changes: - syncToNiceBranch: Check branch existence in main repo, not worktree - syncToNiceBranch: Update branches in main repo, not worktree - hasUnsyncedNiceBranch: Check branch existence and get hashes from main repo - Remove duplicate findRepositoryForWorktree call in syncToNiceBranch This resolves the "No commits between feature/tui-workspace-view and fix/tui-workspace-live-updates" error when creating PRs, as the nice branches are now properly kept in sync with their custom refs. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- container/internal/services/commit_sync.go | 31 +++++++++++++++------- 1 file changed, 21 insertions(+), 10 deletions(-) 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 } From 773d84cce9a651e8566fe1b803b7623c4764969c Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 7 Aug 2025 00:49:40 -0400 Subject: [PATCH 25/25] Improve workspace view layout and move JSON filtering to WebSocket level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move JSON filtering from view layer to PTY client WebSocket handling - Separate text (JSON control) and binary (PTY output) message processing - Fix sidebar width to use fixed columns (20-30) instead of percentage - Remove unused JSON filtering code from workspace view - Simplify terminal output processing at view layer - Add terminal emulator support for proper ANSI handling - Remove unused functions and fix lint issues 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- container/internal/tui/model.go | 6 +- container/internal/tui/pty_client.go | 10 +- container/internal/tui/update.go | 59 +-- container/internal/tui/view_workspace.go | 489 +++++++++++++---------- 4 files changed, 302 insertions(+), 262 deletions(-) diff --git a/container/internal/tui/model.go b/container/internal/tui/model.go index 2bc7c351..2868f953 100644 --- a/container/internal/tui/model.go +++ b/container/internal/tui/model.go @@ -136,8 +136,10 @@ type Model struct { waitingToShowWorkspaces bool // Workspace view state - workspaceClaudeTerminal viewport.Model - workspaceRegularTerminal viewport.Model + workspaceClaudeTerminal viewport.Model + workspaceRegularTerminal viewport.Model + workspaceClaudeTerminalEmulator *TerminalEmulator + workspaceLastOutputLength int // SSE connection state sseConnected bool 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/update.go b/container/internal/tui/update.go index 7b8a6835..30e3b541 100644 --- a/container/internal/tui/update.go +++ b/container/internal/tui/update.go @@ -690,52 +690,6 @@ func (m Model) handleWorkspaceSelectorKeys(msg tea.KeyMsg) (*Model, tea.Cmd, boo return &m, nil, true } -// initializeMockWorkspaces creates mock workspace data for development -func (m Model) initializeMockWorkspaces() []WorkspaceInfo { - // TODO: Replace this with actual API call to fetch workspaces - return []WorkspaceInfo{ - { - ID: "workspace-1", - Name: "catnip-main", - Path: "/workspace/catnip", - Branch: "main", - IsActive: true, - ChangedFiles: []string{ - "container/internal/tui/view_workspace.go", - "container/internal/tui/model.go", - "src/components/WorkspaceRightSidebar.tsx", - }, - Ports: []PortInfo{ - {Port: "3000", Title: "React Dev Server", Service: "vite"}, - {Port: "8080", Title: "Main API", Service: "go-api"}, - }, - }, - { - ID: "workspace-2", - Name: "feature-branch", - Path: "/workspace/catnip-feature", - Branch: "feature/workspace-ui", - IsActive: false, - ChangedFiles: []string{ - "frontend/src/App.tsx", - "README.md", - }, - Ports: []PortInfo{ - {Port: "3001", Title: "Test Server", Service: "node"}, - }, - }, - { - ID: "workspace-3", - Name: "tom-repo", - Path: "/workspace/tom", - Branch: "main", - IsActive: false, - ChangedFiles: []string{}, - Ports: []PortInfo{}, - }, - } -} - // Version check handler func (m Model) handleVersionCheck(msg VersionCheckMsg) (tea.Model, tea.Cmd) { m.upgradeAvailable = msg.UpgradeAvailable @@ -752,11 +706,18 @@ 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, show the selector + // 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 - m.showWorkspaceSelector = true - m.selectedWorkspaceIndex = 0 + // 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 diff --git a/container/internal/tui/view_workspace.go b/container/internal/tui/view_workspace.go index 96f9a3e8..0b55a501 100644 --- a/container/internal/tui/view_workspace.go +++ b/container/internal/tui/view_workspace.go @@ -24,30 +24,29 @@ func (v *WorkspaceViewImpl) GetViewType() ViewType { // 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 both terminals + // Handle shell output and error messages for Claude terminal only (simplified view) switch msg := msg.(type) { case shellOutputMsg: - // Determine which terminal this is for based on session ID - if strings.HasSuffix(msg.sessionID, "-claude") { + 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) - } else if strings.HasSuffix(msg.sessionID, "-regular") { - return v.handleRegularOutput(m, msg) } case shellErrorMsg: - // Handle errors for both terminals - if strings.HasSuffix(msg.sessionID, "-claude") { + 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) - } else if strings.HasSuffix(msg.sessionID, "-regular") { - return v.handleRegularError(m, msg) } } - // Update both viewport models - var cmd1, cmd2 tea.Cmd - m.workspaceClaudeTerminal, cmd1 = m.workspaceClaudeTerminal.Update(msg) - m.workspaceRegularTerminal, cmd2 = m.workspaceRegularTerminal.Update(msg) + // Update only Claude viewport model (simplified view) + var cmd tea.Cmd + m.workspaceClaudeTerminal, cmd = m.workspaceClaudeTerminal.Update(msg) - return m, tea.Batch(cmd1, cmd2) + return m, cmd } // HandleKey processes key messages for the workspace view @@ -58,75 +57,124 @@ func (v *WorkspaceViewImpl) HandleKey(m *Model, msg tea.KeyMsg) (*Model, tea.Cmd m.SwitchToView(OverviewView) return m, nil default: - // Forward all other input to the regular terminal (bottom terminal) - v.forwardToRegularTerminal(m, msg) + // 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 + // Calculate layout dimensions with proper padding headerHeight := 3 - totalHeight := msg.Height - headerHeight - - // Main content area is 75% width, right sidebar is 25% - mainWidth := (msg.Width * 75) / 100 - _ = msg.Width - mainWidth - 2 // sidebarWidth - Account for borders - - // Claude terminal gets 75% of main height, regular terminal gets 25% - claudeHeight := (totalHeight * 75) / 100 - regularHeight := totalHeight - claudeHeight - 1 // Account for separator - - // Update viewport sizes - m.workspaceClaudeTerminal.Width = mainWidth - 2 - m.workspaceClaudeTerminal.Height = claudeHeight - m.workspaceRegularTerminal.Width = mainWidth - 2 - m.workspaceRegularTerminal.Height = regularHeight - - // Resize PTY sessions if they exist - v.resizeWorkspaceTerminals(m, mainWidth-2, claudeHeight, regularHeight) + 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 + // Calculate layout dimensions with proper padding headerHeight := 3 - totalHeight := m.height - headerHeight - - // Main content area is 75% width, right sidebar is 25% - mainWidth := (m.width * 75) / 100 - sidebarWidth := m.width - mainWidth - 2 // Account for borders - - // Claude terminal gets 75% of main height, regular terminal gets 25% - claudeHeight := (totalHeight * 75) / 100 - regularHeight := totalHeight - claudeHeight - 1 // Account for separator - - // Header for the workspace - headerStyle := components.ShellHeaderStyle.Width(m.width - 2) - header := headerStyle.Render(fmt.Sprintf("📁 Workspace: %s (%s) | Press Esc to return to overview", - m.currentWorkspace.Name, m.currentWorkspace.Branch)) - - // Main content area (left side) - mainContent := v.renderMainContent(m, mainWidth, claudeHeight, regularHeight) - - // Right sidebar content - sidebarContent := v.renderSidebar(m, sidebarWidth, totalHeight) - - // Combine main and sidebar horizontally - workspaceContent := lipgloss.JoinHorizontal( - lipgloss.Top, - mainContent, - sidebarContent, - ) - - return fmt.Sprintf("%s\n%s", header, workspaceContent) + 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 @@ -137,136 +185,152 @@ func (v *WorkspaceViewImpl) renderNoWorkspace(m *Model) string { Width(m.width - 2). Height(m.height - 6) - content := "No workspace selected.\n\nPress Ctrl+W to select a workspace." + content := "No workspaces detected.\n\nPress Ctrl+W to select a workspace." return centerStyle.Render(content) } -func (v *WorkspaceViewImpl) renderMainContent(m *Model, width, claudeHeight, regularHeight int) string { - // Claude terminal (top 75%) - claudeStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("62")). - Width(width). - Height(claudeHeight + 2) // Account for border - - claudeHeader := lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("39")). - Padding(0, 1). - Render("🤖 Claude Terminal (?agent=claude)") +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.ID + "-claude" + claudeSessionID := m.currentWorkspace.Name + debugLog("renderClaudeTerminal - looking for session: %s", claudeSessionID) if globalShellManager != nil { - if session := globalShellManager.GetSession(claudeSessionID); session != nil { - m.workspaceClaudeTerminal.SetContent(string(session.Output)) - } else { - m.workspaceClaudeTerminal.SetContent("Connecting to Claude terminal...") + 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) } - } - } - - claudeContent := claudeStyle.Render(claudeHeader + "\n" + m.workspaceClaudeTerminal.View()) - - // Regular terminal (bottom 25%) - regularStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("62")). - Width(width). - Height(regularHeight + 2) // Account for border + if session := globalShellManager.GetSession(claudeSessionID); session != nil { + debugLog("renderClaudeTerminal - found session, output length: %d, connected: %v", len(session.Output), session.Connected) - regularHeader := lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("39")). - Padding(0, 1). - Render("💻 Regular Terminal") + // 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) + } - // Set content for regular terminal viewport - if m.currentWorkspace != nil { - regularSessionID := m.currentWorkspace.ID + "-regular" - if globalShellManager != nil { - if session := globalShellManager.GetSession(regularSessionID); session != nil { - m.workspaceRegularTerminal.SetContent(string(session.Output)) + // 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 { - m.workspaceRegularTerminal.SetContent("Connecting to terminal...") + 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") } - regularContent := regularStyle.Render(regularHeader + "\n" + m.workspaceRegularTerminal.View()) - - return lipgloss.JoinVertical( - lipgloss.Left, - claudeContent, - regularContent, - ) + // 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) renderSidebar(m *Model, width, height int) string { - sidebarStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("62")). - Width(width). - Height(height + 2). // Account for border - Padding(1) +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 { - // Git status section - sections = append(sections, lipgloss.NewStyle().Bold(true).Render("📝 Git Status")) - sections = append(sections, fmt.Sprintf("Branch: %s", m.currentWorkspace.Branch)) + // 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, "") - - // Changed files section - sections = append(sections, lipgloss.NewStyle().Bold(true).Render("📄 Changed Files")) + + // 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 >= 5 { // Limit display to first 5 files - sections = append(sections, fmt.Sprintf("... and %d more", len(m.currentWorkspace.ChangedFiles)-5)) + if i >= 3 { // Limit to first 3 files + sections = append(sections, fmt.Sprintf(" ...%d more", len(m.currentWorkspace.ChangedFiles)-3)) break } - // Extract just filename from path + // Extract just filename filename := file if lastSlash := strings.LastIndex(file, "/"); lastSlash != -1 { filename = file[lastSlash+1:] } - sections = append(sections, fmt.Sprintf("• %s", filename)) + sections = append(sections, " • "+filename) } } else { - sections = append(sections, "No changes") + sections = append(sections, "📝 No changes") } sections = append(sections, "") - - // Ports section - sections = append(sections, lipgloss.NewStyle().Bold(true).Render("🌐 Active Ports")) + + // 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)) + sections = append(sections, fmt.Sprintf(" :%s %s", port.Port, port.Title)) } } else { - sections = append(sections, "No active ports") + sections = append(sections, "🌐 No ports") } } else { - sections = append(sections, "No workspace data available") + sections = append(sections, "No workspace") } + // Join all sections and ensure it fits the width content := strings.Join(sections, "\n") - return sidebarStyle.Render(content) + + // 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) { - // Update the Claude terminal viewport - m.workspaceClaudeTerminal.SetContent(string(msg.data)) - m.workspaceClaudeTerminal.GotoBottom() - return m, nil -} + 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() + } -func (v *WorkspaceViewImpl) handleRegularOutput(m *Model, msg shellOutputMsg) (*Model, tea.Cmd) { - // Update the regular terminal viewport - m.workspaceRegularTerminal.SetContent(string(msg.data)) - m.workspaceRegularTerminal.GotoBottom() return m, nil } @@ -276,23 +340,17 @@ func (v *WorkspaceViewImpl) handleClaudeError(m *Model, msg shellErrorMsg) (*Mod return m, nil } -func (v *WorkspaceViewImpl) handleRegularError(m *Model, msg shellErrorMsg) (*Model, tea.Cmd) { - debugLog("Regular terminal error for workspace %s: %v", m.currentWorkspace.ID, msg.err) - // Could add error display to regular terminal - return m, nil -} - -func (v *WorkspaceViewImpl) forwardToRegularTerminal(m *Model, msg tea.KeyMsg) { +func (v *WorkspaceViewImpl) forwardToClaudeTerminal(m *Model, msg tea.KeyMsg) { if m.currentWorkspace == nil { return } - // Send input to the regular terminal PTY session - regularSessionID := m.currentWorkspace.ID + "-regular" - debugLog("Workspace view forwarding key to regular terminal: %s", msg.String()) - + // 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(regularSessionID); session != nil && session.Client != nil { + if session := globalShellManager.GetSession(claudeSessionID); session != nil && session.Client != nil { var data []byte if len(msg.Runes) > 0 { data = []byte(string(msg.Runes)) @@ -328,7 +386,7 @@ func (v *WorkspaceViewImpl) forwardToRegularTerminal(m *Model, msg tea.KeyMsg) { 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 regular terminal: %v", err) + debugLog("Failed to send data to workspace Claude terminal: %v", err) if globalShellManager != nil && globalShellManager.program != nil { globalShellManager.program.Send(shellErrorMsg{ sessionID: sessionID, @@ -336,115 +394,126 @@ func (v *WorkspaceViewImpl) forwardToRegularTerminal(m *Model, msg tea.KeyMsg) { }) } } - }(data, regularSessionID) + }(data, claudeSessionID) } } } } -func (v *WorkspaceViewImpl) resizeWorkspaceTerminals(m *Model, width, claudeHeight, regularHeight int) { +func (v *WorkspaceViewImpl) resizeClaudeTerminal(m *Model, width, height int) { if m.currentWorkspace == nil { return } if globalShellManager != nil { - // Resize Claude terminal - claudeSessionID := m.currentWorkspace.ID + "-claude" + // 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, claudeHeight); err != nil { + if err := session.Client.Resize(width, height); err != nil { debugLog("Failed to resize Claude terminal: %v", err) } }() } - - // Resize regular terminal - regularSessionID := m.currentWorkspace.ID + "-regular" - if session := globalShellManager.GetSession(regularSessionID); session != nil && session.Client != nil { - go func() { - if err := session.Client.Resize(width, regularHeight); err != nil { - debugLog("Failed to resize regular 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.ID + "-claude" - regularSessionID := workspace.ID + "-regular" + claudeSessionID := workspace.Name - // Calculate terminal dimensions + // Calculate terminal dimensions using same logic as Render method (simplified) headerHeight := 3 - totalHeight := m.height - headerHeight - mainWidth := (m.width * 75) / 100 - claudeHeight := (totalHeight * 75) / 100 - regularHeight := totalHeight - claudeHeight - 1 + padding := 2 + availableHeight := m.height - headerHeight - padding + availableWidth := m.width - padding - terminalWidth := mainWidth - 2 + // Ensure minimum dimensions + if availableHeight < 10 { + availableHeight = 10 + } + if availableWidth < 40 { + availableWidth = 40 + } - // Initialize viewports - m.workspaceClaudeTerminal.Width = terminalWidth - m.workspaceClaudeTerminal.Height = claudeHeight - m.workspaceRegularTerminal.Width = terminalWidth - m.workspaceRegularTerminal.Height = regularHeight + mainWidth := (availableWidth * 70) / 100 + claudeHeight := availableHeight // Full height for simplified view - // Create both terminal sessions - var cmds []tea.Cmd + // Ensure minimum terminal height + if claudeHeight < 10 { + claudeHeight = 10 + } - // Create Claude terminal session with ?agent=claude parameter - claudeCmd := createAndConnectWorkspaceShell(claudeSessionID, terminalWidth, claudeHeight, workspace.Path, "?agent=claude") - cmds = append(cmds, claudeCmd) + terminalWidth := mainWidth - 4 // Account for terminal borders + if terminalWidth < 20 { + terminalWidth = 20 + } - // Create regular terminal session - regularCmd := createAndConnectWorkspaceShell(regularSessionID, terminalWidth, regularHeight, workspace.Path, "") - cmds = append(cmds, regularCmd) + // 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, tea.Batch(cmds...) + 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("Failed to connect workspace shell session %s: %v", sessionID, err) + 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) } - // Change to workspace directory - if workspacePath != "" { - cdCmd := fmt.Sprintf("cd %s\n", workspacePath) - if err := session.Client.Send([]byte(cdCmd)); err != nil { - debugLog("Failed to change directory for workspace %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} } -} \ No newline at end of file +}