package ui import ( "context" "fmt" "os/exec" "regexp" "runtime" "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/chenasraf/watchr/internal/runner" ) // PreviewPosition defines where the preview panel is displayed type PreviewPosition string const ( PreviewBottom PreviewPosition = "bottom" PreviewTop PreviewPosition = "top" PreviewLeft PreviewPosition = "left" PreviewRight PreviewPosition = "right" ) // Config holds the UI configuration type Config struct { Command string Shell string PreviewSize int PreviewSizeIsPercent bool PreviewPosition PreviewPosition ShowLineNums bool LineNumWidth int Prompt string RefreshInterval time.Duration RefreshFromStart bool // If true, refresh timer starts when command starts; if false, when command ends (default) Interactive bool } // model represents the application state type model struct { config Config lines []runner.Line filtered []int // indices into lines that match filter cursor int // cursor position in filtered list offset int // scroll offset for visible window filter string filterCursor int // cursor position within filter string filterMode bool filterRegex bool // true when filter is in regex mode filterRegexErr error // non-nil when regex pattern is invalid showPreview bool showHelp bool // help overlay visible width int height int runner *runner.Runner ctx context.Context cancel context.CancelFunc loading bool streaming bool // true while command is running (streaming output) streamResult *runner.StreamingResult // current streaming result lastLineCount int // track line count for updates userScrolled bool // true if user manually scrolled during streaming refreshGeneration int // incremented on manual refresh to reset timer refreshStartTime time.Time // when the refresh timer was started spinnerFrame int // current spinner animation frame errorMsg string statusMsg string // temporary status message (e.g., "Yanked!") exitCode int // last command exit code } // messages type resultMsg struct { lines []runner.Line exitCode int } type errMsg struct{ err error } type tickMsg struct { generation int } type clearStatusMsg struct{} type spinnerTickMsg time.Time type streamTickMsg time.Time // periodic check for streaming updates type startStreamMsg struct{} // trigger to start streaming type countdownTickMsg struct { // periodic update for refresh countdown display generation int } // Spinner frames for the loading animation var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} func (e errMsg) Error() string { return e.err.Error() } // copyToClipboard copies text to the system clipboard using OS-specific commands func copyToClipboard(text string) error { var cmd *exec.Cmd switch runtime.GOOS { case "darwin": cmd = exec.Command("pbcopy") case "linux": // Try xclip first, fall back to xsel if _, err := exec.LookPath("xclip"); err == nil { cmd = exec.Command("xclip", "-selection", "clipboard") } else { cmd = exec.Command("xsel", "--clipboard", "--input") } case "windows": cmd = exec.Command("clip") default: return fmt.Errorf("unsupported platform: %s", runtime.GOOS) } cmd.Stdin = strings.NewReader(text) return cmd.Run() } func initialModel(cfg Config) model { ctx, cancel := context.WithCancel(context.Background()) var r *runner.Runner if cfg.Interactive { r = runner.NewInteractiveRunner(cfg.Shell, cfg.Command) } else { r = runner.NewRunner(cfg.Shell, cfg.Command) } return model{ config: cfg, lines: []runner.Line{}, filtered: []int{}, cursor: 0, offset: 0, filter: "", filterMode: false, showPreview: false, runner: r, ctx: ctx, cancel: cancel, loading: true, } } func (m *model) Init() tea.Cmd { // Send a message to start streaming (handled in Update with pointer receiver) return func() tea.Msg { return startStreamMsg{} } } func (m model) spinnerTickCmd() tea.Cmd { return tea.Tick(80*time.Millisecond, func(t time.Time) tea.Msg { return spinnerTickMsg(t) }) } func (m model) streamTickCmd() tea.Cmd { return tea.Tick(50*time.Millisecond, func(t time.Time) tea.Msg { return streamTickMsg(t) }) } func (m model) countdownTickCmd() tea.Cmd { gen := m.refreshGeneration return tea.Tick(time.Second, func(t time.Time) tea.Msg { return countdownTickMsg{generation: gen} }) } func (m *model) startStreaming() tea.Cmd { // Cancel any existing context and create a new one if m.cancel != nil { m.cancel() } m.ctx, m.cancel = context.WithCancel(context.Background()) // Pass previous lines for in-place updates m.streamResult = m.runner.RunStreaming(m.ctx, m.lines) m.streaming = true m.loading = true m.lastLineCount = len(m.lines) m.exitCode = -1 m.errorMsg = "" m.userScrolled = false cmds := []tea.Cmd{m.streamTickCmd()} // Start refresh timer from command start if configured if m.config.RefreshFromStart && m.config.RefreshInterval > 0 { m.refreshStartTime = time.Now() cmds = append(cmds, m.tickCmd()) if m.config.RefreshInterval > time.Second { cmds = append(cmds, m.countdownTickCmd()) } } return tea.Batch(cmds...) } func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: return m.handleKeyPress(msg) case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height return m, nil case startStreamMsg: cmd := m.startStreaming() return m, tea.Batch(cmd, m.spinnerTickCmd()) case resultMsg: m.lines = msg.lines m.exitCode = msg.exitCode m.loading = false m.streaming = false m.updateFiltered() return m, nil case streamTickMsg: if m.streamResult == nil { return m, nil } // Check for new lines newLines := m.streamResult.GetLines() newCount := len(newLines) if newCount != m.lastLineCount { m.lines = newLines m.lastLineCount = newCount m.updateFiltered() // Auto-scroll to bottom if user hasn't manually scrolled if !m.userScrolled { visible := m.visibleLines() if visible > 0 { m.cursor = max(len(m.filtered)-1, 0) m.offset = max(len(m.filtered)-visible, 0) } } } // Check if command completed if m.streamResult.IsDone() { m.streaming = false m.loading = false m.exitCode = m.streamResult.ExitCode if m.streamResult.Error != nil { m.errorMsg = m.streamResult.Error.Error() } // Trim excess lines from previous run currentCount := m.streamResult.GetCurrentLineCount() if currentCount < len(m.lines) { m.lines = m.lines[:currentCount] m.updateFiltered() } // If auto-refresh is enabled and timer starts from end, schedule the next run if m.config.RefreshInterval > 0 && !m.config.RefreshFromStart { m.refreshStartTime = time.Now() cmds := []tea.Cmd{m.tickCmd()} // Start countdown display updates if interval > 1s if m.config.RefreshInterval > time.Second { cmds = append(cmds, m.countdownTickCmd()) } return m, tea.Batch(cmds...) } return m, nil } // Continue streaming return m, m.streamTickCmd() case tickMsg: // Ignore ticks from before a manual refresh if msg.generation != m.refreshGeneration { return m, nil } if m.config.RefreshInterval > 0 && !m.streaming { // Restart streaming for refresh cmd := m.startStreaming() return m, tea.Batch(cmd, m.spinnerTickCmd()) } return m, nil case errMsg: m.errorMsg = msg.Error() m.loading = false m.streaming = false return m, nil case clearStatusMsg: m.statusMsg = "" return m, nil case spinnerTickMsg: if m.loading || m.streaming { m.spinnerFrame = (m.spinnerFrame + 1) % len(spinnerFrames) return m, m.spinnerTickCmd() } return m, nil case countdownTickMsg: // Ignore ticks from before a manual refresh if msg.generation != m.refreshGeneration { return m, nil } // Continue ticking if waiting for auto-refresh if m.config.RefreshInterval > time.Second && !m.streaming && !m.refreshStartTime.IsZero() { elapsed := time.Since(m.refreshStartTime) if elapsed < m.config.RefreshInterval { return m, m.countdownTickCmd() } } return m, nil } return m, nil } func (m model) tickCmd() tea.Cmd { gen := m.refreshGeneration return tea.Tick(m.config.RefreshInterval, func(t time.Time) tea.Msg { return tickMsg{generation: gen} }) } func (m *model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // In help mode, any key closes it if m.showHelp { switch msg.String() { case "?", "esc", "q", "enter": m.showHelp = false } return m, nil } // In filter mode, handle text input if m.filterMode { switch msg.Type { case tea.KeyEsc: m.filterMode = false m.filter = "" m.filterCursor = 0 m.filterRegex = false m.filterRegexErr = nil m.updateFiltered() return m, nil case tea.KeyEnter: m.filterMode = false return m, nil case tea.KeyLeft: if msg.Alt { // Alt+Left: move to previous word boundary pos := m.filterCursor for pos > 0 && m.filter[pos-1] == ' ' { pos-- } for pos > 0 && m.filter[pos-1] != ' ' { pos-- } m.filterCursor = pos } else if m.filterCursor > 0 { m.filterCursor-- } return m, nil case tea.KeyRight: if msg.Alt { // Alt+Right: move to next word boundary pos := m.filterCursor for pos < len(m.filter) && m.filter[pos] != ' ' { pos++ } for pos < len(m.filter) && m.filter[pos] == ' ' { pos++ } m.filterCursor = pos } else if m.filterCursor < len(m.filter) { m.filterCursor++ } return m, nil case tea.KeyBackspace: if msg.Alt { // Alt+Backspace: delete word behind cursor if m.filterCursor > 0 { pos := m.filterCursor // Skip trailing spaces for pos > 0 && m.filter[pos-1] == ' ' { pos-- } // Skip word characters for pos > 0 && m.filter[pos-1] != ' ' { pos-- } m.filter = m.filter[:pos] + m.filter[m.filterCursor:] m.filterCursor = pos m.updateFiltered() } } else if m.filterCursor > 0 { m.filter = m.filter[:m.filterCursor-1] + m.filter[m.filterCursor:] m.filterCursor-- m.updateFiltered() } return m, nil default: if len(msg.Runes) > 0 { s := string(msg.Runes) if s == "/" && m.filter == "" { m.filterRegex = !m.filterRegex m.filterRegexErr = nil m.updateFiltered() } else { m.filter = m.filter[:m.filterCursor] + s + m.filter[m.filterCursor:] m.filterCursor += len(s) m.updateFiltered() } } return m, nil } } // Normal mode keybindings switch msg.String() { case "q", "ctrl+c": m.cancel() return m, tea.Quit case "esc": // Clear filter if active, otherwise quit if m.filter != "" || m.filterRegex { m.filter = "" m.filterCursor = 0 m.filterRegex = false m.filterRegexErr = nil m.updateFiltered() return m, nil } m.cancel() return m, tea.Quit case "j", "down", "ctrl+n": m.userScrolled = true m.moveCursor(1) case "k", "up", "ctrl+p": m.userScrolled = true m.moveCursor(-1) case "g", "home": m.userScrolled = true m.cursor = 0 m.offset = 0 case "G", "end": m.userScrolled = false // Resume following output if len(m.filtered) > 0 { m.cursor = len(m.filtered) - 1 m.adjustOffset() } case "ctrl+d": m.userScrolled = true m.moveCursor(m.visibleLines() / 2) case "ctrl+u": m.userScrolled = true m.moveCursor(-m.visibleLines() / 2) case "pgdown", "ctrl+f": m.userScrolled = true m.moveCursor(m.visibleLines()) case "pgup", "ctrl+b": m.userScrolled = true m.moveCursor(-m.visibleLines()) case "p": m.showPreview = !m.showPreview m.adjustOffset() // Keep selected line visible after preview toggle case "r", "ctrl+r": // Restart streaming and reset auto-refresh timer m.refreshGeneration++ cmd := m.startStreaming() return m, tea.Batch(cmd, m.spinnerTickCmd()) case "c": // Stop the running command if one is running if m.streaming { m.cancel() m.statusMsg = "Command stopped" return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg { return clearStatusMsg{} }) } case "/": m.filterMode = true m.filterCursor = len(m.filter) case "?": m.showHelp = true case "y": // Yank (copy) selected line to clipboard if len(m.filtered) > 0 && m.cursor >= 0 && m.cursor < len(m.filtered) { idx := m.filtered[m.cursor] if idx < len(m.lines) { content := m.lines[idx].Content if err := copyToClipboard(content); err != nil { m.statusMsg = "Failed to copy" } else { m.statusMsg = "Copied to clipboard" } return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg { return clearStatusMsg{} }) } } } return m, nil } func (m *model) moveCursor(delta int) { m.cursor += delta if m.cursor < 0 { m.cursor = 0 } if m.cursor >= len(m.filtered) { m.cursor = len(m.filtered) - 1 } if m.cursor < 0 { m.cursor = 0 } m.adjustOffset() } func (m *model) adjustOffset() { visible := m.visibleLines() if visible <= 0 { return } // Try to center the cursor idealOffset := m.cursor - visible/2 // Clamp to valid range idealOffset = max(idealOffset, 0) maxOffset := max(len(m.filtered)-visible, 0) idealOffset = min(idealOffset, maxOffset) m.offset = idealOffset } func (m model) previewSize() int { if m.config.PreviewSizeIsPercent { if m.config.PreviewPosition == PreviewLeft || m.config.PreviewPosition == PreviewRight { return m.width * m.config.PreviewSize / 100 } return m.height * m.config.PreviewSize / 100 } return m.config.PreviewSize } func (m model) visibleLines() int { // Fixed lines: top border (1) + header (1) + separator (1) + bottom border (1) + prompt (1) = 5 fixedLines := 5 if m.showPreview && (m.config.PreviewPosition == PreviewTop || m.config.PreviewPosition == PreviewBottom) { // Add preview height + separator between content and preview return m.height - fixedLines - m.previewSize() - 1 } return m.height - fixedLines } const ellipsis = "…" // truncateToWidth truncates a string to fit within the given visual width, // adding an ellipsis if truncation occurs. Uses visual width, not byte count. func truncateToWidth(s string, maxWidth int) string { if maxWidth <= 0 { return "" } sw := lipgloss.Width(s) if sw <= maxWidth { return s } // Need to truncate - leave room for ellipsis (1 char wide) targetWidth := maxWidth - 1 if targetWidth <= 0 { return ellipsis } // Truncate rune by rune until we fit var result strings.Builder currentWidth := 0 for _, r := range s { runeWidth := lipgloss.Width(string(r)) if currentWidth+runeWidth > targetWidth { break } result.WriteRune(r) currentWidth += runeWidth } return result.String() + ellipsis } // wrapText wraps text to fit within the given width, returning multiple lines. // It is ANSI-aware: escape sequences are preserved intact and don't count // toward the visible width. When a line wraps, any active ANSI state is // carried over so colours continue on the next line. func wrapText(s string, width int) []string { if width <= 0 { return nil } if s == "" { return []string{""} } var lines []string var currentLine strings.Builder currentWidth := 0 // Track the last seen ANSI escape so we can re-apply it after a wrap var activeANSI string i := 0 runes := []rune(s) for i < len(runes) { // Check for ANSI escape sequence: ESC [ ... final_byte if runes[i] == '\033' && i+1 < len(runes) && runes[i+1] == '[' { // Consume entire escape sequence var seq strings.Builder seq.WriteRune(runes[i]) // ESC i++ seq.WriteRune(runes[i]) // [ i++ for i < len(runes) { seq.WriteRune(runes[i]) // Final byte of CSI sequence is in range 0x40-0x7E if runes[i] >= 0x40 && runes[i] <= 0x7E { i++ break } i++ } seqStr := seq.String() currentLine.WriteString(seqStr) // Track reset vs color sequences if seqStr == "\033[0m" || seqStr == "\033[m" { activeANSI = "" } else { activeANSI = seqStr } continue } r := runes[i] runeWidth := lipgloss.Width(string(r)) if currentWidth+runeWidth > width { // Close any active ANSI on this line before wrapping if activeANSI != "" { currentLine.WriteString("\033[0m") } lines = append(lines, currentLine.String()) currentLine.Reset() currentWidth = 0 // Re-apply active ANSI on the new line if activeANSI != "" { currentLine.WriteString(activeANSI) } } currentLine.WriteRune(r) currentWidth += runeWidth i++ } // Don't forget the last line if currentLine.Len() > 0 { lines = append(lines, currentLine.String()) } return lines } // wrapPreviewContent splits multi-line content (e.g. pretty-printed JSON) by // newlines first, then wraps each line to fit within the given width. func wrapPreviewContent(s string, width int) []string { var result []string for line := range strings.SplitSeq(s, "\n") { if line == "" { result = append(result, "") continue } wrapped := wrapText(line, width) result = append(result, wrapped...) } return result } func (m *model) updateFiltered() { m.filtered = []int{} m.filterRegexErr = nil if m.filterRegex && m.filter != "" { re, err := regexp.Compile("(?i)" + m.filter) if err != nil { m.filterRegexErr = err // Show all lines when regex is invalid for i := range m.lines { m.filtered = append(m.filtered, i) } } else { for i, line := range m.lines { if re.MatchString(line.Content) { m.filtered = append(m.filtered, i) } } } } else { filter := strings.ToLower(m.filter) for i, line := range m.lines { if m.filter == "" || strings.Contains(strings.ToLower(line.Content), filter) { m.filtered = append(m.filtered, i) } } } // Reset cursor if out of bounds if m.cursor >= len(m.filtered) { m.cursor = len(m.filtered) - 1 } if m.cursor < 0 { m.cursor = 0 } // Clamp offset to valid bounds instead of resetting to 0 // This preserves scroll position during streaming updates visible := m.visibleLines() if visible > 0 { maxOffset := max(len(m.filtered)-visible, 0) if m.offset > maxOffset { m.offset = maxOffset } } } // renderHelpOverlay creates the help box content (without positioning) func (m model) renderHelpOverlay() (box string, boxWidth, boxHeight int) { keyStyle := lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("10")) // green descStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("252")) // light gray titleStyle := lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("12")) // blue // Define keybindings bindings := []struct { key string desc string }{ {"j / k", "Move down / up"}, {"g / G", "Go to first / last line"}, {"Ctrl+d / Ctrl+u", "Half page down / up"}, {"PgDn / PgUp", "Full page down / up"}, {"Ctrl+f / Ctrl+b", "Full page down / up"}, {"", ""}, {"p", "Toggle preview pane"}, {"/", "Enter filter mode"}, {"//", "Toggle regex filter mode"}, {"Esc", "Exit filter / clear"}, {"", ""}, {"r / Ctrl+r", "Reload command"}, {"c", "Stop running command"}, {"y", "Copy line to clipboard"}, {"q / Esc", "Quit"}, {"?", "Toggle this help"}, } // Build content var content strings.Builder content.WriteString(titleStyle.Render("Keybindings")) content.WriteString("\n\n") for _, b := range bindings { if b.key == "" { content.WriteString("\n") continue } key := keyStyle.Render(fmt.Sprintf("%-18s", b.key)) desc := descStyle.Render(b.desc) content.WriteString(fmt.Sprintf(" %s %s\n", key, desc)) } content.WriteString("\n") content.WriteString(descStyle.Render("Press any key to close")) // Create box style boxStyle := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("12")). Padding(1, 2) box = boxStyle.Render(content.String()) boxWidth = lipgloss.Width(box) boxHeight = lipgloss.Height(box) return box, boxWidth, boxHeight } // splitAtVisualWidth splits a string at a visual width position, handling ANSI codes // Returns (left part, right part) where left has exactly targetWidth visual width func splitAtVisualWidth(s string, targetWidth int) (string, string) { var left, right strings.Builder visualWidth := 0 inEscape := false runes := []rune(s) i := 0 // Build left part up to targetWidth for i < len(runes) && visualWidth < targetWidth { r := runes[i] if r == '\x1b' { // Start of ANSI escape sequence - include it in left part left.WriteRune(r) i++ for i < len(runes) && !isAnsiTerminator(runes[i]) { left.WriteRune(runes[i]) i++ } if i < len(runes) { left.WriteRune(runes[i]) // terminator i++ } continue } runeWidth := lipgloss.Width(string(r)) if visualWidth+runeWidth <= targetWidth { left.WriteRune(r) visualWidth += runeWidth i++ } else { break } } // Pad left if needed for visualWidth < targetWidth { left.WriteRune(' ') visualWidth++ } // Skip runes in the "overlay zone" - we don't need them for right part calculation // The caller will handle inserting the overlay content // Build right part from remaining for ; i < len(runes); i++ { r := runes[i] if r == '\x1b' { right.WriteRune(r) i++ for i < len(runes) && !isAnsiTerminator(runes[i]) { right.WriteRune(runes[i]) i++ } if i < len(runes) { right.WriteRune(runes[i]) } continue } right.WriteRune(r) } _ = inEscape // unused but kept for clarity return left.String(), right.String() } // skipVisualWidth skips a number of visual width units in a string, handling ANSI codes // It preserves and returns ANSI sequences encountered during skipping so styling can be restored func skipVisualWidth(s string, skipWidth int) string { var result strings.Builder var ansiState strings.Builder // collect ANSI codes while skipping visualWidth := 0 runes := []rune(s) i := 0 // Skip until we've passed skipWidth, but collect ANSI codes for i < len(runes) && visualWidth < skipWidth { r := runes[i] if r == '\x1b' { // ANSI escape - collect it (don't count visual width) ansiState.WriteRune(r) i++ for i < len(runes) && !isAnsiTerminator(runes[i]) { ansiState.WriteRune(runes[i]) i++ } if i < len(runes) { ansiState.WriteRune(runes[i]) // terminator i++ } continue } runeWidth := lipgloss.Width(string(r)) visualWidth += runeWidth i++ } // Prepend collected ANSI state to restore styling result.WriteString(ansiState.String()) // Output the rest for ; i < len(runes); i++ { result.WriteRune(runes[i]) } return result.String() } func isAnsiTerminator(r rune) bool { return (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') } // overlayBox composites an overlay box on top of a base view func overlayBox(base string, box string, boxWidth, boxHeight, screenWidth, screenHeight int) string { // ANSI reset sequence to stop any styling from bleeding into overlay const ansiReset = "\x1b[0m" // Split base into lines baseLines := strings.Split(base, "\n") // Ensure we have enough lines for len(baseLines) < screenHeight { baseLines = append(baseLines, "") } // Split box into lines boxLines := strings.Split(box, "\n") // Calculate center position startX := (screenWidth - boxWidth) / 2 startY := (screenHeight - boxHeight) / 2 if startX < 0 { startX = 0 } if startY < 0 { startY = 0 } // Overlay box onto base for i, boxLine := range boxLines { y := startY + i if y >= len(baseLines) { break } baseLine := baseLines[y] baseVisualWidth := lipgloss.Width(baseLine) // Get left part (before overlay) leftPart, _ := splitAtVisualWidth(baseLine, startX) // Get right part (after overlay) endX := startX + boxWidth var rightPart string if endX < baseVisualWidth { rightPart = skipVisualWidth(baseLine, endX) } // Combine: left + reset + box + right // Reset before overlay to stop highlight bleeding into overlay baseLines[y] = leftPart + ansiReset + boxLine + rightPart } return strings.Join(baseLines, "\n") } func (m *model) View() string { if m.width == 0 || m.height == 0 { return spinnerFrames[m.spinnerFrame] + " Running command…" } // Render the main UI mainView := m.renderMainView() // Overlay help if active if m.showHelp { box, boxWidth, boxHeight := m.renderHelpOverlay() return overlayBox(mainView, box, boxWidth, boxHeight, m.width, m.height) } return mainView } func (m model) renderMainView() string { // Box drawing characters (rounded) const ( topLeft = "╭" topRight = "╮" bottomLeft = "╰" bottomRight = "╯" horizontal = "─" vertical = "│" leftT = "├" rightT = "┤" topT = "┬" bottomT = "┴" ) borderColor := lipgloss.Color("240") borderStyle := lipgloss.NewStyle().Foreground(borderColor) // Styles promptStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("14")) selectedStyle := lipgloss.NewStyle(). Background(lipgloss.Color("15")). Foreground(lipgloss.Color("#000000")). Bold(true) lineNumStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("241")) filterStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("11")) filterRegexStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("13")) filterErrStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("9")) // Inner width (excluding border characters) innerWidth := m.width - 2 // Helper to create a horizontal line (optionally with a T-junction for vertical split) hLine := func(left, right string, splitPos int) string { if splitPos > 0 && splitPos < innerWidth { return borderStyle.Render(left + strings.Repeat(horizontal, splitPos) + topT + strings.Repeat(horizontal, innerWidth-splitPos-1) + right) } return borderStyle.Render(left + strings.Repeat(horizontal, innerWidth) + right) } // Helper for header separator line with T junction pointing down (for vertical split below) hLineMid := func(left, right string, splitPos int) string { if splitPos > 0 && splitPos < innerWidth { return borderStyle.Render(left + strings.Repeat(horizontal, splitPos) + topT + strings.Repeat(horizontal, innerWidth-splitPos-1) + right) } return borderStyle.Render(left + strings.Repeat(horizontal, innerWidth) + right) } // Helper for bottom line with vertical split hLineBottom := func(left, right string, splitPos int) string { if splitPos > 0 && splitPos < innerWidth { return borderStyle.Render(left + strings.Repeat(horizontal, splitPos) + bottomT + strings.Repeat(horizontal, innerWidth-splitPos-1) + right) } return borderStyle.Render(left + strings.Repeat(horizontal, innerWidth) + right) } // Helper to pad content to inner width padLine := func(content string) string { contentWidth := lipgloss.Width(content) if contentWidth < innerWidth { content += strings.Repeat(" ", innerWidth-contentWidth) } else if contentWidth > innerWidth { // Use lipgloss style with MaxWidth for ANSI-safe truncation content = lipgloss.NewStyle().MaxWidth(innerWidth-1).Render(content) + ellipsis } return borderStyle.Render(vertical) + content + borderStyle.Render(vertical) } // Build header content with status indicator titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("12")).Bold(true) // blue prefix := titleStyle.Render("watchr") + " • " var commandLine string switch { case m.streaming: // Streaming - show streaming indicator streamStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("14")) // cyan commandLine = prefix + streamStyle.Render("◉ "+m.config.Command) case m.loading: // Still loading - no status yet commandLine = prefix + m.config.Command case m.exitCode == 0: // Success - green checkmark and green command successStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("10")) // green commandLine = prefix + successStyle.Render("✓ "+m.config.Command) default: // Failure - red cross with exit code and red command failStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9")) // red commandLine = prefix + failStyle.Render(fmt.Sprintf("✗ [%d] %s", m.exitCode, m.config.Command)) } // Add refresh countdown on the right if auto-refresh is enabled and > 1s if m.config.RefreshInterval > time.Second && !m.streaming && !m.refreshStartTime.IsZero() { elapsed := time.Since(m.refreshStartTime) remaining := m.config.RefreshInterval - elapsed if remaining > 0 { countdownStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("241")) // dim gray countdown := countdownStyle.Render(fmt.Sprintf("(%ds)", int(remaining.Seconds())+1)) cmdWidth := lipgloss.Width(commandLine) countdownWidth := lipgloss.Width(countdown) gap := innerWidth - cmdWidth - countdownWidth if gap > 0 { commandLine += strings.Repeat(" ", gap) + countdown } } } // Build prompt line (will go at bottom) var promptLine string switch { case m.filterMode && m.filterRegex: label := filterRegexStyle.Render("regex/") before := m.filter[:m.filterCursor] after := m.filter[m.filterCursor:] input := filterStyle.Render(before) + "█" + filterStyle.Render(after) promptLine = label + input if m.filterRegexErr != nil { promptLine += " " + filterErrStyle.Render("(invalid regex)") } case m.filterMode: before := m.filter[:m.filterCursor] after := m.filter[m.filterCursor:] promptLine = filterStyle.Render("/"+before) + "█" + filterStyle.Render(after) case m.filter != "" && m.filterRegex: promptLine = promptStyle.Render(fmt.Sprintf("%s (regex: %s)", m.config.Prompt, m.filter)) case m.filter != "": promptLine = promptStyle.Render(fmt.Sprintf("%s (filter: %s)", m.config.Prompt, m.filter)) default: promptLine = promptStyle.Render(m.config.Prompt) } if m.streaming { promptLine += " " + spinnerFrames[m.spinnerFrame] + " Streaming…" } else if m.loading { promptLine += " " + spinnerFrames[m.spinnerFrame] + " Running command…" } if m.statusMsg != "" { statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("10")) // green promptLine += " " + statusStyle.Render(m.statusMsg) } // Add help hint on the right helpHint := lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render("? for help") promptWidth := lipgloss.Width(promptLine) hintWidth := lipgloss.Width(helpHint) gap := m.width - promptWidth - hintWidth if gap > 0 { promptLine += strings.Repeat(" ", gap) + helpHint } // Calculate layout listHeight := m.visibleLines() // listWidth is content area minus 1 for padding before border listWidth := innerWidth - 1 if m.showPreview && (m.config.PreviewPosition == PreviewLeft || m.config.PreviewPosition == PreviewRight) { // For horizontal split: innerWidth = leftW + 1 (middle border) + rightW // List gets the non-preview side width, minus 1 for padding listWidth = innerWidth - m.previewSize() - 2 } // Build lines view var listLines []string for i := range listHeight { lineIdx := m.offset + i if lineIdx >= len(m.filtered) { // Empty line to fill space listLines = append(listLines, "") continue } idx := m.filtered[lineIdx] if idx >= len(m.lines) { listLines = append(listLines, "") continue } line := m.lines[idx] var lineText string isSelected := lineIdx == m.cursor // Full width including the padding space before border fullWidth := listWidth + 1 if m.config.ShowLineNums { // Calculate widths without ANSI codes first lineNumStr := fmt.Sprintf("%*d ", m.config.LineNumWidth, line.Number) lineNumWidth := len(lineNumStr) // Plain ASCII, so len() == visual width contentWidth := listWidth - lineNumWidth // Truncate content (no ANSI codes yet) content := truncateToWidth(line.Content, contentWidth) if isSelected { // For selected line: gray line number + black content, both on white background selectedLineNumStyle := lipgloss.NewStyle(). Background(lipgloss.Color("15")). Foreground(lipgloss.Color("241")) selectedContentStyle := lipgloss.NewStyle(). Background(lipgloss.Color("15")). Foreground(lipgloss.Color("#000000")). Bold(true) // Pad content to fill remaining width contentPadded := content padding := fullWidth - lineNumWidth - lipgloss.Width(content) if padding > 0 { contentPadded = content + strings.Repeat(" ", padding) } lineText = selectedLineNumStyle.Render(lineNumStr) + selectedContentStyle.Render(contentPadded) } else { // Normal line - style line numbers differently lineText = lineNumStyle.Render(lineNumStr) + content } } else { // No line numbers, just truncate content lineText = truncateToWidth(line.Content, listWidth) if isSelected { // Pad to full width for selection highlight padding := fullWidth - lipgloss.Width(lineText) if padding > 0 { lineText += strings.Repeat(" ", padding) } lineText = selectedStyle.Render(lineText) } } listLines = append(listLines, lineText) } // Build preview content var previewContent string if m.showPreview && len(m.filtered) > 0 && m.cursor >= 0 && m.cursor < len(m.filtered) { idx := m.filtered[m.cursor] if idx < len(m.lines) { previewContent = highlightJSON(m.lines[idx].Content) } } // Error message if m.errorMsg != "" { listLines = append(listLines, lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render("Error: "+m.errorMsg)) } // Calculate vertical split position for left/right preview // This must match where the middle vertical bar falls in content lines var vSplitPos int if m.showPreview { switch m.config.PreviewPosition { case PreviewLeft: vSplitPos = m.previewSize() case PreviewRight: // leftW = innerWidth - previewSize - 1, so split is at leftW vSplitPos = innerWidth - m.previewSize() - 1 } } // Build the unified box var lines []string // Top border (no junction - vertical split starts at header separator) lines = append(lines, hLine(topLeft, topRight, 0)) // Header line (command only) lines = append(lines, padLine(commandLine)) // Separator between header and content (T junction if vertical split) lines = append(lines, hLineMid(leftT, rightT, vSplitPos)) // Content area (with optional preview) if !m.showPreview { // Just content lines, padded to fill height for i := range listHeight { if i < len(listLines) { lines = append(lines, padLine(listLines[i])) } else { lines = append(lines, padLine("")) } } } else { previewH := m.previewSize() switch m.config.PreviewPosition { case PreviewTop, PreviewBottom: // Vertical split - preview above or below content var previewLines []string // Wrap preview content to fit width if previewContent != "" { previewLines = wrapPreviewContent(previewContent, innerWidth) } // Pad preview to height for len(previewLines) < previewH { previewLines = append(previewLines, "") } if m.config.PreviewPosition == PreviewTop { // Preview first for _, line := range previewLines[:previewH] { lines = append(lines, padLine(line)) } // Separator (no vertical split for top/bottom preview) lines = append(lines, hLine(leftT, rightT, 0)) // Then content, padded to fill height for i := range listHeight { if i < len(listLines) { lines = append(lines, padLine(listLines[i])) } else { lines = append(lines, padLine("")) } } } else { // Content first, padded to fill height for i := range listHeight { if i < len(listLines) { lines = append(lines, padLine(listLines[i])) } else { lines = append(lines, padLine("")) } } // Separator (no vertical split for top/bottom preview) lines = append(lines, hLine(leftT, rightT, 0)) // Then preview for _, line := range previewLines[:previewH] { lines = append(lines, padLine(line)) } } case PreviewLeft, PreviewRight: // Horizontal split: |leftContent|rightContent| // innerWidth = leftW + 1 (middle border) + rightW var leftW, rightW int if m.config.PreviewPosition == PreviewLeft { leftW = m.previewSize() rightW = innerWidth - leftW - 1 } else { rightW = m.previewSize() leftW = innerWidth - rightW - 1 } // Prepare preview lines (wrap text instead of truncating) var previewLines []string if previewContent != "" { previewW := leftW if m.config.PreviewPosition == PreviewRight { previewW = rightW } previewLines = wrapPreviewContent(previewContent, previewW) } for len(previewLines) < listHeight { previewLines = append(previewLines, "") } // Helper to truncate/pad to width fitToWidth := func(s string, w int, isPreview bool) string { sw := lipgloss.Width(s) if sw > w { if isPreview { // Preview is already wrapped, just pad return s + strings.Repeat(" ", w-sw) } // List content may have ANSI codes, use lipgloss for safe truncation return lipgloss.NewStyle().MaxWidth(w-1).Render(s) + ellipsis } return s + strings.Repeat(" ", w-sw) } // Build combined lines for i := range listHeight { var leftContent, rightContent string var leftIsPreview, rightIsPreview bool if m.config.PreviewPosition == PreviewLeft { leftContent = previewLines[i] leftIsPreview = true if i < len(listLines) { rightContent = listLines[i] } } else { if i < len(listLines) { leftContent = listLines[i] } rightContent = previewLines[i] rightIsPreview = true } leftContent = fitToWidth(leftContent, leftW, leftIsPreview) rightContent = fitToWidth(rightContent, rightW, rightIsPreview) line := borderStyle.Render(vertical) + leftContent + borderStyle.Render(vertical) + rightContent + borderStyle.Render(vertical) lines = append(lines, line) } } } // Bottom border lines = append(lines, hLineBottom(bottomLeft, bottomRight, vSplitPos)) // Combine box with prompt fullView := strings.Join(lines, "\n") + "\n" + promptLine return fullView } // Run starts the UI func Run(cfg Config) error { if cfg.PreviewPosition == "" { cfg.PreviewPosition = PreviewBottom } m := initialModel(cfg) p := tea.NewProgram(&m, tea.WithAltScreen()) _, err := p.Run() return err }