Skip to main content
Glama
ai_coder_pty_view.go46.2 kB
package tui import ( "fmt" "regexp" "strings" "time" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/hinshun/vt10x" "github.com/standardbeagle/brummer/internal/aicoder" ) // AICoderPTYView manages the PTY-based AI coder interface type AICoderPTYView struct { width int height int // PTY management ptyManager *aicoder.PTYManager currentSession *aicoder.PTYSession // View state isFullScreen bool terminalFocused bool showHelp bool statusMessage string statusTime time.Time // Scrollback history scrollbackBuffer []string scrollOffset int maxScrollback int useRawHistory bool // Use raw output history with ANSI codes // Key bindings keyBindings []aicoder.KeyBinding // Styling borderStyle lipgloss.Style fullScreenStyle lipgloss.Style helpStyle lipgloss.Style sessionInfoStyle lipgloss.Style } // PTYOutputMsg represents terminal output type PTYOutputMsg struct { SessionID string Data []byte } // PTYEventMsg represents PTY events type PTYEventMsg struct { Event aicoder.PTYEvent } // NewAICoderPTYView creates a new AI coder PTY view func NewAICoderPTYView(ptyManager *aicoder.PTYManager) *AICoderPTYView { view := &AICoderPTYView{ ptyManager: ptyManager, isFullScreen: false, terminalFocused: false, showHelp: false, keyBindings: aicoder.GetDefaultKeyBindings(), scrollbackBuffer: make([]string, 0), scrollOffset: 0, maxScrollback: 10000, // Keep 10k lines of history useRawHistory: true, // Use raw output with ANSI codes } view.setupStyles() return view } // setupStyles initializes the styling for the view func (v *AICoderPTYView) setupStyles() { // Border style that doesn't override terminal colors v.borderStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("62")). Padding(0, 1) v.fullScreenStyle = lipgloss.NewStyle(). Border(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color("196")). Padding(0, 1) v.helpStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("245")). Italic(true) v.sessionInfoStyle = lipgloss.NewStyle(). Background(lipgloss.Color("62")). Foreground(lipgloss.Color("230")). Padding(0, 1). Bold(true) } // Update handles messages for the PTY view func (v *AICoderPTYView) Update(msg tea.Msg) (*AICoderPTYView, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: v.width = msg.Width v.height = msg.Height // Resize current session if exists if v.currentSession != nil { termWidth, termHeight := v.getTerminalSize() v.currentSession.Resize(termWidth, termHeight) } case tea.MouseMsg: // Handle mouse wheel scrolling if v.currentSession != nil { switch msg.Type { case tea.MouseWheelUp: // Scroll up v.scrollOffset += 3 // Scroll 3 lines at a time // Limit scroll to available history if v.currentSession != nil { history := v.currentSession.GetOutputHistory() if len(history) > 0 { historyLines := len(strings.Split(string(history), "\n")) _, termHeight := v.getTerminalSize() maxScroll := historyLines - termHeight if maxScroll < 0 { maxScroll = 0 } if v.scrollOffset > maxScroll { v.scrollOffset = maxScroll } } } return v, nil case tea.MouseWheelDown: // Scroll down v.scrollOffset -= 3 if v.scrollOffset < 0 { v.scrollOffset = 0 } return v, nil } } return v, nil case tea.KeyMsg: return v.handleKeyPress(msg) case PTYOutputMsg: // Terminal output received - add to scrollback buffer if v.currentSession != nil && msg.SessionID == v.currentSession.ID { v.addToScrollback(string(msg.Data)) // Auto-scroll to bottom when new content arrives (unless user is actively scrolling) if v.scrollOffset > 0 && v.scrollOffset < 10 { v.scrollOffset = 0 } } return v, nil case PTYEventMsg: return v.handlePTYEvent(msg.Event) } return v, nil } // handleKeyPress handles keyboard input func (v *AICoderPTYView) handleKeyPress(msg tea.KeyMsg) (*AICoderPTYView, tea.Cmd) { // Handle global key bindings first switch { case key.Matches(msg, key.NewBinding(key.WithKeys("f11"))): cmd := v.toggleFullScreen() return v, cmd case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+h"))): v.showHelp = !v.showHelp return v, nil case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+n"))): // Next session if session, err := v.ptyManager.NextSession(); err == nil { v.currentSession = session } return v, nil case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+shift+p"))): // Previous session (using shift to avoid conflict with data injection) if session, err := v.ptyManager.PreviousSession(); err == nil { v.currentSession = session } return v, nil case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+d"))): // Detach from current session (but keep it running) v.currentSession = nil v.terminalFocused = false return v, nil case key.Matches(msg, key.NewBinding(key.WithKeys("f12"))): // Toggle debug mode for auto event forwarding if v.currentSession != nil { newState := !v.currentSession.IsDebugModeEnabled() v.currentSession.SetDebugMode(newState) // Show status message status := "disabled" if newState { status = "enabled" } v.statusMessage = fmt.Sprintf("[DEBUG MODE %s - Auto-forwarding Brummer events]", strings.ToUpper(status)) v.statusTime = time.Now() } return v, nil } // Handle data injection key bindings for _, binding := range v.keyBindings { if key.Matches(msg, key.NewBinding(key.WithKeys(binding.Key))) { if err := v.ptyManager.InjectDataToCurrent(binding.DataType); err == nil { // Show brief feedback v.statusMessage = fmt.Sprintf("✅ Injected: %s", binding.Description) v.statusTime = time.Now() return v, v.showDataInjectionFeedback(binding.Description) } else if err != nil { // Show error feedback v.statusMessage = fmt.Sprintf("❌ Failed to inject data: %v", err) v.statusTime = time.Now() } return v, nil } } // If terminal is focused, send input to PTY if v.terminalFocused && v.currentSession != nil { // Check for Ctrl+Q to unfocus terminal (ESC is used by AI agents) if key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+q"))) { v.terminalFocused = false return v, nil } // Convert key message to bytes and send to PTY input := v.keyMsgToBytes(msg) if len(input) > 0 { if err := v.currentSession.WriteInput(input); err != nil { // Session is closed or input buffer is full - silently handle v.terminalFocused = false return v, nil } } return v, nil } // Handle view-level commands when terminal is not focused switch { case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))): // Focus on terminal v.terminalFocused = true return v, nil case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))): // Unfocus terminal or exit full screen if v.isFullScreen { cmd := v.toggleFullScreen() return v, cmd } else { v.terminalFocused = false } return v, nil case key.Matches(msg, key.NewBinding(key.WithKeys("pgup"))): // Scroll up if v.currentSession != nil { _, termHeight := v.getTerminalSize() v.scrollOffset += termHeight / 2 // Limit scroll to available history history := v.currentSession.GetOutputHistory() if len(history) > 0 { historyLines := len(strings.Split(string(history), "\n")) maxScroll := historyLines - termHeight if maxScroll < 0 { maxScroll = 0 } if v.scrollOffset > maxScroll { v.scrollOffset = maxScroll } } } return v, nil case key.Matches(msg, key.NewBinding(key.WithKeys("pgdown"))): // Scroll down if v.currentSession != nil { _, termHeight := v.getTerminalSize() v.scrollOffset -= termHeight / 2 if v.scrollOffset < 0 { v.scrollOffset = 0 } } return v, nil } return v, nil } // handlePTYEvent handles PTY-specific events func (v *AICoderPTYView) handlePTYEvent(event aicoder.PTYEvent) (*AICoderPTYView, tea.Cmd) { switch event.Type { case aicoder.PTYEventClose: // Session closed, clear current session if it was this one if v.currentSession != nil && v.currentSession.ID == event.SessionID { v.currentSession = nil v.terminalFocused = false } } return v, nil } // toggleFullScreen toggles full-screen mode func (v *AICoderPTYView) toggleFullScreen() tea.Cmd { v.isFullScreen = !v.isFullScreen if v.currentSession != nil { v.currentSession.SetFullScreen(v.isFullScreen) // Resize terminal to match new dimensions termWidth, termHeight := v.getTerminalSize() v.currentSession.Resize(termWidth, termHeight) } // Return command to enter/exit alternate screen if v.isFullScreen { return tea.EnterAltScreen } return tea.ExitAltScreen } // addToScrollback adds output to the scrollback buffer func (v *AICoderPTYView) addToScrollback(data string) { // Split data into lines and add to buffer lines := strings.Split(data, "\n") for _, line := range lines { if line != "" || len(lines) > 1 { v.scrollbackBuffer = append(v.scrollbackBuffer, line) // Trim buffer if it exceeds max size if len(v.scrollbackBuffer) > v.maxScrollback { v.scrollbackBuffer = v.scrollbackBuffer[len(v.scrollbackBuffer)-v.maxScrollback:] } } } // Auto-scroll to bottom when new content arrives v.scrollOffset = 0 } // getTerminalSize calculates the available terminal size based on view mode and constraints // // LAYOUT CONSTRAINTS DOCUMENTATION: // This function is critical for proper terminal rendering and must account for all UI elements // that consume screen space. The calculations ensure the PTY content fits within borders. // // CONSTRAINT BREAKDOWN: // ┌─────────────────────────────────────────────────────────────────────────┐ // │ WINDOWED MODE LAYOUT │ // ├─────────────────────────────────────────────────────────────────────────┤ // │ Session Info Header (1 line) │ // │ Status Message (0-1 lines, conditional, 3s timeout) │ // │ Blank Line (1 line) │ // │ ╭─────────────────────────────────────────────────────────────────────╮ │ // │ │ Terminal Content Area (calculated height) │ │ // │ │ ← Border (1 char) + Padding (1 char) = 2 chars per side │ │ // │ │ ← Total horizontal deduction: 4 characters │ │ // │ ╰─────────────────────────────────────────────────────────────────────╯ │ // │ Controls/Help Footer (2-10 lines, depends on showHelp) │ // └─────────────────────────────────────────────────────────────────────────┘ // // FULL SCREEN MODE LAYOUT: // ┌─────────────────────────────────────────────────────────────────────────┐ // │ Session Info (Full Screen) (1 line) │ // │ ┌─────────────────────────────────────────────────────────────────────┐ │ // │ │ Raw PTY Content (entire remaining space) │ │ // │ │ ← Border: 2 chars (left + right) │ │ // │ │ ← Padding: 2 chars (left + right) │ │ // │ │ ← Total deduction: 4 characters │ │ // │ └─────────────────────────────────────────────────────────────────────┘ │ // │ Footer Controls (1 line) │ // └─────────────────────────────────────────────────────────────────────────┘ // // CRITICAL CONSISTENCY REQUIREMENT: // This function MUST return dimensions that match the rendering calculations in: // - renderWindowed(): terminalWidth := v.width - 4 // - renderFullScreen(): borderedContent width calculation // - renderTerminalWithBorder(): width parameter usage // // The "-4" deduction is used consistently across all rendering functions. func (v *AICoderPTYView) getTerminalSize() (int, int) { // Ensure we have valid dimensions before any calculations if v.width <= 0 || v.height <= 0 { // Return sensible defaults if dimensions not set yet // These match common terminal defaults return 80, 24 } // WINDOWED MODE HEADER/FOOTER CALCULATIONS: // These must match the actual UI elements rendered in renderWindowed() headerLines := 3 // Base: Session info (1) + blank line (1) + content start (1) if v.statusMessage != "" && time.Since(v.statusTime) < 3*time.Second { headerLines += 2 // Status message (1) + blank line (1) } footerLines := 2 // Base: blank line (1) + controls (1) if v.showHelp { footerLines = 10 // Extended help text takes ~8-10 lines } // CORE CONSTRAINT: BORDER + PADDING DEDUCTION // This is the most critical calculation that must remain consistent: // // Border rendering structure (see renderTerminalWithBorder): // "╭─────────╮" ← Top border // "│ content │" ← Left border (1) + left padding (1) + content + right padding (1) + right border (1) // "╰─────────╯" ← Bottom border // // Total horizontal space consumed by border + padding: // - Left border: 1 character // - Left padding: 1 character // - Right padding: 1 character // - Right border: 1 character // - TOTAL: 4 characters // // This 4-character deduction is used in: // 1. This function (getTerminalSize) // 2. renderWindowed() terminal width calculation // 3. PTY session resize operations // 4. Border rendering width calculations const BORDER_AND_PADDING_WIDTH = 4 if v.isFullScreen { // FULL SCREEN MODE: // Use maximum available space minus border/padding constraints // Height constraint is minimal (header + footer + border) width := v.width - BORDER_AND_PADDING_WIDTH height := v.height - 4 // Header (1) + footer (1) + top/bottom borders (2) // Safety bounds to prevent degenerate terminals if width <= 0 { width = 80 // Standard terminal width fallback } if height <= 0 { height = 24 // Standard terminal height fallback } return width, height } else { // WINDOWED MODE: // Account for all UI elements that consume vertical space width := v.width - BORDER_AND_PADDING_WIDTH - 2 // Additional 2 columns to fit properly in window height := v.height - headerLines - footerLines // Safety bounds to prevent degenerate terminals if width <= 0 { width = 80 // Standard terminal width fallback } if height <= 0 { height = 24 // Standard terminal height fallback } return width, height } } // keyMsgToBytes converts a tea.KeyMsg to bytes for PTY input func (v *AICoderPTYView) keyMsgToBytes(msg tea.KeyMsg) []byte { switch msg.Type { case tea.KeyRunes: return []byte(msg.String()) case tea.KeySpace: return []byte(" ") case tea.KeyEnter: return []byte("\r") case tea.KeyBackspace: return []byte("\b") case tea.KeyTab: return []byte("\t") case tea.KeyEsc: return []byte("\x1b") case tea.KeyUp: return []byte("\x1b[A") case tea.KeyDown: return []byte("\x1b[B") case tea.KeyRight: return []byte("\x1b[C") case tea.KeyLeft: return []byte("\x1b[D") case tea.KeyHome: return []byte("\x1b[H") case tea.KeyEnd: return []byte("\x1b[F") case tea.KeyPgUp: return []byte("\x1b[5~") case tea.KeyPgDown: return []byte("\x1b[6~") case tea.KeyDelete: return []byte("\x1b[3~") case tea.KeyCtrlC: return []byte("\x03") case tea.KeyCtrlD: return []byte("\x04") case tea.KeyCtrlZ: return []byte("\x1a") default: return []byte{} } } // showDataInjectionFeedback shows brief feedback for data injection func (v *AICoderPTYView) showDataInjectionFeedback(description string) tea.Cmd { return tea.Tick(2*time.Second, func(time.Time) tea.Msg { return struct{}{} // Clear feedback after 2 seconds }) } // View renders the AI coder PTY view func (v *AICoderPTYView) View() string { // Ensure we have valid dimensions before rendering if v.width <= 0 || v.height <= 0 { return fmt.Sprintf("Initializing AI Coder view... (dimensions: %dx%d)", v.width, v.height) } // Add debug info about current session if v.currentSession == nil { // Try to get current session from PTY manager if v.ptyManager != nil { if currentSession, exists := v.ptyManager.GetCurrentSession(); exists { v.currentSession = currentSession // Resize to current dimensions termWidth, termHeight := v.getTerminalSize() v.currentSession.Resize(termWidth, termHeight) } } // If still no session, show appropriate message if v.currentSession == nil { // Check if there are any sessions available sessions := []string{} hasPTYManager := v.ptyManager != nil sessionCount := 0 if v.ptyManager != nil { allSessions := v.ptyManager.ListSessions() sessionCount = len(allSessions) for _, s := range allSessions { sessions = append(sessions, fmt.Sprintf("%s (Active: %v, Terminal: %v)", s.ID, s.IsActive, s.Terminal != nil)) } } debugInfo := fmt.Sprintf("PTY Manager: %v, Session Count: %d", hasPTYManager, sessionCount) if len(sessions) > 0 { return fmt.Sprintf("No AI Coder session selected (dimensions: %dx%d)\n%s\nAvailable sessions: %v\n\nUse /ai <provider> to start a session", v.width, v.height, debugInfo, sessions) } return fmt.Sprintf("No AI Coder session active (dimensions: %dx%d)\n%s\n\nUse /ai <provider> to start a session", v.width, v.height, debugInfo) } } if v.isFullScreen { return v.renderFullScreen() } else { return v.renderWindowed() } } // renderFullScreen renders the full-screen terminal view func (v *AICoderPTYView) renderFullScreen() string { var content strings.Builder // Full screen header header := v.sessionInfoStyle.Render(v.getSessionInfo() + " (Full Screen)") content.WriteString(header) content.WriteString("\n") // Terminal content if v.currentSession != nil { terminalContent := v.renderTerminalContent() // FULL SCREEN BORDER CONSTRAINT: // Width: v.width - 4 (BORDER_AND_PADDING_WIDTH from getTerminalSize) // Height: v.height - 4 (header + footer + top/bottom borders) // This MUST match the getTerminalSize() full screen calculation borderedContent := v.renderTerminalWithFullScreenBorder(terminalContent, v.width-4, v.height-4) content.WriteString(borderedContent) } else { noSessionMsg := "No active AI coder session\nPress Ctrl+N to create a new session" fullScreenStyle := v.fullScreenStyle. Width(v.width - 4). Height(v.height - 4). MaxWidth(v.width - 4). MaxHeight(v.height - 4) content.WriteString(fullScreenStyle.Render(noSessionMsg)) } // Full screen footer footer := v.helpStyle.Render("ESC: Exit Full Screen | Ctrl+C: Interrupt | Ctrl+D: Detach") content.WriteString("\n") content.WriteString(footer) return content.String() } // renderWindowed renders the windowed terminal view func (v *AICoderPTYView) renderWindowed() string { var content strings.Builder // Session info header sessionInfo := v.sessionInfoStyle.Render(v.getSessionInfo()) content.WriteString(sessionInfo) content.WriteString("\n") // Status message if present if v.statusMessage != "" && time.Since(v.statusTime) < 3*time.Second { statusStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("220")). Background(lipgloss.Color("236")). Padding(0, 1). Bold(true) content.WriteString(statusStyle.Render(v.statusMessage)) content.WriteString("\n") } content.WriteString("\n") // Terminal content - use more available space if v.currentSession != nil { terminalContent := v.renderTerminalContent() // Calculate height more precisely headerLines := 3 // Session info + blank line if v.statusMessage != "" && time.Since(v.statusTime) < 3*time.Second { headerLines += 2 // Status message + blank } footerLines := 2 // Controls line + padding if v.showHelp { footerLines = 10 // Full help text } terminalHeight := v.height - headerLines - footerLines if terminalHeight < 5 { terminalHeight = 5 } // CONSISTENCY REQUIREMENT: This width calculation MUST match getTerminalSize() // Both functions use the same BORDER_AND_PADDING_WIDTH = 4 constraint // This ensures the rendered content fits exactly within the calculated terminal size terminalWidth := v.width - 4 // Match getTerminalSize() BORDER_AND_PADDING_WIDTH if terminalWidth < 20 { terminalWidth = 20 } // Render terminal content with custom border to preserve ANSI codes borderedContent := v.renderTerminalWithBorder(terminalContent, terminalWidth, terminalHeight) content.WriteString(borderedContent) } else { noSessionMsg := "No active AI coder session\n\nTo start a new session:\n• Use: /ai <provider> (interactive)\n• Use: /ai <provider> <task> (with task)" terminalHeight := v.height - 6 if terminalHeight < 5 { terminalHeight = 5 } terminalWidth := v.width - 6 if terminalWidth < 20 { terminalWidth = 20 } // Also apply MaxWidth/MaxHeight here containerStyle := v.borderStyle. Width(terminalWidth). Height(terminalHeight). MaxWidth(terminalWidth). MaxHeight(terminalHeight) content.WriteString(containerStyle.Render(noSessionMsg)) } content.WriteString("\n") // Help section if v.showHelp { content.WriteString(v.renderHelp()) } else { // Brief controls controls := "F11: Full Screen | F12: Debug Mode | Ctrl+H: Help" if v.terminalFocused { controls = "Terminal Focused | Ctrl+Q: Unfocus | Ctrl+E/L/T/B/P/U/R: Inject Data | " + controls } else { controls = "Terminal Not Focused | Enter: Focus Terminal | / (start of line): Brummer Commands | " + controls } if v.scrollOffset > 0 { controls = fmt.Sprintf("[Scrolled ↑ %d] ", v.scrollOffset) + controls + " | Mouse/PgUp/PgDn: Scroll" } else { controls = controls + " | Mouse/PgUp/PgDn: Scroll" } content.WriteString(v.helpStyle.Render(controls)) } return content.String() } // GetRawOutput returns the complete raw terminal output for full screen mode func (v *AICoderPTYView) GetRawOutput() string { if v.currentSession == nil { return "\033[2J\033[H" + "No active AI coder session\r\nPress Ctrl+N to create a new session" } // In full screen mode, return the raw PTY output directly // This preserves all ANSI codes including cursor positioning, colors, etc. history := v.currentSession.GetOutputHistory() if len(history) == 0 { return "\033[2J\033[HWaiting for output..." } // Return the raw output as-is // The PTY program (like Claude Code) is responsible for screen management return string(history) } // renderTerminalContent renders the actual terminal content with full color support // // This function is the heart of our PTY rendering system. It reads from the vt10x // terminal emulator buffer and reconstructs ANSI escape sequences to preserve // colors and text attributes in the BubbleTea TUI. // // The key insight is that vt10x has already parsed all the ANSI sequences from // the PTY output and maintains a cell-based buffer (like a real terminal). Our // job is to read this buffer and reconstruct the necessary ANSI codes for display. func (v *AICoderPTYView) renderTerminalContent() string { if v.currentSession == nil { return "" } // If we're scrolled up, render from history if v.scrollOffset > 0 { return v.renderScrolledView() } // Get the terminal emulator which has already parsed the PTY output terminal := v.currentSession.GetTerminal() if terminal == nil { return fmt.Sprintf("Waiting for terminal... (session: %s, active: %v, has output: %v)", v.currentSession.ID, v.currentSession.IsActive, len(v.currentSession.GetOutputHistory()) > 0) } // Build the visible content from the terminal buffer var content strings.Builder // Lock terminal while reading to ensure consistency terminal.Lock() defer terminal.Unlock() // Get our available rendering dimensions termWidth, termHeight := v.getTerminalSize() // Get the actual terminal buffer dimensions // The terminal may be larger than our display area width, height := terminal.Size() // Limit to our available display space if height > termHeight { height = termHeight } // Ensure content fits within our calculated display constraints // termWidth already accounts for border and padding, so use it directly if width > termWidth { width = termWidth } // Track the current text style as we scan across cells // This allows us to minimize ANSI code generation by only // emitting codes when the style changes currentFG := vt10x.DefaultFG currentBG := vt10x.DefaultBG var currentMode int16 styleActive := false for y := 0; y < height; y++ { lineBuffer := strings.Builder{} for x := 0; x < width; x++ { // Get the cell at this position // Each cell contains: character, foreground color, background color, and text attributes cell := terminal.Cell(x, y) // Check if this cell's style differs from the current style // This optimization prevents generating redundant ANSI codes if cell.FG != currentFG || cell.BG != currentBG || cell.Mode != currentMode { // Reset previous style if one was active // This ensures a clean slate before applying new attributes if styleActive { lineBuffer.WriteString("\033[0m") styleActive = false } // Check if this cell needs any styling // DefaultFG/DefaultBG are special values meaning "use terminal default" if cell.FG != vt10x.DefaultFG || cell.BG != vt10x.DefaultBG || cell.Mode != 0 { // Build ANSI escape sequence codes codes := []string{} // Text attribute codes (Mode is a bitmask) // These must come before color codes in the ANSI sequence if cell.Mode&(1<<2) != 0 { // Bold (bit 2) codes = append(codes, "1") } if cell.Mode&(1<<1) != 0 { // Underline (bit 1) codes = append(codes, "4") } if cell.Mode&(1<<0) != 0 { // Reverse video (bit 0) codes = append(codes, "7") } if cell.Mode&(1<<5) != 0 { // Blink (bit 5) codes = append(codes, "5") } if cell.Mode&(1<<4) != 0 { // Italic (bit 4) codes = append(codes, "3") } // Foreground color handling // vt10x uses a clever Color encoding scheme: // - 0-7: Standard ANSI colors // - 8-15: Bright ANSI colors // - 16-255: 256-color palette // - 256-16777215: 24-bit true color (RGB packed) // - 16777216+: Special values (DefaultFG, DefaultBG) if cell.FG != vt10x.DefaultFG { if cell.FG < 8 { // Standard ANSI colors (black, red, green, yellow, blue, magenta, cyan, white) // Use codes 30-37 codes = append(codes, fmt.Sprintf("3%d", cell.FG)) } else if cell.FG < 16 { // Bright ANSI colors // Use codes 90-97 (bright black through bright white) codes = append(codes, fmt.Sprintf("9%d", cell.FG-8)) } else if cell.FG < 256 { // 256-color palette // Use ESC[38;5;{n}m format codes = append(codes, fmt.Sprintf("38;5;%d", cell.FG)) } else if cell.FG < vt10x.DefaultFG { // 24-bit true color (RGB) // vt10x packs RGB values into a single uint32: // Color = (R << 16) | (G << 8) | B // We need to extract and use ESC[38;2;{r};{g};{b}m format r := (cell.FG >> 16) & 0xFF g := (cell.FG >> 8) & 0xFF b := cell.FG & 0xFF codes = append(codes, fmt.Sprintf("38;2;%d;%d;%d", r, g, b)) } } // Background color handling (same scheme as foreground) if cell.BG != vt10x.DefaultBG { if cell.BG < 8 { // Standard ANSI background colors // Use codes 40-47 codes = append(codes, fmt.Sprintf("4%d", cell.BG)) } else if cell.BG < 16 { // Bright ANSI background colors // Use codes 100-107 codes = append(codes, fmt.Sprintf("10%d", cell.BG-8)) } else if cell.BG < 256 { // 256-color palette background // Use ESC[48;5;{n}m format codes = append(codes, fmt.Sprintf("48;5;%d", cell.BG)) } else if cell.BG < vt10x.DefaultBG { // 24-bit true color background (RGB) // Use ESC[48;2;{r};{g};{b}m format r := (cell.BG >> 16) & 0xFF g := (cell.BG >> 8) & 0xFF b := cell.BG & 0xFF codes = append(codes, fmt.Sprintf("48;2;%d;%d;%d", r, g, b)) } } // Generate the complete ANSI escape sequence if len(codes) > 0 { // ESC[{code1};{code2};...m format ansiCode := fmt.Sprintf("\033[%sm", strings.Join(codes, ";")) lineBuffer.WriteString(ansiCode) styleActive = true } } // Update our tracking variables currentFG = cell.FG currentBG = cell.BG currentMode = cell.Mode } // Write the character if cell.Char != 0 && cell.Char != '\n' && cell.Char != '\r' { lineBuffer.WriteRune(cell.Char) } else { lineBuffer.WriteRune(' ') } } // Reset style at end of line if needed if styleActive { lineBuffer.WriteString("\033[0m") } content.WriteString(lineBuffer.String()) if y < height-1 { content.WriteString("\n") } } return content.String() } // renderScrolledView renders the terminal with scroll offset applied func (v *AICoderPTYView) renderScrolledView() string { if v.currentSession == nil { return "" } // Get the output history history := v.currentSession.GetOutputHistory() if len(history) == 0 { return "No history available" } // Convert history to string and split into lines historyStr := string(history) allLines := strings.Split(historyStr, "\n") // Get terminal dimensions _, height := v.getTerminalSize() // Calculate which lines to show // We want to show 'height' lines, ending 'scrollOffset' lines from the bottom totalLines := len(allLines) endLine := totalLines - v.scrollOffset if endLine < 0 { endLine = 0 } if endLine > totalLines { endLine = totalLines } startLine := endLine - height if startLine < 0 { startLine = 0 } // Extract the visible lines var visibleLines []string if startLine < endLine && endLine <= totalLines { visibleLines = allLines[startLine:endLine] } // Join and return result := strings.Join(visibleLines, "\n") // Add scroll indicator if v.scrollOffset > 0 { scrollInfo := fmt.Sprintf(" [Scrolled up %d lines] ", v.scrollOffset) // Prepend to first line if there is one lines := strings.Split(result, "\n") if len(lines) > 0 { lines[0] = scrollInfo + lines[0] result = strings.Join(lines, "\n") } else { result = scrollInfo } } return result } // renderFromHistory renders content from the raw output history func (v *AICoderPTYView) renderFromHistory() string { // For now, disable history scrolling as it's causing rendering issues // Just render the current terminal state terminal := v.currentSession.GetTerminal() if terminal == nil { return "" } var content strings.Builder // Lock terminal state while reading terminal.Lock() defer terminal.Unlock() // Get terminal dimensions width, height := terminal.Size() cursor := terminal.Cursor() cursorVisible := terminal.CursorVisible() // Render each line with color support for y := 0; y < height; y++ { lineBuffer := strings.Builder{} for x := 0; x < width; x++ { cell := terminal.Cell(x, y) // Skip style handling - we use raw output now // Handle cursor if v.terminalFocused && cursorVisible && x == cursor.X && y == cursor.Y { lineBuffer.WriteRune('█') // Block cursor } else if cell.Char != 0 { // Render the character lineBuffer.WriteRune(cell.Char) } else { lineBuffer.WriteRune(' ') } } // Reset at end of line lineBuffer.WriteString("\033[0m") content.WriteString(lineBuffer.String()) // Add newline except for last line if y < height-1 { content.WriteString("\n") } } return content.String() } // renderTerminalWithBorder renders terminal content with a border while preserving ANSI codes // // TERMINAL SIZE ↔ BORDER RENDERING RELATIONSHIP: // This function is the bridge between getTerminalSize() calculations and actual visual output. // The relationship is critical for preventing content overflow and ensuring proper layout. // // CONSTRAINT FLOW: // 1. getTerminalSize() calculates: terminalWidth = viewWidth - 4 // 2. PTY session is resized to these dimensions // 3. renderTerminalContent() generates content fitting those dimensions // 4. This function renders borders around content using the SAME width parameter // 5. Final output fits exactly within the original view boundaries // // PARAMETER RELATIONSHIP: // - width parameter: Should equal getTerminalSize() width return value // - height parameter: Should equal getTerminalSize() height return value // - Content: Must fit within (width-4) x (height-2) after accounting for borders // // BORDER STRUCTURE AND SPACE CONSUMPTION: // ┌────────────────────────────────────────────────────┐ // │ ╭──────────────────────────────────────────────╮ │ ← width param // │ │ content area (width-4 chars wide) │ │ // │ │ ^ left border (1) + left pad (1) │ │ // │ │ right pad (1) + right border (1) ^ │ │ // │ ╰──────────────────────────────────────────────╯ │ // └────────────────────────────────────────────────────┘ // // CRITICAL INSIGHT: Using lipgloss.Style.Render() on content that contains ANSI codes // will strip those codes! This is why we must use raw ANSI codes for the border itself // and carefully preserve the content's ANSI codes. // // This function: // 1. Draws a rounded border using Unicode box-drawing characters // 2. Applies color to the border using raw ANSI codes (not lipgloss) // 3. Preserves all ANSI codes in the content // 4. Handles line wrapping and padding correctly // 5. Ensures content fits exactly within calculated dimensions func (v *AICoderPTYView) renderTerminalWithBorder(content string, width, height int) string { // Use rounded border characters for a modern look topLeft := "╭" topRight := "╮" bottomLeft := "╰" bottomRight := "╯" horizontal := "─" vertical := "│" // IMPORTANT: We use raw ANSI codes for border coloring // Using lipgloss.Style.Render() would strip ANSI codes from our content! borderColorCode := "\033[38;5;62m" // Color 62 (a nice blue-gray) resetCode := "\033[0m" var result strings.Builder // Top border result.WriteString(borderColorCode) result.WriteString(topLeft) for i := 0; i < width-2; i++ { result.WriteString(horizontal) } result.WriteString(topRight) result.WriteString(resetCode) result.WriteString("\n") // Content lines with side borders and padding lines := strings.Split(content, "\n") for i := 0; i < height-2; i++ { // Left border and padding result.WriteString(borderColorCode) result.WriteString(vertical) result.WriteString(resetCode) result.WriteString(" ") // left padding // Content line (preserving ANSI codes) if i < len(lines) { // The line already has ANSI codes, just write it result.WriteString(lines[i]) // Calculate visible length (excluding ANSI codes) for right padding visibleLen := ansiLength(lines[i]) if visibleLen < width-4 { // -4 for borders and padding // Add right padding for j := visibleLen; j < width-4; j++ { result.WriteString(" ") } } } else { // Empty line - fill with spaces for j := 0; j < width-4; j++ { result.WriteString(" ") } } // Right padding and border result.WriteString(" ") // right padding result.WriteString(borderColorCode) result.WriteString(vertical) result.WriteString(resetCode) result.WriteString("\n") } // Bottom border result.WriteString(borderColorCode) result.WriteString(bottomLeft) for i := 0; i < width-2; i++ { result.WriteString(horizontal) } result.WriteString(bottomRight) result.WriteString(resetCode) return result.String() } // ansiLength calculates the visible length of a string, excluding ANSI escape sequences // // BORDER RENDERING DEPENDENCY: // This function is essential for the getTerminalSize() ↔ border rendering relationship. // Without accurate visible length calculation, borders would be misaligned and content // would overflow or have incorrect padding. // // WHY THIS MATTERS FOR CONSTRAINTS: // 1. getTerminalSize() calculates: content should fit in (width-4) characters // 2. PTY generates content with ANSI codes: "\033[31mHello\033[0m" (5 visible chars, 13 total bytes) // 3. Border rendering needs visible length (5) to calculate correct padding // 4. Without this, padding calculation would use total length (13) and overflow borders // // ANSI SEQUENCE EXAMPLES: // - "\033[31mRed text\033[0m" → visible length: 8 ("Red text") // - "\033[1;32mBold green\033[0m" → visible length: 10 ("Bold green") // - "Hello \033[4munderlined\033[0m world" → visible length: 21 ("Hello underlined world") // // REGEX PATTERN BREAKDOWN: // - \x1b (ESC character, same as \033) // - \[ (literal bracket) // - [0-9;]* (any sequence of digits and semicolons) // - m (the terminating 'm') // // This covers all SGR (Select Graphic Rendition) sequences used for colors and text attributes. // This is the most common type of ANSI sequence in terminal content. func ansiLength(s string) int { // Remove all ANSI SGR sequences ansiRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`) cleaned := ansiRegex.ReplaceAllString(s, "") // Use rune length instead of byte length to properly handle Unicode // This ensures emojis and other multi-byte characters count as single visible characters return len([]rune(cleaned)) } // renderTerminalWithFullScreenBorder renders terminal content with a full screen border func (v *AICoderPTYView) renderTerminalWithFullScreenBorder(content string, width, height int) string { // Use normal border characters for full screen topLeft := "┌" topRight := "┐" bottomLeft := "└" bottomRight := "┘" horizontal := "─" vertical := "│" // Border characters with color (red for full screen) borderColorCode := "\033[38;5;196m" // Color 196 (red) resetCode := "\033[0m" var result strings.Builder // Top border result.WriteString(borderColorCode) result.WriteString(topLeft) for i := 0; i < width-2; i++ { result.WriteString(horizontal) } result.WriteString(topRight) result.WriteString(resetCode) result.WriteString("\n") // Content lines with side borders and padding lines := strings.Split(content, "\n") for i := 0; i < height-2; i++ { // Left border and padding result.WriteString(borderColorCode) result.WriteString(vertical) result.WriteString(resetCode) result.WriteString(" ") // left padding // Content line (preserving ANSI codes) if i < len(lines) { // The line already has ANSI codes, just write it result.WriteString(lines[i]) // Calculate visible length (excluding ANSI codes) for right padding visibleLen := ansiLength(lines[i]) if visibleLen < width-4 { // -4 for borders and padding // Add right padding for j := visibleLen; j < width-4; j++ { result.WriteString(" ") } } } else { // Empty line - fill with spaces for j := 0; j < width-4; j++ { result.WriteString(" ") } } // Right padding and border result.WriteString(" ") // right padding result.WriteString(borderColorCode) result.WriteString(vertical) result.WriteString(resetCode) result.WriteString("\n") } // Bottom border result.WriteString(borderColorCode) result.WriteString(bottomLeft) for i := 0; i < width-2; i++ { result.WriteString(horizontal) } result.WriteString(bottomRight) result.WriteString(resetCode) return result.String() } // renderHelp renders the help section func (v *AICoderPTYView) renderHelp() string { var help strings.Builder help.WriteString(v.helpStyle.Render("🔥 AI Coder PTY Controls:\n")) help.WriteString(v.helpStyle.Render("Navigation: F11 (Full Screen) | F12 (Debug Mode) | Enter (Focus) | Ctrl+Q (Unfocus) | ESC (Exit Full Screen)\n")) help.WriteString(v.helpStyle.Render("Sessions: Ctrl+N (Next) | Ctrl+Shift+P (Previous) | Ctrl+D (Detach)\n")) help.WriteString(v.helpStyle.Render("\n🔹 Data Injection Keys:\n")) for _, binding := range v.keyBindings { help.WriteString(v.helpStyle.Render(fmt.Sprintf(" %s: %s\n", strings.ToUpper(binding.Key), binding.Description))) } help.WriteString(v.helpStyle.Render("\n🚨 Debug Mode (F12):\n")) help.WriteString(v.helpStyle.Render("When enabled, automatically forwards errors, test failures, and build failures to AI\n")) help.WriteString(v.helpStyle.Render("\n💡 Slash Commands:\n")) help.WriteString(v.helpStyle.Render("/ at start of line: Opens Brummer command palette\n")) help.WriteString(v.helpStyle.Render("/ mid-line: Sent to AI coder as regular input\n")) help.WriteString(v.helpStyle.Render("\nCtrl+H: Toggle this help")) return help.String() } // getSessionInfo returns information about the current session func (v *AICoderPTYView) getSessionInfo() string { sessions := v.ptyManager.ListSessions() sessionCount := len(sessions) if v.currentSession == nil { if sessionCount == 0 { return "No AI Coder Sessions" } else { return fmt.Sprintf("AI Coder Sessions (%d) - None Selected", sessionCount) } } // Find current session index currentIndex := -1 for i, session := range sessions { if session.ID == v.currentSession.ID { currentIndex = i + 1 // 1-based indexing for display break } } status := "Running" if !v.currentSession.IsActive { status = "Stopped" } debugMode := "" if v.currentSession.IsDebugModeEnabled() { debugMode = " [DEBUG]" } return fmt.Sprintf("Session %d/%d: %s (%s)%s", currentIndex, sessionCount, v.currentSession.Name, status, debugMode) } // SetCurrentSession sets the current session for the view func (v *AICoderPTYView) SetCurrentSession(session *aicoder.PTYSession) { v.currentSession = session if session != nil { // Resize to current dimensions termWidth, termHeight := v.getTerminalSize() session.Resize(termWidth, termHeight) // Set status message to confirm session is set v.statusMessage = fmt.Sprintf("✅ AI Coder session started: %s", session.Name) v.statusTime = time.Now() // Auto-focus the terminal when a new session is set v.terminalFocused = true } } // IsTerminalFocused returns whether the terminal is currently focused for input func (v *AICoderPTYView) IsTerminalFocused() bool { return v.terminalFocused } // UnfocusTerminal removes focus from the terminal func (v *AICoderPTYView) UnfocusTerminal() { v.terminalFocused = false } // ShouldInterceptSlashCommand determines if "/" should open Brummer command palette // Returns true if: // - Terminal is not focused, OR // - Cursor is at start of line (typically indicates new command) func (v *AICoderPTYView) ShouldInterceptSlashCommand() bool { // If terminal not focused, always allow Brummer commands if !v.terminalFocused { return true } // If no current session, allow Brummer commands if v.currentSession == nil { return true } // When terminal is focused, slash commands should go to the AI agent // This allows AI agents to handle "/" as regular input return false } // GetCurrentLineContent returns the current line content for context func (v *AICoderPTYView) GetCurrentLineContent() string { if v.currentSession == nil { return "" } return v.currentSession.GetCurrentLineContent() } // AttachToSession attaches the view to a specific session func (v *AICoderPTYView) AttachToSession(sessionID string) error { if err := v.ptyManager.SetCurrentSession(sessionID); err != nil { return err } if session, exists := v.ptyManager.GetSession(sessionID); exists { v.SetCurrentSession(session) v.terminalFocused = true return nil } return fmt.Errorf("session not found") } // CreateSession creates a new PTY session func (v *AICoderPTYView) CreateSession(name, command string, args []string) (*aicoder.PTYSession, error) { session, err := v.ptyManager.CreateSession(name, command, args) if err != nil { return nil, err } // Auto-attach to the new session v.SetCurrentSession(session) v.terminalFocused = true return session, nil }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/standardbeagle/brummer'

If you have feedback or need assistance with the MCP directory API, please join our Discord server