diff --git a/README.md b/README.md index 3d803aa..94821eb 100755 --- a/README.md +++ b/README.md @@ -196,8 +196,8 @@ Configuration values are applied in this order (later sources override earlier o | ------------------ | -------------------------------- | | `r`, `Ctrl-r` | Reload (re-run command) | | `R` | Reload & clear all lines | -| `Del` | Delete selected line | -| `Ctrl-Del` | Clear all lines (with confirm) | +| `d`, `Del` | Delete selected line | +| `D` | Clear all lines | | `c` | Stop running command | | `q`, `Esc` | Quit | | `j`, `k` | Move down/up | diff --git a/internal/ui/actions.go b/internal/ui/actions.go new file mode 100644 index 0000000..00cde09 --- /dev/null +++ b/internal/ui/actions.go @@ -0,0 +1,153 @@ +package ui + +import ( + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +func (m *model) actionReload() (tea.Model, tea.Cmd) { + m.refreshGeneration++ + cmd := m.startStreaming() + return m, tea.Batch(cmd, m.spinnerTickCmd()) +} + +func (m *model) actionReloadClear() (tea.Model, tea.Cmd) { + m.lines = nil + m.updateFiltered() + return m.actionReload() +} + +func (m *model) actionDeleteLine() (tea.Model, tea.Cmd) { + if len(m.filtered) > 0 && m.cursor >= 0 && m.cursor < len(m.filtered) { + idx := m.filtered[m.cursor] + if idx < len(m.lines) { + m.lines = append(m.lines[:idx], m.lines[idx+1:]...) + m.updateFiltered() + } + } + return m, nil +} + +func (m *model) actionClearAllLines() (tea.Model, tea.Cmd) { + m.confirmMode = true + m.confirmMessage = "Clear all lines? (y/N)" + m.confirmAction = func(m *model) (tea.Model, tea.Cmd) { + m.lines = nil + m.updateFiltered() + m.statusMsg = "All lines cleared" + return m, m.statusTimeoutCmd() + } + return m, nil +} + +func (m *model) actionStopCommand() (tea.Model, tea.Cmd) { + if m.streaming { + m.cancel() + m.statusMsg = "Command stopped" + return m, m.statusTimeoutCmd() + } + return m, nil +} + +func (m *model) actionTogglePreview() (tea.Model, tea.Cmd) { + m.showPreview = !m.showPreview + m.adjustOffset() + return m, nil +} + +func (m *model) actionIncreasePreview() (tea.Model, tea.Cmd) { + if m.showPreview { + m.config.PreviewSize += previewSizeStep(m.config.PreviewSizeIsPercent) + m.adjustOffset() + } + return m, nil +} + +func (m *model) actionDecreasePreview() (tea.Model, tea.Cmd) { + if m.showPreview { + step := previewSizeStep(m.config.PreviewSizeIsPercent) + if m.config.PreviewSize > step { + m.config.PreviewSize -= step + m.adjustOffset() + } + } + return m, nil +} + +func (m *model) actionGoToFirst() (tea.Model, tea.Cmd) { + m.userScrolled = true + m.previewOffset = 0 + m.cursor = 0 + m.offset = 0 + return m, nil +} + +func (m *model) actionGoToLast() (tea.Model, tea.Cmd) { + m.userScrolled = false + m.previewOffset = 0 + if len(m.filtered) > 0 { + m.cursor = len(m.filtered) - 1 + m.adjustOffset() + } + return m, nil +} + +func (m *model) actionEnterFilter() (tea.Model, tea.Cmd) { + m.filterMode = true + m.filterCursor = len(m.filter) + return m, nil +} + +func (m *model) actionToggleRegexFilter() (tea.Model, tea.Cmd) { + m.filterMode = true + m.filterRegex = !m.filterRegex + m.filterRegexErr = nil + m.filterCursor = len(m.filter) + m.updateFiltered() + return m, nil +} + +func (m *model) actionCopyLine(plain bool) (tea.Model, tea.Cmd) { + 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 plain { + content = stripANSI(content) + } + if err := copyToClipboard(content); err != nil { + m.statusMsg = "Failed to copy" + } else if plain { + m.statusMsg = "Copied to clipboard (plain)" + } else { + m.statusMsg = "Copied to clipboard" + } + return m, m.statusTimeoutCmd() + } + } + return m, nil +} + +func (m *model) actionShowHelp() (tea.Model, tea.Cmd) { + m.showHelp = true + return m, nil +} + +func (m *model) actionQuit() (tea.Model, tea.Cmd) { + m.cancel() + return m, tea.Quit +} + +func (m *model) actionOpenPalette() (tea.Model, tea.Cmd) { + m.cmdPaletteMode = true + m.cmdPaletteFilter = "" + m.cmdPaletteCursor = 0 + m.cmdPaletteSelected = 0 + return m, nil +} + +// statusTimeoutCmd returns a command that clears the status message after 2 seconds. +func (m model) statusTimeoutCmd() tea.Cmd { + return tea.Tick(2*time.Second, func(t time.Time) tea.Msg { return clearStatusMsg{} }) +} diff --git a/internal/ui/actions_test.go b/internal/ui/actions_test.go new file mode 100644 index 0000000..fbbca5f --- /dev/null +++ b/internal/ui/actions_test.go @@ -0,0 +1,197 @@ +package ui + +import ( + "testing" + + "github.com/chenasraf/watchr/internal/runner" +) + +func TestActionReload(t *testing.T) { + m := testModelWithLines() + _, cmd := m.actionReload() + if m.refreshGeneration != 1 { + t.Errorf("expected refreshGeneration 1, got %d", m.refreshGeneration) + } + if cmd == nil { + t.Error("expected command to be returned") + } +} + +func TestActionReloadClear(t *testing.T) { + m := testModelWithLines() + originalLines := len(m.lines) + if originalLines == 0 { + t.Fatal("expected lines to be populated") + } + + _, cmd := m.actionReloadClear() + if m.lines != nil { + t.Errorf("expected lines nil, got %d", len(m.lines)) + } + if m.refreshGeneration != 1 { + t.Errorf("expected refreshGeneration 1, got %d", m.refreshGeneration) + } + if cmd == nil { + t.Error("expected command to be returned") + } +} + +func TestActionDeleteLine(t *testing.T) { + m := testModelWithLines() + m.cursor = 1 // "foo bar" + m.actionDeleteLine() + + for _, line := range m.lines { + if line.Content == "foo bar" { + t.Error("expected 'foo bar' to be deleted") + } + } + if len(m.lines) != 3 { + t.Errorf("expected 3 lines, got %d", len(m.lines)) + } +} + +func TestActionDeleteLineEmpty(t *testing.T) { + cfg := Config{Command: "echo test", Shell: "sh"} + m := testModel(cfg) + // Should not panic + m.actionDeleteLine() +} + +func TestActionClearAllLines(t *testing.T) { + m := testModelWithLines() + m.actionClearAllLines() + + if !m.confirmMode { + t.Error("expected confirmMode true") + } + if m.confirmMessage != "Clear all lines? (y/N)" { + t.Errorf("expected confirm message, got %q", m.confirmMessage) + } +} + +func TestActionStopCommand(t *testing.T) { + m := testModelWithCancel() + m.streaming = true + + _, cmd := m.actionStopCommand() + if m.statusMsg != "Command stopped" { + t.Errorf("expected 'Command stopped', got %q", m.statusMsg) + } + if cmd == nil { + t.Error("expected timeout command") + } +} + +func TestActionStopCommandNotStreaming(t *testing.T) { + m := testModelWithCancel() + m.streaming = false + + _, cmd := m.actionStopCommand() + if m.statusMsg != "" { + t.Errorf("expected empty status, got %q", m.statusMsg) + } + if cmd != nil { + t.Error("expected nil command when not streaming") + } +} + +func TestActionTogglePreview(t *testing.T) { + m := testModelWithLines() + m.showPreview = false + + m.actionTogglePreview() + if !m.showPreview { + t.Error("expected showPreview true") + } + + m.actionTogglePreview() + if m.showPreview { + t.Error("expected showPreview false") + } +} + +func TestActionPreviewResize(t *testing.T) { + m := testModelWithLines() + m.showPreview = true + m.config.PreviewSize = 10 + m.config.PreviewSizeIsPercent = false + + m.actionIncreasePreview() + if m.config.PreviewSize != 12 { + t.Errorf("expected 12, got %d", m.config.PreviewSize) + } + + m.actionDecreasePreview() + if m.config.PreviewSize != 10 { + t.Errorf("expected 10, got %d", m.config.PreviewSize) + } +} + +func TestActionGoToFirstLast(t *testing.T) { + m := testModelWithLines() + m.cursor = 2 + + m.actionGoToFirst() + if m.cursor != 0 { + t.Errorf("expected cursor 0, got %d", m.cursor) + } + + m.actionGoToLast() + if m.cursor != len(m.filtered)-1 { + t.Errorf("expected cursor %d, got %d", len(m.filtered)-1, m.cursor) + } +} + +func TestActionCopyLine(t *testing.T) { + m := testModelWithLines() + m.cursor = 0 + + // Test copy (may succeed or fail depending on clipboard availability) + _, cmd := m.actionCopyLine(false) + if m.statusMsg == "" { + t.Error("expected statusMsg to be set") + } + if cmd == nil { + t.Error("expected timeout command") + } +} + +func TestActionCopyLinePlain(t *testing.T) { + m := testModelWithLines() + m.lines = []runner.Line{{Number: 1, Content: "\x1b[31mred\x1b[0m"}} + m.updateFiltered() + m.cursor = 0 + + _, cmd := m.actionCopyLine(true) + if m.statusMsg == "" { + t.Error("expected statusMsg to be set") + } + if cmd == nil { + t.Error("expected timeout command") + } +} + +func TestActionShowHelp(t *testing.T) { + m := testModelWithLines() + m.actionShowHelp() + if !m.showHelp { + t.Error("expected showHelp true") + } +} + +func TestActionOpenPalette(t *testing.T) { + m := testModelWithLines() + m.actionOpenPalette() + if !m.cmdPaletteMode { + t.Error("expected cmdPaletteMode true") + } +} + +func TestActionEnterFilter(t *testing.T) { + m := testModelWithLines() + m.actionEnterFilter() + if !m.filterMode { + t.Error("expected filterMode true") + } +} diff --git a/internal/ui/commands.go b/internal/ui/commands.go new file mode 100644 index 0000000..bbea01b --- /dev/null +++ b/internal/ui/commands.go @@ -0,0 +1,79 @@ +package ui + +import ( + "fmt" + "os/exec" + "runtime" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +// command represents a command palette entry +type command struct { + name string // display name + shortcut string // keybinding hint + action func(m *model) (tea.Model, tea.Cmd) +} + +// commands returns the list of available command palette entries. +func commands() []command { + return []command{ + {"Reload command", "r / Ctrl+r", (*model).actionReload}, + {"Reload & clear lines", "R", (*model).actionReloadClear}, + {"Delete selected line", "d / Del", (*model).actionDeleteLine}, + {"Clear all lines", "D", (*model).actionClearAllLines}, + {"Stop running command", "c", (*model).actionStopCommand}, + {"Toggle preview pane", "p", (*model).actionTogglePreview}, + {"Increase preview size", "+", (*model).actionIncreasePreview}, + {"Decrease preview size", "-", (*model).actionDecreasePreview}, + {"Go to first line", "g", (*model).actionGoToFirst}, + {"Go to last line", "G", (*model).actionGoToLast}, + {"Enter filter mode", "/", (*model).actionEnterFilter}, + {"Toggle regex filter", "//", (*model).actionToggleRegexFilter}, + {"Copy line to clipboard", "y", func(m *model) (tea.Model, tea.Cmd) { return m.actionCopyLine(false) }}, + {"Copy line (plain text)", "Y", func(m *model) (tea.Model, tea.Cmd) { return m.actionCopyLine(true) }}, + {"Show help", "?", (*model).actionShowHelp}, + {"Quit", "q", (*model).actionQuit}, + } +} + +// filteredCommands returns commands matching the current palette filter. +func (m *model) filteredCommands() []command { + all := commands() + if m.cmdPaletteFilter == "" { + return all + } + filter := strings.ToLower(m.cmdPaletteFilter) + var result []command + for _, c := range all { + if strings.Contains(strings.ToLower(c.name), filter) { + result = append(result, c) + } + } + return result +} + +// 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() +} diff --git a/internal/ui/commands_test.go b/internal/ui/commands_test.go new file mode 100644 index 0000000..3e56663 --- /dev/null +++ b/internal/ui/commands_test.go @@ -0,0 +1,59 @@ +package ui + +import ( + "testing" +) + +func TestCommandsCount(t *testing.T) { + cmds := commands() + if len(cmds) != 16 { + t.Errorf("expected 16 commands, got %d", len(cmds)) + } +} + +func TestFilteredCommandsNoFilter(t *testing.T) { + m := testModelWithLines() + m.cmdPaletteFilter = "" + filtered := m.filteredCommands() + all := commands() + if len(filtered) != len(all) { + t.Errorf("expected %d commands with no filter, got %d", len(all), len(filtered)) + } +} + +func TestFilteredCommandsWithFilter(t *testing.T) { + m := testModelWithLines() + m.cmdPaletteFilter = "reload" + filtered := m.filteredCommands() + // "Reload command" and "Reload & clear lines" + if len(filtered) != 2 { + t.Errorf("expected 2 commands matching 'reload', got %d", len(filtered)) + } +} + +func TestFilteredCommandsCaseInsensitive(t *testing.T) { + m := testModelWithLines() + m.cmdPaletteFilter = "QUIT" + filtered := m.filteredCommands() + if len(filtered) != 1 { + t.Errorf("expected 1 command matching 'QUIT', got %d", len(filtered)) + } +} + +func TestCommandPaletteTogglePreview(t *testing.T) { + m := testModelWithLines() + m.showPreview = false + + // Find and execute "Toggle preview pane" command + cmds := commands() + for _, cmd := range cmds { + if cmd.name == "Toggle preview pane" { + cmd.action(m) + break + } + } + + if !m.showPreview { + t.Error("expected showPreview true after toggle command") + } +} diff --git a/internal/ui/helpers_test.go b/internal/ui/helpers_test.go new file mode 100644 index 0000000..a5e4a69 --- /dev/null +++ b/internal/ui/helpers_test.go @@ -0,0 +1,36 @@ +package ui + +import ( + "context" + + "github.com/chenasraf/watchr/internal/runner" +) + +func testModel(cfg Config) *model { + m := initialModel(cfg) + return &m +} + +func testModelWithLines() *model { + cfg := Config{Command: "echo test", Shell: "sh"} + m := testModel(cfg) + m.lines = []runner.Line{ + {Number: 1, Content: "hello world"}, + {Number: 2, Content: "foo bar"}, + {Number: 3, Content: "hello foo"}, + {Number: 4, Content: "baz qux"}, + } + m.height = 30 + m.width = 80 + m.updateFiltered() + return m +} + +func testModelWithCancel() *model { + cfg := Config{Command: "echo test", Shell: "sh"} + m := initialModel(cfg) + ctx, cancel := context.WithCancel(context.Background()) + m.ctx = ctx + m.cancel = cancel + return &m +} diff --git a/internal/ui/keys.go b/internal/ui/keys.go new file mode 100644 index 0000000..ff202cb --- /dev/null +++ b/internal/ui/keys.go @@ -0,0 +1,230 @@ +package ui + +import ( + tea "github.com/charmbracelet/bubbletea" +) + +func (m *model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.showHelp { + return m.handleHelpMode(msg) + } + if m.confirmMode { + return m.handleConfirmMode(msg) + } + if m.cmdPaletteMode { + return m.handleCmdPaletteMode(msg) + } + if m.filterMode { + return m.handleFilterMode(msg) + } + return m.handleNormalMode(msg) +} + +func (m *model) handleHelpMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "?", "esc", "q", "enter": + m.showHelp = false + } + return m, nil +} + +func (m *model) handleConfirmMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "y", "Y": + m.confirmMode = false + if m.confirmAction != nil { + return m.confirmAction(m) + } + default: + m.confirmMode = false + } + return m, nil +} + +func (m *model) handleCmdPaletteMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEsc: + m.cmdPaletteMode = false + m.cmdPaletteFilter = "" + m.cmdPaletteCursor = 0 + m.cmdPaletteSelected = 0 + return m, nil + case tea.KeyEnter: + filtered := m.filteredCommands() + if len(filtered) > 0 && m.cmdPaletteSelected < len(filtered) { + m.cmdPaletteMode = false + cmd := filtered[m.cmdPaletteSelected] + m.cmdPaletteFilter = "" + m.cmdPaletteCursor = 0 + m.cmdPaletteSelected = 0 + return cmd.action(m) + } + return m, nil + case tea.KeyUp: + if m.cmdPaletteSelected > 0 { + m.cmdPaletteSelected-- + } + return m, nil + case tea.KeyDown: + filtered := m.filteredCommands() + if m.cmdPaletteSelected < len(filtered)-1 { + m.cmdPaletteSelected++ + } + return m, nil + case tea.KeyLeft: + if msg.Alt { + m.cmdPaletteCursor = wordBoundaryLeft(m.cmdPaletteFilter, m.cmdPaletteCursor) + } else if m.cmdPaletteCursor > 0 { + m.cmdPaletteCursor-- + } + return m, nil + case tea.KeyRight: + if msg.Alt { + m.cmdPaletteCursor = wordBoundaryRight(m.cmdPaletteFilter, m.cmdPaletteCursor) + } else if m.cmdPaletteCursor < len(m.cmdPaletteFilter) { + m.cmdPaletteCursor++ + } + return m, nil + case tea.KeyBackspace: + if msg.Alt { + m.cmdPaletteFilter, m.cmdPaletteCursor = textBackspaceWord(m.cmdPaletteFilter, m.cmdPaletteCursor) + } else { + m.cmdPaletteFilter, m.cmdPaletteCursor = textBackspace(m.cmdPaletteFilter, m.cmdPaletteCursor) + } + m.cmdPaletteSelected = 0 + return m, nil + default: + if len(msg.Runes) > 0 { + m.cmdPaletteFilter, m.cmdPaletteCursor = textInsert(m.cmdPaletteFilter, string(msg.Runes), m.cmdPaletteCursor) + m.cmdPaletteSelected = 0 + } + return m, nil + } +} + +func (m *model) handleFilterMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + 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 { + m.filterCursor = wordBoundaryLeft(m.filter, m.filterCursor) + } else if m.filterCursor > 0 { + m.filterCursor-- + } + return m, nil + case tea.KeyRight: + if msg.Alt { + m.filterCursor = wordBoundaryRight(m.filter, m.filterCursor) + } else if m.filterCursor < len(m.filter) { + m.filterCursor++ + } + return m, nil + case tea.KeyBackspace: + if msg.Alt { + m.filter, m.filterCursor = textBackspaceWord(m.filter, m.filterCursor) + } else { + m.filter, m.filterCursor = textBackspace(m.filter, 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 + } else { + m.filter, m.filterCursor = textInsert(m.filter, s, m.filterCursor) + } + m.updateFiltered() + } + return m, nil + } +} + +func (m *model) handleNormalMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "ctrl+c": + return m.actionQuit() + case "esc": + if m.filter != "" || m.filterRegex { + m.filter = "" + m.filterCursor = 0 + m.filterRegex = false + m.filterRegexErr = nil + m.updateFiltered() + return m, nil + } + return m.actionQuit() + + 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": + return m.actionGoToFirst() + case "G", "end": + return m.actionGoToLast() + case "ctrl+d": + m.userScrolled = true + m.moveCursor(m.visibleLines() / 2) + case "ctrl+u": + m.userScrolled = true + m.moveCursor(-m.visibleLines() / 2) + case "J": + if m.showPreview { + m.previewOffset++ + m.clampPreviewOffset() + } + case "K": + if m.showPreview && m.previewOffset > 0 { + m.previewOffset-- + } + case "pgdown", "ctrl+f": + m.userScrolled = true + m.moveCursor(m.visibleLines()) + case "pgup", "ctrl+b": + m.userScrolled = true + m.moveCursor(-m.visibleLines()) + case "p": + return m.actionTogglePreview() + case "+", "=": + return m.actionIncreasePreview() + case "-": + return m.actionDecreasePreview() + case "r", "ctrl+r": + return m.actionReload() + case "R": + return m.actionReloadClear() + case "d", "delete": + return m.actionDeleteLine() + case "D": + return m.actionClearAllLines() + case "c": + return m.actionStopCommand() + case "/": + return m.actionEnterFilter() + case ":": + return m.actionOpenPalette() + case "?": + return m.actionShowHelp() + case "y": + return m.actionCopyLine(false) + case "Y": + return m.actionCopyLine(true) + } + + return m, nil +} diff --git a/internal/ui/keys_test.go b/internal/ui/keys_test.go new file mode 100644 index 0000000..f63cb3b --- /dev/null +++ b/internal/ui/keys_test.go @@ -0,0 +1,793 @@ +package ui + +import ( + "context" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/chenasraf/watchr/internal/runner" +) + +func TestFilterCursorMovement(t *testing.T) { + cfg := Config{Command: "echo test", Shell: "sh"} + m := testModel(cfg) + m.filterMode = true + m.filter = "hello" + m.filterCursor = 5 + + // Left arrow moves cursor left + keyMsg := tea.KeyMsg{Type: tea.KeyLeft} + result, _ := m.handleKeyPress(keyMsg) + m = result.(*model) + if m.filterCursor != 4 { + t.Errorf("expected filterCursor 4 after left, got %d", m.filterCursor) + } + + // Left again + result, _ = m.handleKeyPress(keyMsg) + m = result.(*model) + if m.filterCursor != 3 { + t.Errorf("expected filterCursor 3 after second left, got %d", m.filterCursor) + } + + // Left doesn't go below 0 + m.filterCursor = 0 + result, _ = m.handleKeyPress(keyMsg) + m = result.(*model) + if m.filterCursor != 0 { + t.Errorf("expected filterCursor 0 (clamped), got %d", m.filterCursor) + } + + // Right arrow moves cursor right + m.filterCursor = 2 + keyMsg = tea.KeyMsg{Type: tea.KeyRight} + result, _ = m.handleKeyPress(keyMsg) + m = result.(*model) + if m.filterCursor != 3 { + t.Errorf("expected filterCursor 3 after right, got %d", m.filterCursor) + } + + // Right doesn't go past end + m.filterCursor = 5 + result, _ = m.handleKeyPress(keyMsg) + m = result.(*model) + if m.filterCursor != 5 { + t.Errorf("expected filterCursor 5 (clamped), got %d", m.filterCursor) + } +} + +func TestFilterAltLeftRight(t *testing.T) { + cfg := Config{Command: "echo test", Shell: "sh"} + + t.Run("alt+left jumps to previous word boundary", func(t *testing.T) { + m := testModel(cfg) + m.filterMode = true + m.filter = "foo bar baz" + m.filterCursor = 11 // end + + keyMsg := tea.KeyMsg{Type: tea.KeyLeft, Alt: true} + result, _ := m.handleKeyPress(keyMsg) + m = result.(*model) + if m.filterCursor != 8 { + t.Errorf("expected filterCursor 8, got %d", m.filterCursor) + } + + result, _ = m.handleKeyPress(keyMsg) + m = result.(*model) + if m.filterCursor != 4 { + t.Errorf("expected filterCursor 4, got %d", m.filterCursor) + } + + result, _ = m.handleKeyPress(keyMsg) + m = result.(*model) + if m.filterCursor != 0 { + t.Errorf("expected filterCursor 0, got %d", m.filterCursor) + } + + // Already at start, stays at 0 + result, _ = m.handleKeyPress(keyMsg) + m = result.(*model) + if m.filterCursor != 0 { + t.Errorf("expected filterCursor 0 (clamped), got %d", m.filterCursor) + } + }) + + t.Run("alt+right jumps to next word boundary", func(t *testing.T) { + m := testModel(cfg) + m.filterMode = true + m.filter = "foo bar baz" + m.filterCursor = 0 + + keyMsg := tea.KeyMsg{Type: tea.KeyRight, Alt: true} + result, _ := m.handleKeyPress(keyMsg) + m = result.(*model) + if m.filterCursor != 4 { + t.Errorf("expected filterCursor 4, got %d", m.filterCursor) + } + + result, _ = m.handleKeyPress(keyMsg) + m = result.(*model) + if m.filterCursor != 8 { + t.Errorf("expected filterCursor 8, got %d", m.filterCursor) + } + + result, _ = m.handleKeyPress(keyMsg) + m = result.(*model) + if m.filterCursor != 11 { + t.Errorf("expected filterCursor 11, got %d", m.filterCursor) + } + + // Already at end, stays at 11 + result, _ = m.handleKeyPress(keyMsg) + m = result.(*model) + if m.filterCursor != 11 { + t.Errorf("expected filterCursor 11 (clamped), got %d", m.filterCursor) + } + }) + + t.Run("alt+left skips trailing spaces", func(t *testing.T) { + m := testModel(cfg) + m.filterMode = true + m.filter = "foo bar" + m.filterCursor = 6 // middle of spaces, before "bar" + + keyMsg := tea.KeyMsg{Type: tea.KeyLeft, Alt: true} + result, _ := m.handleKeyPress(keyMsg) + m = result.(*model) + if m.filterCursor != 0 { + t.Errorf("expected filterCursor 0, got %d", m.filterCursor) + } + }) + + t.Run("alt+right skips trailing spaces", func(t *testing.T) { + m := testModel(cfg) + m.filterMode = true + m.filter = "foo bar" + m.filterCursor = 3 // end of "foo" + + keyMsg := tea.KeyMsg{Type: tea.KeyRight, Alt: true} + result, _ := m.handleKeyPress(keyMsg) + m = result.(*model) + if m.filterCursor != 6 { + t.Errorf("expected filterCursor 6, got %d", m.filterCursor) + } + }) +} + +func TestFilterInsertAtCursor(t *testing.T) { + cfg := Config{Command: "echo test", Shell: "sh"} + m := testModel(cfg) + m.filterMode = true + m.filter = "helo" + m.filterCursor = 3 + + // Insert 'l' at position 3 -> "hello" + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}} + result, _ := m.handleKeyPress(keyMsg) + m = result.(*model) + if m.filter != "hello" { + t.Errorf("expected filter 'hello', got %q", m.filter) + } + if m.filterCursor != 4 { + t.Errorf("expected filterCursor 4, got %d", m.filterCursor) + } +} + +func TestFilterBackspaceAtCursor(t *testing.T) { + cfg := Config{Command: "echo test", Shell: "sh"} + m := testModel(cfg) + m.filterMode = true + m.filter = "hello" + m.filterCursor = 3 + + // Backspace at position 3 -> "helo" + keyMsg := tea.KeyMsg{Type: tea.KeyBackspace} + result, _ := m.handleKeyPress(keyMsg) + m = result.(*model) + if m.filter != "helo" { + t.Errorf("expected filter 'helo', got %q", m.filter) + } + if m.filterCursor != 2 { + t.Errorf("expected filterCursor 2, got %d", m.filterCursor) + } + + // Backspace at position 0 does nothing + m.filterCursor = 0 + result, _ = m.handleKeyPress(keyMsg) + m = result.(*model) + if m.filter != "helo" { + t.Errorf("expected filter 'helo' (unchanged), got %q", m.filter) + } + if m.filterCursor != 0 { + t.Errorf("expected filterCursor 0, got %d", m.filterCursor) + } +} + +func TestFilterAltBackspace(t *testing.T) { + cfg := Config{Command: "echo test", Shell: "sh"} + + tests := []struct { + name string + filter string + cursor int + expectedFilter string + expectedCursor int + }{ + {"delete last word", "hello world", 11, "hello ", 6}, + {"delete middle word", "foo bar baz", 7, "foo baz", 4}, + {"delete first word", "hello world", 5, " world", 0}, + {"delete with trailing spaces", "hello ", 8, "", 0}, + {"cursor at start", "hello", 0, "hello", 0}, + {"single word", "hello", 5, "", 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := testModel(cfg) + m.filterMode = true + m.filter = tt.filter + m.filterCursor = tt.cursor + + keyMsg := tea.KeyMsg{Type: tea.KeyBackspace, Alt: true} + result, _ := m.handleKeyPress(keyMsg) + newModel := result.(*model) + + if newModel.filter != tt.expectedFilter { + t.Errorf("expected filter %q, got %q", tt.expectedFilter, newModel.filter) + } + if newModel.filterCursor != tt.expectedCursor { + t.Errorf("expected filterCursor %d, got %d", tt.expectedCursor, newModel.filterCursor) + } + }) + } +} + +func TestFilterRegexToggle(t *testing.T) { + cfg := Config{Command: "echo test", Shell: "sh"} + m := testModel(cfg) + m.filterMode = true + m.filter = "" + m.filterCursor = 0 + + // Type '/' on empty filter toggles regex mode on + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}} + result, _ := m.handleKeyPress(keyMsg) + m = result.(*model) + if !m.filterRegex { + t.Error("expected filterRegex to be true after typing /") + } + if m.filter != "" { + t.Errorf("expected empty filter, got %q", m.filter) + } + + // Type '/' again on empty filter toggles regex mode off + result, _ = m.handleKeyPress(keyMsg) + m = result.(*model) + if m.filterRegex { + t.Error("expected filterRegex to be false after second /") + } + + // Type '/' when filter is non-empty adds it to filter + m.filterRegex = true + m.filter = "abc" + m.filterCursor = 3 + result, _ = m.handleKeyPress(keyMsg) + m = result.(*model) + if m.filter != "abc/" { + t.Errorf("expected filter 'abc/', got %q", m.filter) + } + if !m.filterRegex { + t.Error("expected filterRegex to remain true") + } +} + +func TestFilterEscClearsRegex(t *testing.T) { + cfg := Config{Command: "echo test", Shell: "sh"} + m := testModel(cfg) + m.filterMode = true + m.filter = "test" + m.filterCursor = 4 + m.filterRegex = true + + // Esc in filter mode clears everything + keyMsg := tea.KeyMsg{Type: tea.KeyEsc} + result, _ := m.handleKeyPress(keyMsg) + m = result.(*model) + + if m.filterMode { + t.Error("expected filterMode to be false") + } + if m.filter != "" { + t.Errorf("expected empty filter, got %q", m.filter) + } + if m.filterCursor != 0 { + t.Errorf("expected filterCursor 0, got %d", m.filterCursor) + } + if m.filterRegex { + t.Error("expected filterRegex to be false") + } +} + +func TestStopCommandKeybinding(t *testing.T) { + cfg := Config{ + Command: "echo test", + Shell: "sh", + } + + t.Run("stops running command when streaming", func(t *testing.T) { + m := initialModel(cfg) + // Set up a cancellable context to track if cancel was called + ctx, cancel := context.WithCancel(context.Background()) + m.ctx = ctx + m.cancel = cancel + m.streaming = true + m.statusMsg = "" + + // Simulate pressing 'c' + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}} + result, cmd := m.handleKeyPress(keyMsg) + newModel := result.(*model) + + // Should set status message + if newModel.statusMsg != "Command stopped" { + t.Errorf("expected statusMsg 'Command stopped', got %q", newModel.statusMsg) + } + + // Should return a command (the tick for clearing status) + if cmd == nil { + t.Error("expected a command to be returned for status message timeout") + } + + // Context should be cancelled + select { + case <-ctx.Done(): + // Good, context was cancelled + default: + t.Error("expected context to be cancelled") + } + }) + + t.Run("does nothing when not streaming", func(t *testing.T) { + m := initialModel(cfg) + ctx, cancel := context.WithCancel(context.Background()) + m.ctx = ctx + m.cancel = cancel + m.streaming = false + m.statusMsg = "" + + // Simulate pressing 'c' + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}} + result, cmd := m.handleKeyPress(keyMsg) + newModel := result.(*model) + + // Should not set status message + if newModel.statusMsg != "" { + t.Errorf("expected empty statusMsg, got %q", newModel.statusMsg) + } + + // Should not return a command + if cmd != nil { + t.Error("expected no command to be returned when not streaming") + } + + // Context should NOT be cancelled + select { + case <-ctx.Done(): + t.Error("expected context to NOT be cancelled when not streaming") + default: + // Good, context is still active + } + }) +} + +// New keybinding tests + +func TestKeyReloadClear(t *testing.T) { + m := testModelWithLines() + if len(m.lines) == 0 { + t.Fatal("expected lines to be populated") + } + + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'R'}} + result, cmd := m.handleKeyPress(keyMsg) + newModel := result.(*model) + + if newModel.lines != nil { + t.Errorf("expected lines to be nil after R, got %d lines", len(newModel.lines)) + } + if newModel.refreshGeneration != 1 { + t.Errorf("expected refreshGeneration 1, got %d", newModel.refreshGeneration) + } + if cmd == nil { + t.Error("expected a command to be returned") + } +} + +func TestKeyDelete(t *testing.T) { + m := testModelWithLines() + originalLen := len(m.lines) + m.cursor = 1 // select "foo bar" + + keyMsg := tea.KeyMsg{Type: tea.KeyDelete} + result, _ := m.handleKeyPress(keyMsg) + newModel := result.(*model) + + if len(newModel.lines) != originalLen-1 { + t.Errorf("expected %d lines after delete, got %d", originalLen-1, len(newModel.lines)) + } + // The second line ("foo bar") should be gone + for _, line := range newModel.lines { + if line.Content == "foo bar" { + t.Error("expected 'foo bar' to be deleted") + } + } +} + +func TestKeyDeleteEmpty(t *testing.T) { + cfg := Config{Command: "echo test", Shell: "sh"} + m := testModel(cfg) + m.height = 30 + m.width = 80 + + // No lines, should not panic + keyMsg := tea.KeyMsg{Type: tea.KeyDelete} + result, _ := m.handleKeyPress(keyMsg) + newModel := result.(*model) + if len(newModel.lines) != 0 { + t.Errorf("expected 0 lines, got %d", len(newModel.lines)) + } +} + +func TestKeyCtrlDelete(t *testing.T) { + m := testModelWithLines() + // ctrl+delete is hard to simulate via tea.KeyMsg, so test the confirm flow directly + m.confirmMode = true + m.confirmMessage = "Clear all lines? (y/N)" + m.confirmAction = func(m *model) (tea.Model, tea.Cmd) { + m.lines = nil + m.updateFiltered() + m.statusMsg = "All lines cleared" + return m, nil + } + + // Test confirmation with 'y' + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}} + result, _ := m.handleKeyPress(keyMsg) + newModel := result.(*model) + if newModel.lines != nil { + t.Errorf("expected lines to be nil after confirm, got %d", len(newModel.lines)) + } + if newModel.statusMsg != "All lines cleared" { + t.Errorf("expected status 'All lines cleared', got %q", newModel.statusMsg) + } +} + +func TestKeyConfirmDialog(t *testing.T) { + t.Run("y confirms action", func(t *testing.T) { + m := testModelWithLines() + confirmed := false + m.confirmMode = true + m.confirmMessage = "Test?" + m.confirmAction = func(m *model) (tea.Model, tea.Cmd) { + confirmed = true + return m, nil + } + + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}} + result, _ := m.handleKeyPress(keyMsg) + newModel := result.(*model) + + if !confirmed { + t.Error("expected confirm action to be called") + } + if newModel.confirmMode { + t.Error("expected confirmMode to be false after confirm") + } + }) + + t.Run("other key cancels", func(t *testing.T) { + m := testModelWithLines() + confirmed := false + m.confirmMode = true + m.confirmMessage = "Test?" + m.confirmAction = func(m *model) (tea.Model, tea.Cmd) { + confirmed = true + return m, nil + } + + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}} + result, _ := m.handleKeyPress(keyMsg) + newModel := result.(*model) + + if confirmed { + t.Error("expected confirm action NOT to be called") + } + if newModel.confirmMode { + t.Error("expected confirmMode to be false after cancel") + } + }) +} + +func TestKeyGoToFirstLast(t *testing.T) { + m := testModelWithLines() + m.cursor = 2 + + // Go to first line with 'g' + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}} + result, _ := m.handleKeyPress(keyMsg) + newModel := result.(*model) + + if newModel.cursor != 0 { + t.Errorf("expected cursor 0 after 'g', got %d", newModel.cursor) + } + if newModel.offset != 0 { + t.Errorf("expected offset 0 after 'g', got %d", newModel.offset) + } + if !newModel.userScrolled { + t.Error("expected userScrolled true after 'g'") + } + + // Go to last line with 'G' + keyMsg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'G'}} + result, _ = newModel.handleKeyPress(keyMsg) + newModel = result.(*model) + + if newModel.cursor != len(newModel.filtered)-1 { + t.Errorf("expected cursor at last line (%d), got %d", len(newModel.filtered)-1, newModel.cursor) + } + if newModel.userScrolled { + t.Error("expected userScrolled false after 'G' (resume following)") + } +} + +func TestKeyPreviewScrollJK(t *testing.T) { + m := testModelWithLines() + m.showPreview = true + m.config.PreviewSize = 5 + m.config.PreviewSizeIsPercent = false + m.config.PreviewPosition = PreviewBottom + + // J scrolls preview down + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'J'}} + result, _ := m.handleKeyPress(keyMsg) + newModel := result.(*model) + // previewOffset might be clamped to 0 for short content, but the code path runs + if newModel.previewOffset < 0 { + t.Errorf("expected previewOffset >= 0, got %d", newModel.previewOffset) + } + + // Set offset to 1 and test K scrolls up + newModel.previewOffset = 1 + keyMsg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'K'}} + result, _ = newModel.handleKeyPress(keyMsg) + newModel = result.(*model) + if newModel.previewOffset != 0 { + t.Errorf("expected previewOffset 0 after K, got %d", newModel.previewOffset) + } +} + +func TestKeyPreviewScrollJKWithoutPreview(t *testing.T) { + m := testModelWithLines() + m.showPreview = false + + // J should not change previewOffset when preview is hidden + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'J'}} + result, _ := m.handleKeyPress(keyMsg) + newModel := result.(*model) + if newModel.previewOffset != 0 { + t.Errorf("expected previewOffset 0 when preview hidden, got %d", newModel.previewOffset) + } +} + +func TestKeyTogglePreview(t *testing.T) { + m := testModelWithLines() + if m.showPreview { + t.Fatal("expected showPreview false initially") + } + + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}} + result, _ := m.handleKeyPress(keyMsg) + newModel := result.(*model) + if !newModel.showPreview { + t.Error("expected showPreview true after 'p'") + } + + result, _ = newModel.handleKeyPress(keyMsg) + newModel = result.(*model) + if newModel.showPreview { + t.Error("expected showPreview false after second 'p'") + } +} + +func TestKeyResizePreview(t *testing.T) { + m := testModelWithLines() + m.showPreview = true + m.config.PreviewSize = 10 + m.config.PreviewSizeIsPercent = false + + // '+' increases + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'+'}} + result, _ := m.handleKeyPress(keyMsg) + newModel := result.(*model) + if newModel.config.PreviewSize != 12 { + t.Errorf("expected PreviewSize 12 after '+', got %d", newModel.config.PreviewSize) + } + + // '-' decreases + keyMsg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'-'}} + result, _ = newModel.handleKeyPress(keyMsg) + newModel = result.(*model) + if newModel.config.PreviewSize != 10 { + t.Errorf("expected PreviewSize 10 after '-', got %d", newModel.config.PreviewSize) + } + + // '-' doesn't go below step + newModel.config.PreviewSize = 2 + result, _ = newModel.handleKeyPress(keyMsg) + newModel = result.(*model) + if newModel.config.PreviewSize != 2 { + t.Errorf("expected PreviewSize 2 (minimum), got %d", newModel.config.PreviewSize) + } +} + +func TestKeyCmdPaletteOpenAndNav(t *testing.T) { + m := testModelWithLines() + + // ':' opens command palette + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{':'}} + result, _ := m.handleKeyPress(keyMsg) + newModel := result.(*model) + if !newModel.cmdPaletteMode { + t.Error("expected cmdPaletteMode true after ':'") + } + + // Down arrow moves selection + keyMsg = tea.KeyMsg{Type: tea.KeyDown} + result, _ = newModel.handleKeyPress(keyMsg) + newModel = result.(*model) + if newModel.cmdPaletteSelected != 1 { + t.Errorf("expected cmdPaletteSelected 1, got %d", newModel.cmdPaletteSelected) + } + + // Up arrow moves selection back + keyMsg = tea.KeyMsg{Type: tea.KeyUp} + result, _ = newModel.handleKeyPress(keyMsg) + newModel = result.(*model) + if newModel.cmdPaletteSelected != 0 { + t.Errorf("expected cmdPaletteSelected 0, got %d", newModel.cmdPaletteSelected) + } + + // Typing filters + keyMsg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}} + result, _ = newModel.handleKeyPress(keyMsg) + newModel = result.(*model) + if newModel.cmdPaletteFilter != "r" { + t.Errorf("expected cmdPaletteFilter 'r', got %q", newModel.cmdPaletteFilter) + } + + // Esc closes palette + keyMsg = tea.KeyMsg{Type: tea.KeyEsc} + result, _ = newModel.handleKeyPress(keyMsg) + newModel = result.(*model) + if newModel.cmdPaletteMode { + t.Error("expected cmdPaletteMode false after Esc") + } +} + +func TestKeyHelpToggle(t *testing.T) { + m := testModelWithLines() + + // '?' opens help + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}} + result, _ := m.handleKeyPress(keyMsg) + newModel := result.(*model) + if !newModel.showHelp { + t.Error("expected showHelp true after '?'") + } + + // '?' closes help + result, _ = newModel.handleKeyPress(keyMsg) + newModel = result.(*model) + if newModel.showHelp { + t.Error("expected showHelp false after second '?'") + } +} + +func TestKeyQuit(t *testing.T) { + m := testModelWithCancel() + + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}} + _, cmd := m.handleKeyPress(keyMsg) + + if cmd == nil { + t.Error("expected quit command to be returned") + } +} + +func TestKeyFilterMode(t *testing.T) { + m := testModelWithLines() + + // '/' enters filter mode + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}} + result, _ := m.handleKeyPress(keyMsg) + newModel := result.(*model) + if !newModel.filterMode { + t.Error("expected filterMode true after '/'") + } +} + +func TestKeyResizePreviewNoEffect(t *testing.T) { + // '+' and '-' do nothing when preview is not shown + m := testModelWithLines() + m.showPreview = false + m.config.PreviewSize = 10 + + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'+'}} + result, _ := m.handleKeyPress(keyMsg) + newModel := result.(*model) + if newModel.config.PreviewSize != 10 { + t.Errorf("expected PreviewSize unchanged at 10, got %d", newModel.config.PreviewSize) + } +} + +func TestKeyNavigationJK(t *testing.T) { + m := testModelWithLines() + m.cursor = 0 + + // 'j' moves down + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}} + result, _ := m.handleKeyPress(keyMsg) + newModel := result.(*model) + if newModel.cursor != 1 { + t.Errorf("expected cursor 1 after 'j', got %d", newModel.cursor) + } + if !newModel.userScrolled { + t.Error("expected userScrolled true after 'j'") + } + + // 'k' moves up + keyMsg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}} + result, _ = newModel.handleKeyPress(keyMsg) + newModel = result.(*model) + if newModel.cursor != 0 { + t.Errorf("expected cursor 0 after 'k', got %d", newModel.cursor) + } +} + +func TestKeyYank(t *testing.T) { + m := testModelWithLines() + m.cursor = 0 + + // 'y' should set a status message (clipboard may or may not work in test env) + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}} + result, cmd := m.handleKeyPress(keyMsg) + newModel := result.(*model) + + // Should have set some status message (either success or failure) + if newModel.statusMsg == "" { + t.Error("expected statusMsg to be set after 'y'") + } + if cmd == nil { + t.Error("expected a command for status timeout") + } +} + +func TestKeyYankPlain(t *testing.T) { + m := testModelWithLines() + m.lines = []runner.Line{ + {Number: 1, Content: "\x1b[31mred text\x1b[0m"}, + } + m.updateFiltered() + m.cursor = 0 + + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'Y'}} + result, cmd := m.handleKeyPress(keyMsg) + newModel := result.(*model) + + if newModel.statusMsg == "" { + t.Error("expected statusMsg to be set after 'Y'") + } + if cmd == nil { + t.Error("expected a command for status timeout") + } +} diff --git a/internal/ui/layout.go b/internal/ui/layout.go new file mode 100644 index 0000000..4042519 --- /dev/null +++ b/internal/ui/layout.go @@ -0,0 +1,161 @@ +package ui + +import ( + "regexp" + "strings" +) + +func (m *model) moveCursor(delta int) { + m.previewOffset = 0 + 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 previewSizeStep(isPercent bool) int { + if isPercent { + return 5 + } + return 2 +} + +// clampPreviewOffset computes the actual preview content size and clamps +// previewOffset so it can't exceed the scrollable range. +func (m *model) clampPreviewOffset() { + if !m.showPreview || m.cursor < 0 || m.cursor >= len(m.filtered) { + m.previewOffset = 0 + return + } + idx := m.filtered[m.cursor] + if idx >= len(m.lines) { + m.previewOffset = 0 + return + } + + content := highlightJSON(m.lines[idx].Content) + innerWidth := m.width - 2 + + var previewW, visibleH int + switch m.config.PreviewPosition { + case PreviewTop, PreviewBottom: + previewW = innerWidth + visibleH = m.previewSize() + case PreviewLeft: + previewW = m.previewSize() + visibleH = m.visibleLines() + case PreviewRight: + previewW = m.previewSize() + visibleH = m.visibleLines() + } + + previewLines := wrapPreviewContent(content, previewW) + maxOffset := max(len(previewLines)-visibleH, 0) + if m.previewOffset > maxOffset { + m.previewOffset = maxOffset + } +} + +// applyPreviewOffset slices previewLines based on the current preview scroll +// offset, clamping the offset so it doesn't scroll past the content. +func (m *model) applyPreviewOffset(previewLines []string, visibleH int) []string { + maxOffset := max(len(previewLines)-visibleH, 0) + if m.previewOffset > maxOffset { + m.previewOffset = maxOffset + } + if m.previewOffset > 0 { + previewLines = previewLines[m.previewOffset:] + } + return previewLines +} + +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 +} + +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 + } + } +} diff --git a/internal/ui/layout_test.go b/internal/ui/layout_test.go new file mode 100644 index 0000000..3f463f6 --- /dev/null +++ b/internal/ui/layout_test.go @@ -0,0 +1,363 @@ +package ui + +import ( + "testing" + + "github.com/chenasraf/watchr/internal/runner" +) + +func TestModelUpdateFiltered(t *testing.T) { + cfg := Config{ + Command: "echo test", + Shell: "sh", + } + + m := initialModel(cfg) + + // Add some test lines + m.lines = []runner.Line{ + {Number: 1, Content: "hello world"}, + {Number: 2, Content: "foo bar"}, + {Number: 3, Content: "hello foo"}, + {Number: 4, Content: "baz qux"}, + } + + // Test with no filter + m.filter = "" + m.updateFiltered() + + if len(m.filtered) != 4 { + t.Errorf("expected 4 filtered lines, got %d", len(m.filtered)) + } + + // Test with filter + m.filter = "hello" + m.updateFiltered() + + if len(m.filtered) != 2 { + t.Errorf("expected 2 filtered lines for 'hello', got %d", len(m.filtered)) + } + + // Test case insensitive + m.filter = "HELLO" + m.updateFiltered() + + if len(m.filtered) != 2 { + t.Errorf("expected 2 filtered lines for 'HELLO' (case insensitive), got %d", len(m.filtered)) + } + + // Test no matches + m.filter = "xyz" + m.updateFiltered() + + if len(m.filtered) != 0 { + t.Errorf("expected 0 filtered lines for 'xyz', got %d", len(m.filtered)) + } +} + +func TestModelMoveCursor(t *testing.T) { + cfg := Config{ + Command: "echo test", + Shell: "sh", + } + + m := initialModel(cfg) + m.filtered = []int{0, 1, 2, 3, 4} + m.height = 100 // enough height for all lines + + // Move down + m.moveCursor(1) + if m.cursor != 1 { + t.Errorf("expected cursor at 1, got %d", m.cursor) + } + + // Move down more + m.moveCursor(2) + if m.cursor != 3 { + t.Errorf("expected cursor at 3, got %d", m.cursor) + } + + // Move past end + m.moveCursor(10) + if m.cursor != 4 { + t.Errorf("expected cursor at 4 (clamped), got %d", m.cursor) + } + + // Move up + m.moveCursor(-2) + if m.cursor != 2 { + t.Errorf("expected cursor at 2, got %d", m.cursor) + } + + // Move past beginning + m.moveCursor(-10) + if m.cursor != 0 { + t.Errorf("expected cursor at 0 (clamped), got %d", m.cursor) + } +} + +func TestVisibleLines(t *testing.T) { + cfg := Config{ + Command: "echo test", + Shell: "sh", + PreviewSize: 40, + PreviewSizeIsPercent: true, + PreviewPosition: PreviewBottom, + } + + m := initialModel(cfg) + m.height = 100 + + // Fixed lines: top border (1) + header (1) + separator (1) + bottom border (1) + prompt (1) = 5 + fixedLines := 5 + + // Without preview + m.showPreview = false + visible := m.visibleLines() + expected := 100 - fixedLines + if visible != expected { + t.Errorf("expected %d visible lines without preview, got %d", expected, visible) + } + + // With preview at bottom (percentage) + m.showPreview = true + visible = m.visibleLines() + previewHeight := 100 * 40 / 100 // 40% + // Add 1 for the separator between content and preview + expected = 100 - fixedLines - previewHeight - 1 + if visible != expected { + t.Errorf("expected %d visible lines with preview, got %d", expected, visible) + } + + // With preview using absolute size + m.config.PreviewSizeIsPercent = false + m.config.PreviewSize = 10 + visible = m.visibleLines() + // Add 1 for the separator between content and preview + expected = 100 - fixedLines - 10 - 1 + if visible != expected { + t.Errorf("expected %d visible lines with absolute preview size, got %d", expected, visible) + } +} + +func TestUpdateFilteredPreservesOffset(t *testing.T) { + cfg := Config{ + Command: "echo test", + Shell: "sh", + } + + m := initialModel(cfg) + m.height = 20 // Enough for visibleLines to return > 0 + + // Add many test lines + for i := 1; i <= 100; i++ { + m.lines = append(m.lines, runner.Line{Number: i, Content: "line content"}) + } + + // Set initial state with offset + m.filter = "" + m.updateFiltered() + m.offset = 50 + m.cursor = 55 + + // Simulate streaming update - add more lines without changing filter + m.lines = append(m.lines, runner.Line{Number: 101, Content: "new line"}) + m.updateFiltered() + + // Offset should be preserved (or clamped if necessary) + if m.offset < 50 { + t.Errorf("expected offset to be preserved (>= 50), got %d", m.offset) + } + + // Cursor should be preserved + if m.cursor != 55 { + t.Errorf("expected cursor to be preserved at 55, got %d", m.cursor) + } +} + +func TestUpdateFilteredClampsOffsetWhenNeeded(t *testing.T) { + cfg := Config{ + Command: "echo test", + Shell: "sh", + } + + m := initialModel(cfg) + m.height = 20 + + // Add test lines + for i := 1; i <= 100; i++ { + m.lines = append(m.lines, runner.Line{Number: i, Content: "line content"}) + } + + m.filter = "" + m.updateFiltered() + m.offset = 90 + m.cursor = 95 + + // Now filter to fewer lines + m.filter = "xyz" // No matches + m.updateFiltered() + + // Offset should be clamped to valid range + if m.offset != 0 { + t.Errorf("expected offset to be clamped to 0, got %d", m.offset) + } + + // Cursor should be clamped + if m.cursor != 0 { + t.Errorf("expected cursor to be clamped to 0, got %d", m.cursor) + } +} + +func TestFilterRegexMatching(t *testing.T) { + cfg := Config{Command: "echo test", Shell: "sh"} + m := initialModel(cfg) + m.lines = []runner.Line{ + {Number: 1, Content: "hello world"}, + {Number: 2, Content: "foo bar"}, + {Number: 3, Content: "hello foo"}, + {Number: 4, Content: "baz 123 qux"}, + } + + // Regex filter matching + m.filterRegex = true + m.filter = "hello.*foo" + m.updateFiltered() + if len(m.filtered) != 1 { + t.Errorf("expected 1 match for regex 'hello.*foo', got %d", len(m.filtered)) + } + if len(m.filtered) > 0 && m.filtered[0] != 2 { + t.Errorf("expected match at index 2, got %d", m.filtered[0]) + } + + // Regex with character class + m.filter = "\\d+" + m.updateFiltered() + if len(m.filtered) != 1 { + t.Errorf("expected 1 match for regex '\\d+', got %d", len(m.filtered)) + } + + // Regex is case insensitive + m.filter = "HELLO" + m.updateFiltered() + if len(m.filtered) != 2 { + t.Errorf("expected 2 matches for case-insensitive regex 'HELLO', got %d", len(m.filtered)) + } +} + +func TestFilterRegexInvalid(t *testing.T) { + cfg := Config{Command: "echo test", Shell: "sh"} + m := initialModel(cfg) + m.lines = []runner.Line{ + {Number: 1, Content: "hello world"}, + {Number: 2, Content: "foo bar"}, + } + + m.filterRegex = true + m.filter = "[invalid" + m.updateFiltered() + + // Should have an error + if m.filterRegexErr == nil { + t.Error("expected filterRegexErr to be non-nil for invalid regex") + } + + // Should show all lines when regex is invalid + if len(m.filtered) != 2 { + t.Errorf("expected all 2 lines shown for invalid regex, got %d", len(m.filtered)) + } + + // Valid regex clears the error + m.filter = "hello" + m.updateFiltered() + if m.filterRegexErr != nil { + t.Errorf("expected filterRegexErr to be nil for valid regex, got %v", m.filterRegexErr) + } +} + +func TestPreviewSizeStep(t *testing.T) { + if previewSizeStep(true) != 5 { + t.Errorf("expected 5 for percent mode, got %d", previewSizeStep(true)) + } + if previewSizeStep(false) != 2 { + t.Errorf("expected 2 for absolute mode, got %d", previewSizeStep(false)) + } +} + +func TestClampPreviewOffset(t *testing.T) { + m := testModelWithLines() + m.showPreview = true + m.config.PreviewPosition = PreviewBottom + m.config.PreviewSize = 5 + m.config.PreviewSizeIsPercent = false + + // Set an excessively high offset + m.previewOffset = 100 + m.clampPreviewOffset() + + // Should be clamped to a valid range (0 for short content) + if m.previewOffset > 0 { + t.Errorf("expected previewOffset clamped to 0 for short content, got %d", m.previewOffset) + } +} + +func TestClampPreviewOffsetNoPreview(t *testing.T) { + m := testModelWithLines() + m.showPreview = false + m.previewOffset = 5 + + m.clampPreviewOffset() + if m.previewOffset != 0 { + t.Errorf("expected previewOffset reset to 0 when preview hidden, got %d", m.previewOffset) + } +} + +func TestApplyPreviewOffset(t *testing.T) { + m := testModelWithLines() + lines := []string{"a", "b", "c", "d", "e"} + + // No offset + m.previewOffset = 0 + result := m.applyPreviewOffset(lines, 3) + if len(result) != 5 { + t.Errorf("expected 5 lines with no offset, got %d", len(result)) + } + + // With offset + m.previewOffset = 2 + result = m.applyPreviewOffset(lines, 3) + if len(result) != 3 || result[0] != "c" { + t.Errorf("expected lines starting at 'c', got %v", result) + } + + // Offset clamped if too high + m.previewOffset = 10 + _ = m.applyPreviewOffset(lines, 3) + if m.previewOffset != 2 { + t.Errorf("expected previewOffset clamped to 2, got %d", m.previewOffset) + } +} + +func TestAdjustOffset(t *testing.T) { + m := testModelWithLines() + m.height = 15 // visibleLines = 15 - 5 = 10 + + // Cursor near start - offset should be 0 + m.cursor = 0 + m.adjustOffset() + if m.offset != 0 { + t.Errorf("expected offset 0 for cursor at start, got %d", m.offset) + } + + // Cursor in middle of many lines + for i := range 50 { + m.filtered = append(m.filtered, i) + } + m.cursor = 25 + m.adjustOffset() + // Should center the cursor + expected := 25 - m.visibleLines()/2 + if m.offset != expected { + t.Errorf("expected offset %d for centered cursor, got %d", expected, m.offset) + } +} diff --git a/internal/ui/model.go b/internal/ui/model.go new file mode 100644 index 0000000..fa36f06 --- /dev/null +++ b/internal/ui/model.go @@ -0,0 +1,98 @@ +package ui + +import ( + "context" + "time" + + tea "github.com/charmbracelet/bubbletea" + "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 + previewOffset int // scroll offset for preview pane + 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 + + cmdPaletteMode bool // whether command palette is open + cmdPaletteFilter string // current filter text + cmdPaletteCursor int // cursor position within filter string + cmdPaletteSelected int // selected item index in filtered list + + confirmMode bool // whether a confirmation dialog is visible + confirmMessage string // message to display in confirmation dialog + confirmAction func(m *model) (tea.Model, tea.Cmd) +} + +// 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() } diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go new file mode 100644 index 0000000..b847df5 --- /dev/null +++ b/internal/ui/model_test.go @@ -0,0 +1,210 @@ +package ui + +import ( + "testing" + "time" +) + +func TestConfig(t *testing.T) { + cfg := Config{ + Command: "echo test", + Shell: "sh", + PreviewSize: 40, + PreviewSizeIsPercent: true, + PreviewPosition: PreviewBottom, + ShowLineNums: true, + LineNumWidth: 6, + Prompt: "watchr> ", + RefreshInterval: 5 * time.Second, + } + + if cfg.Command != "echo test" { + t.Errorf("expected command 'echo test', got %q", cfg.Command) + } + + if cfg.Shell != "sh" { + t.Errorf("expected shell 'sh', got %q", cfg.Shell) + } + + if cfg.PreviewSize != 40 { + t.Errorf("expected preview size 40, got %d", cfg.PreviewSize) + } + + if !cfg.PreviewSizeIsPercent { + t.Error("expected PreviewSizeIsPercent to be true") + } + + if cfg.PreviewPosition != PreviewBottom { + t.Errorf("expected preview position 'bottom', got %q", cfg.PreviewPosition) + } + + if !cfg.ShowLineNums { + t.Error("expected ShowLineNums to be true") + } + + if cfg.LineNumWidth != 6 { + t.Errorf("expected line num width 6, got %d", cfg.LineNumWidth) + } + + if cfg.Prompt != "watchr> " { + t.Errorf("expected prompt 'watchr> ', got %q", cfg.Prompt) + } + + if cfg.RefreshInterval != 5*time.Second { + t.Errorf("expected refresh interval 5s, got %v", cfg.RefreshInterval) + } +} + +func TestPreviewPositionConstants(t *testing.T) { + tests := []struct { + pos PreviewPosition + want string + }{ + {PreviewBottom, "bottom"}, + {PreviewTop, "top"}, + {PreviewLeft, "left"}, + {PreviewRight, "right"}, + } + + for _, tt := range tests { + if string(tt.pos) != tt.want { + t.Errorf("PreviewPosition %v != %q", tt.pos, tt.want) + } + } +} + +func TestConfigDefaults(t *testing.T) { + // Test with zero values + cfg := Config{} + + if cfg.Command != "" { + t.Errorf("expected empty command, got %q", cfg.Command) + } + + if cfg.Shell != "" { + t.Errorf("expected empty shell, got %q", cfg.Shell) + } + + if cfg.PreviewSize != 0 { + t.Errorf("expected preview size 0, got %d", cfg.PreviewSize) + } + + if cfg.PreviewSizeIsPercent { + t.Error("expected PreviewSizeIsPercent to be false") + } + + if cfg.PreviewPosition != "" { + t.Errorf("expected empty preview position, got %q", cfg.PreviewPosition) + } + + if cfg.ShowLineNums { + t.Error("expected ShowLineNums to be false") + } + + if cfg.LineNumWidth != 0 { + t.Errorf("expected line num width 0, got %d", cfg.LineNumWidth) + } + + if cfg.Prompt != "" { + t.Errorf("expected empty prompt, got %q", cfg.Prompt) + } +} + +func TestInitialModel(t *testing.T) { + cfg := Config{ + Command: "echo test", + Shell: "sh", + PreviewSize: 40, + PreviewSizeIsPercent: true, + PreviewPosition: PreviewBottom, + ShowLineNums: true, + LineNumWidth: 6, + Prompt: "watchr> ", + } + + m := initialModel(cfg) + + if m.config.Command != cfg.Command { + t.Errorf("expected command %q, got %q", cfg.Command, m.config.Command) + } + + if m.cursor != 0 { + t.Errorf("expected cursor at 0, got %d", m.cursor) + } + + if m.offset != 0 { + t.Errorf("expected offset at 0, got %d", m.offset) + } + + if m.filterMode { + t.Error("expected filterMode to be false") + } + + if m.showPreview { + t.Error("expected showPreview to be false") + } + + if !m.loading { + t.Error("expected loading to be true initially") + } +} + +func TestConfigRefreshFromStart(t *testing.T) { + // Test with RefreshFromStart false (default) + cfg := Config{ + Command: "echo test", + Shell: "sh", + RefreshInterval: 5 * time.Second, + RefreshFromStart: false, + } + + if cfg.RefreshFromStart { + t.Error("expected RefreshFromStart to be false by default") + } + + // Test with RefreshFromStart true + cfg.RefreshFromStart = true + if !cfg.RefreshFromStart { + t.Error("expected RefreshFromStart to be true after setting") + } +} + +func TestModelUserScrolled(t *testing.T) { + cfg := Config{ + Command: "echo test", + Shell: "sh", + } + + m := initialModel(cfg) + + // Initially should be false + if m.userScrolled { + t.Error("expected userScrolled to be false initially") + } + + // After setting, should be true + m.userScrolled = true + if !m.userScrolled { + t.Error("expected userScrolled to be true after setting") + } +} + +func TestModelRefreshGeneration(t *testing.T) { + cfg := Config{ + Command: "echo test", + Shell: "sh", + } + + m := initialModel(cfg) + + // Initially should be 0 + if m.refreshGeneration != 0 { + t.Errorf("expected refreshGeneration to be 0 initially, got %d", m.refreshGeneration) + } + + // After incrementing + m.refreshGeneration++ + if m.refreshGeneration != 1 { + t.Errorf("expected refreshGeneration to be 1 after increment, got %d", m.refreshGeneration) + } +} diff --git a/internal/ui/text.go b/internal/ui/text.go new file mode 100644 index 0000000..8c1dd70 --- /dev/null +++ b/internal/ui/text.go @@ -0,0 +1,346 @@ +package ui + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +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 +} + +// 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() +} + +// wordBoundaryLeft returns the cursor position after jumping left to the previous word boundary. +func wordBoundaryLeft(s string, pos int) int { + for pos > 0 && s[pos-1] == ' ' { + pos-- + } + for pos > 0 && s[pos-1] != ' ' { + pos-- + } + return pos +} + +// wordBoundaryRight returns the cursor position after jumping right to the next word boundary. +func wordBoundaryRight(s string, pos int) int { + for pos < len(s) && s[pos] != ' ' { + pos++ + } + for pos < len(s) && s[pos] == ' ' { + pos++ + } + return pos +} + +// textInsert inserts a string at the cursor position, returning new text and cursor. +func textInsert(text, insert string, cursor int) (string, int) { + return text[:cursor] + insert + text[cursor:], cursor + len(insert) +} + +// textBackspace deletes one character before the cursor, returning new text and cursor. +func textBackspace(text string, cursor int) (string, int) { + if cursor <= 0 { + return text, cursor + } + return text[:cursor-1] + text[cursor:], cursor - 1 +} + +// textBackspaceWord deletes the word before the cursor, returning new text and cursor. +func textBackspaceWord(text string, cursor int) (string, int) { + if cursor <= 0 { + return text, cursor + } + newPos := wordBoundaryLeft(text, cursor) + return text[:newPos] + text[cursor:], newPos +} + +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") +} diff --git a/internal/ui/text_test.go b/internal/ui/text_test.go new file mode 100644 index 0000000..a38789a --- /dev/null +++ b/internal/ui/text_test.go @@ -0,0 +1,205 @@ +package ui + +import ( + "strings" + "testing" +) + +func TestTruncateToWidth(t *testing.T) { + tests := []struct { + name string + input string + maxWidth int + want string + }{ + {"empty maxWidth", "hello", 0, ""}, + {"no truncation needed", "hello", 10, "hello"}, + {"exact fit", "hello", 5, "hello"}, + {"truncate with ellipsis", "hello world", 8, "hello w…"}, + {"maxWidth 1", "hello", 1, "…"}, + {"empty string", "", 10, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := truncateToWidth(tt.input, tt.maxWidth) + if got != tt.want { + t.Errorf("truncateToWidth(%q, %d) = %q, want %q", tt.input, tt.maxWidth, got, tt.want) + } + }) + } +} + +func TestSplitAtVisualWidth(t *testing.T) { + t.Run("plain text", func(t *testing.T) { + left, right := splitAtVisualWidth("hello world", 5) + if left != "hello" { + t.Errorf("expected left 'hello', got %q", left) + } + if right != " world" { + t.Errorf("expected right ' world', got %q", right) + } + }) + + t.Run("pads if short", func(t *testing.T) { + left, right := splitAtVisualWidth("hi", 5) + if left != "hi " { + t.Errorf("expected left 'hi ', got %q", left) + } + if right != "" { + t.Errorf("expected empty right, got %q", right) + } + }) + + t.Run("with ANSI codes", func(t *testing.T) { + input := "\x1b[31mhello\x1b[0m world" + left, _ := splitAtVisualWidth(input, 5) + // Left should contain the ANSI code and "hello" + if !strings.Contains(left, "\x1b[31m") { + t.Error("expected left to contain ANSI color code") + } + if !strings.Contains(left, "hello") { + t.Error("expected left to contain 'hello'") + } + }) +} + +func TestSkipVisualWidth(t *testing.T) { + t.Run("plain text", func(t *testing.T) { + result := skipVisualWidth("hello world", 6) + if result != "world" { + t.Errorf("expected 'world', got %q", result) + } + }) + + t.Run("preserves ANSI state", func(t *testing.T) { + input := "\x1b[31mhello world\x1b[0m" + result := skipVisualWidth(input, 6) + // Should preserve the ANSI code encountered during skip + if !strings.Contains(result, "\x1b[31m") { + t.Error("expected ANSI state to be preserved") + } + }) +} + +func TestIsAnsiTerminator(t *testing.T) { + // Letters are terminators + if !isAnsiTerminator('m') { + t.Error("expected 'm' to be terminator") + } + if !isAnsiTerminator('A') { + t.Error("expected 'A' to be terminator") + } + if !isAnsiTerminator('z') { + t.Error("expected 'z' to be terminator") + } + + // Digits are not terminators + if isAnsiTerminator('0') { + t.Error("expected '0' not to be terminator") + } + if isAnsiTerminator(';') { + t.Error("expected ';' not to be terminator") + } +} + +func TestWordBoundaryLeft(t *testing.T) { + tests := []struct { + name string + s string + pos int + want int + }{ + {"end of string", "foo bar baz", 11, 8}, + {"middle of word", "foo bar baz", 9, 8}, + {"at word boundary", "foo bar baz", 8, 4}, + {"at start", "foo bar", 0, 0}, + {"with multiple spaces", "foo bar", 9, 6}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := wordBoundaryLeft(tt.s, tt.pos) + if got != tt.want { + t.Errorf("wordBoundaryLeft(%q, %d) = %d, want %d", tt.s, tt.pos, got, tt.want) + } + }) + } +} + +func TestWordBoundaryRight(t *testing.T) { + tests := []struct { + name string + s string + pos int + want int + }{ + {"start of string", "foo bar baz", 0, 4}, + {"middle of word", "foo bar baz", 1, 4}, + {"at word boundary", "foo bar baz", 4, 8}, + {"at end", "foo bar", 7, 7}, + {"with multiple spaces", "foo bar", 3, 6}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := wordBoundaryRight(tt.s, tt.pos) + if got != tt.want { + t.Errorf("wordBoundaryRight(%q, %d) = %d, want %d", tt.s, tt.pos, got, tt.want) + } + }) + } +} + +func TestTextInsert(t *testing.T) { + text, cursor := textInsert("helo", "l", 3) + if text != "hello" || cursor != 4 { + t.Errorf("got %q cursor %d, want 'hello' cursor 4", text, cursor) + } +} + +func TestTextBackspace(t *testing.T) { + text, cursor := textBackspace("hello", 3) + if text != "helo" || cursor != 2 { + t.Errorf("got %q cursor %d, want 'helo' cursor 2", text, cursor) + } + + // At start, no change + text, cursor = textBackspace("hello", 0) + if text != "hello" || cursor != 0 { + t.Errorf("got %q cursor %d, want 'hello' cursor 0", text, cursor) + } +} + +func TestTextBackspaceWord(t *testing.T) { + text, cursor := textBackspaceWord("hello world", 11) + if text != "hello " || cursor != 6 { + t.Errorf("got %q cursor %d, want 'hello ' cursor 6", text, cursor) + } + + // At start, no change + text, cursor = textBackspaceWord("hello", 0) + if text != "hello" || cursor != 0 { + t.Errorf("got %q cursor %d, want 'hello' cursor 0", text, cursor) + } +} + +func TestOverlayBox(t *testing.T) { + base := "aaaaaaaaaa\naaaaaaaaaa\naaaaaaaaaa\naaaaaaaaaa\naaaaaaaaaa" + box := "XX\nXX" + + result := overlayBox(base, box, 2, 2, 10, 5) + lines := strings.Split(result, "\n") + + if len(lines) != 5 { + t.Errorf("expected 5 lines, got %d", len(lines)) + } + + // The overlay should appear in the middle rows + // Center: startY = (5-2)/2 = 1, startX = (10-2)/2 = 4 + // Lines 1 and 2 should contain the overlay + if !strings.Contains(lines[1], "XX") { + t.Errorf("expected overlay in line 1, got %q", lines[1]) + } + if !strings.Contains(lines[2], "XX") { + t.Errorf("expected overlay in line 2, got %q", lines[2]) + } +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go deleted file mode 100755 index 3d0028e..0000000 --- a/internal/ui/ui.go +++ /dev/null @@ -1,1935 +0,0 @@ -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 - previewOffset int // scroll offset for preview pane - 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 - - cmdPaletteMode bool // whether command palette is open - cmdPaletteFilter string // current filter text - cmdPaletteCursor int // cursor position within filter string - cmdPaletteSelected int // selected item index in filtered list - - confirmMode bool // whether a confirmation dialog is visible - confirmMessage string // message to display in confirmation dialog - confirmAction func(m *model) (tea.Model, tea.Cmd) -} - -// 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() } - -// command represents a command palette entry -type command struct { - name string // display name - shortcut string // keybinding hint - action func(m *model) (tea.Model, tea.Cmd) -} - -// commands returns the list of available command palette entries. -func commands() []command { - return []command{ - {"Reload command", "r / Ctrl+r", func(m *model) (tea.Model, tea.Cmd) { - m.refreshGeneration++ - cmd := m.startStreaming() - return m, tea.Batch(cmd, m.spinnerTickCmd()) - }}, - {"Reload & clear lines", "R", func(m *model) (tea.Model, tea.Cmd) { - m.lines = nil - m.updateFiltered() - m.refreshGeneration++ - cmd := m.startStreaming() - return m, tea.Batch(cmd, m.spinnerTickCmd()) - }}, - {"Delete selected line", "Del", func(m *model) (tea.Model, tea.Cmd) { - if len(m.filtered) > 0 && m.cursor >= 0 && m.cursor < len(m.filtered) { - idx := m.filtered[m.cursor] - if idx < len(m.lines) { - m.lines = append(m.lines[:idx], m.lines[idx+1:]...) - m.updateFiltered() - } - } - return m, nil - }}, - {"Clear all lines", "Ctrl+Del", func(m *model) (tea.Model, tea.Cmd) { - m.confirmMode = true - m.confirmMessage = "Clear all lines? (y/N)" - m.confirmAction = func(m *model) (tea.Model, tea.Cmd) { - m.lines = nil - m.updateFiltered() - m.statusMsg = "All lines cleared" - return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg { return clearStatusMsg{} }) - } - return m, nil - }}, - {"Stop running command", "c", func(m *model) (tea.Model, tea.Cmd) { - if m.streaming { - m.cancel() - m.statusMsg = "Command stopped" - return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg { return clearStatusMsg{} }) - } - return m, nil - }}, - {"Toggle preview pane", "p", func(m *model) (tea.Model, tea.Cmd) { - m.showPreview = !m.showPreview - m.adjustOffset() - return m, nil - }}, - {"Increase preview size", "+", func(m *model) (tea.Model, tea.Cmd) { - if m.showPreview { - m.config.PreviewSize += previewSizeStep(m.config.PreviewSizeIsPercent) - m.adjustOffset() - } - return m, nil - }}, - {"Decrease preview size", "-", func(m *model) (tea.Model, tea.Cmd) { - if m.showPreview { - step := previewSizeStep(m.config.PreviewSizeIsPercent) - if m.config.PreviewSize > step { - m.config.PreviewSize -= step - m.adjustOffset() - } - } - return m, nil - }}, - {"Go to first line", "g", func(m *model) (tea.Model, tea.Cmd) { - m.userScrolled = true - m.previewOffset = 0 - m.cursor = 0 - m.offset = 0 - return m, nil - }}, - {"Go to last line", "G", func(m *model) (tea.Model, tea.Cmd) { - m.userScrolled = false - m.previewOffset = 0 - if len(m.filtered) > 0 { - m.cursor = len(m.filtered) - 1 - m.adjustOffset() - } - return m, nil - }}, - {"Enter filter mode", "/", func(m *model) (tea.Model, tea.Cmd) { - m.filterMode = true - m.filterCursor = len(m.filter) - return m, nil - }}, - {"Toggle regex filter", "//", func(m *model) (tea.Model, tea.Cmd) { - m.filterMode = true - m.filterRegex = !m.filterRegex - m.filterRegexErr = nil - m.filterCursor = len(m.filter) - m.updateFiltered() - return m, nil - }}, - {"Copy line to clipboard", "y", func(m *model) (tea.Model, tea.Cmd) { - 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 - }}, - {"Copy line (plain text)", "Y", func(m *model) (tea.Model, tea.Cmd) { - if len(m.filtered) > 0 && m.cursor >= 0 && m.cursor < len(m.filtered) { - idx := m.filtered[m.cursor] - if idx < len(m.lines) { - content := stripANSI(m.lines[idx].Content) - if err := copyToClipboard(content); err != nil { - m.statusMsg = "Failed to copy" - } else { - m.statusMsg = "Copied to clipboard (plain)" - } - return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg { return clearStatusMsg{} }) - } - } - return m, nil - }}, - {"Show help", "?", func(m *model) (tea.Model, tea.Cmd) { - m.showHelp = true - return m, nil - }}, - {"Quit", "q", func(m *model) (tea.Model, tea.Cmd) { - m.cancel() - return m, tea.Quit - }}, - } -} - -// filteredCommands returns commands matching the current palette filter. -func (m *model) filteredCommands() []command { - all := commands() - if m.cmdPaletteFilter == "" { - return all - } - filter := strings.ToLower(m.cmdPaletteFilter) - var result []command - for _, c := range all { - if strings.Contains(strings.ToLower(c.name), filter) { - result = append(result, c) - } - } - return result -} - -// 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 confirmation mode, y confirms and any other key cancels - if m.confirmMode { - switch msg.String() { - case "y", "Y": - m.confirmMode = false - if m.confirmAction != nil { - return m.confirmAction(m) - } - default: - m.confirmMode = false - } - return m, nil - } - - // In command palette mode - if m.cmdPaletteMode { - switch msg.Type { - case tea.KeyEsc: - m.cmdPaletteMode = false - m.cmdPaletteFilter = "" - m.cmdPaletteCursor = 0 - m.cmdPaletteSelected = 0 - return m, nil - case tea.KeyEnter: - filtered := m.filteredCommands() - if len(filtered) > 0 && m.cmdPaletteSelected < len(filtered) { - m.cmdPaletteMode = false - cmd := filtered[m.cmdPaletteSelected] - m.cmdPaletteFilter = "" - m.cmdPaletteCursor = 0 - m.cmdPaletteSelected = 0 - return cmd.action(m) - } - return m, nil - case tea.KeyUp: - if m.cmdPaletteSelected > 0 { - m.cmdPaletteSelected-- - } - return m, nil - case tea.KeyDown: - filtered := m.filteredCommands() - if m.cmdPaletteSelected < len(filtered)-1 { - m.cmdPaletteSelected++ - } - return m, nil - case tea.KeyLeft: - if msg.Alt { - pos := m.cmdPaletteCursor - for pos > 0 && m.cmdPaletteFilter[pos-1] == ' ' { - pos-- - } - for pos > 0 && m.cmdPaletteFilter[pos-1] != ' ' { - pos-- - } - m.cmdPaletteCursor = pos - } else if m.cmdPaletteCursor > 0 { - m.cmdPaletteCursor-- - } - return m, nil - case tea.KeyRight: - if msg.Alt { - pos := m.cmdPaletteCursor - for pos < len(m.cmdPaletteFilter) && m.cmdPaletteFilter[pos] != ' ' { - pos++ - } - for pos < len(m.cmdPaletteFilter) && m.cmdPaletteFilter[pos] == ' ' { - pos++ - } - m.cmdPaletteCursor = pos - } else if m.cmdPaletteCursor < len(m.cmdPaletteFilter) { - m.cmdPaletteCursor++ - } - return m, nil - case tea.KeyBackspace: - if msg.Alt { - if m.cmdPaletteCursor > 0 { - pos := m.cmdPaletteCursor - for pos > 0 && m.cmdPaletteFilter[pos-1] == ' ' { - pos-- - } - for pos > 0 && m.cmdPaletteFilter[pos-1] != ' ' { - pos-- - } - m.cmdPaletteFilter = m.cmdPaletteFilter[:pos] + m.cmdPaletteFilter[m.cmdPaletteCursor:] - m.cmdPaletteCursor = pos - } - } else if m.cmdPaletteCursor > 0 { - m.cmdPaletteFilter = m.cmdPaletteFilter[:m.cmdPaletteCursor-1] + m.cmdPaletteFilter[m.cmdPaletteCursor:] - m.cmdPaletteCursor-- - } - m.cmdPaletteSelected = 0 - return m, nil - default: - if len(msg.Runes) > 0 { - s := string(msg.Runes) - m.cmdPaletteFilter = m.cmdPaletteFilter[:m.cmdPaletteCursor] + s + m.cmdPaletteFilter[m.cmdPaletteCursor:] - m.cmdPaletteCursor += len(s) - m.cmdPaletteSelected = 0 - } - 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.previewOffset = 0 - m.cursor = 0 - m.offset = 0 - case "G", "end": - m.userScrolled = false // Resume following output - m.previewOffset = 0 - 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 "J": - if m.showPreview { - m.previewOffset++ - m.clampPreviewOffset() - } - case "K": - if m.showPreview && m.previewOffset > 0 { - m.previewOffset-- - } - 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 "+", "=": - if m.showPreview { - m.config.PreviewSize += previewSizeStep(m.config.PreviewSizeIsPercent) - m.adjustOffset() - } - case "-": - if m.showPreview { - step := previewSizeStep(m.config.PreviewSizeIsPercent) - if m.config.PreviewSize > step { - m.config.PreviewSize -= step - m.adjustOffset() - } - } - case "r", "ctrl+r": - // Restart streaming and reset auto-refresh timer - m.refreshGeneration++ - cmd := m.startStreaming() - return m, tea.Batch(cmd, m.spinnerTickCmd()) - case "R": - // Refresh command and clear all lines - m.lines = nil - m.updateFiltered() - m.refreshGeneration++ - cmd := m.startStreaming() - return m, tea.Batch(cmd, m.spinnerTickCmd()) - case "delete": - // Delete the currently selected line - if len(m.filtered) > 0 && m.cursor >= 0 && m.cursor < len(m.filtered) { - idx := m.filtered[m.cursor] - if idx < len(m.lines) { - m.lines = append(m.lines[:idx], m.lines[idx+1:]...) - m.updateFiltered() - } - } - case "ctrl+delete": - // Clear all lines with confirmation - m.confirmMode = true - m.confirmMessage = "Clear all lines? (y/N)" - m.confirmAction = func(m *model) (tea.Model, tea.Cmd) { - m.lines = nil - m.updateFiltered() - m.statusMsg = "All lines cleared" - return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg { return clearStatusMsg{} }) - } - return m, nil - 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.cmdPaletteMode = true - m.cmdPaletteFilter = "" - m.cmdPaletteCursor = 0 - m.cmdPaletteSelected = 0 - case "?": - m.showHelp = true - case "y": - // Yank (copy) selected line to clipboard (with ANSI codes) - 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{} - }) - } - } - case "Y": - // Yank (copy) selected line to clipboard, stripping ANSI codes - if len(m.filtered) > 0 && m.cursor >= 0 && m.cursor < len(m.filtered) { - idx := m.filtered[m.cursor] - if idx < len(m.lines) { - content := stripANSI(m.lines[idx].Content) - if err := copyToClipboard(content); err != nil { - m.statusMsg = "Failed to copy" - } else { - m.statusMsg = "Copied to clipboard (plain)" - } - 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.previewOffset = 0 - 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 previewSizeStep(isPercent bool) int { - if isPercent { - return 5 - } - return 2 -} - -// clampPreviewOffset computes the actual preview content size and clamps -// previewOffset so it can't exceed the scrollable range. -func (m *model) clampPreviewOffset() { - if !m.showPreview || m.cursor < 0 || m.cursor >= len(m.filtered) { - m.previewOffset = 0 - return - } - idx := m.filtered[m.cursor] - if idx >= len(m.lines) { - m.previewOffset = 0 - return - } - - content := highlightJSON(m.lines[idx].Content) - innerWidth := m.width - 2 - - var previewW, visibleH int - switch m.config.PreviewPosition { - case PreviewTop, PreviewBottom: - previewW = innerWidth - visibleH = m.previewSize() - case PreviewLeft: - previewW = m.previewSize() - visibleH = m.visibleLines() - case PreviewRight: - previewW = m.previewSize() - visibleH = m.visibleLines() - } - - previewLines := wrapPreviewContent(content, previewW) - maxOffset := max(len(previewLines)-visibleH, 0) - if m.previewOffset > maxOffset { - m.previewOffset = maxOffset - } -} - -// applyPreviewOffset slices previewLines based on the current preview scroll -// offset, clamping the offset so it doesn't scroll past the content. -func (m *model) applyPreviewOffset(previewLines []string, visibleH int) []string { - maxOffset := max(len(previewLines)-visibleH, 0) - if m.previewOffset > maxOffset { - m.previewOffset = maxOffset - } - if m.previewOffset > 0 { - previewLines = previewLines[m.previewOffset:] - } - return previewLines -} - -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 - } - } -} - -// renderCmdPaletteOverlay creates the command palette overlay box -func (m model) renderCmdPaletteOverlay() (box string, boxWidth, boxHeight int) { - keyStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("241")) // dim - - nameStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("252")) - - selectedNameStyle := lipgloss.NewStyle(). - Background(lipgloss.Color("15")). - Foreground(lipgloss.Color("#000000")). - Bold(true) - - selectedKeyStyle := lipgloss.NewStyle(). - Background(lipgloss.Color("15")). - Foreground(lipgloss.Color("241")) - - filterStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("11")) - - borderColor := lipgloss.Color("12") - - allCommands := commands() - filtered := m.filteredCommands() - - // Compute column width - const paletteWidth = 40 - totalSlots := len(allCommands) // fixed height so box doesn't move - - var content strings.Builder - - // Filter input with bottom border - before := m.cmdPaletteFilter[:m.cmdPaletteCursor] - after := m.cmdPaletteFilter[m.cmdPaletteCursor:] - filterLine := filterStyle.Render(":"+before) + "█" + filterStyle.Render(after) - // Pad filter line to full width - filterVisual := lipgloss.Width(filterLine) - if filterVisual < paletteWidth { - filterLine += strings.Repeat(" ", paletteWidth-filterVisual) - } - content.WriteString(filterLine + "\n") - content.WriteString(lipgloss.NewStyle().Foreground(borderColor).Render(strings.Repeat("─", paletteWidth)) + "\n") - - // Command list (fixed number of rows) - for i := range totalSlots { - if i < len(filtered) { - cmd := filtered[i] - gap := max(paletteWidth-lipgloss.Width(cmd.name)-lipgloss.Width(cmd.shortcut), 2) - if i == m.cmdPaletteSelected { - line := selectedNameStyle.Render(cmd.name+strings.Repeat(" ", gap)) + selectedKeyStyle.Render(cmd.shortcut) - content.WriteString(line + "\n") - } else { - content.WriteString(nameStyle.Render(cmd.name) + strings.Repeat(" ", gap) + keyStyle.Render(cmd.shortcut) + "\n") - } - } else { - content.WriteString(strings.Repeat(" ", paletteWidth) + "\n") - } - } - - boxStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(borderColor) - - box = boxStyle.Render(content.String()) - boxWidth = lipgloss.Width(box) - boxHeight = lipgloss.Height(box) - - return box, boxWidth, boxHeight -} - -// 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"}, - {"+/-", "Resize preview pane"}, - {"J / K", "Scroll preview down / up"}, - {"/", "Enter filter mode"}, - {"//", "Toggle regex filter mode"}, - {"Esc", "Exit filter / clear"}, - {"", ""}, - {"r / Ctrl+r", "Reload command"}, - {"R", "Reload & clear lines"}, - {"Del", "Delete selected line"}, - {"Ctrl+Del", "Clear all lines"}, - {"c", "Stop running command"}, - {"y", "Copy line to clipboard"}, - {"Y", "Copy line (plain text)"}, - {":", "Open command palette"}, - {"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 -} - -// renderConfirmOverlay creates a confirmation dialog overlay -func (m model) renderConfirmOverlay() (box string, boxWidth, boxHeight int) { - msgStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("252")) - - boxStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("11")). - Padding(1, 2) - - content := msgStyle.Render(m.confirmMessage) - box = boxStyle.Render(content) - 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) - } - - // Overlay command palette if active - if m.cmdPaletteMode { - box, boxWidth, boxHeight := m.renderCmdPaletteOverlay() - return overlayBox(mainView, box, boxWidth, boxHeight, m.width, m.height) - } - - // Overlay confirmation dialog if active - if m.confirmMode { - box, boxWidth, boxHeight := m.renderConfirmOverlay() - 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: strip ANSI so highlight colors aren't mixed with content colors - plainContent := stripANSI(content) - - // 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 := plainContent - padding := fullWidth - lineNumWidth - len(plainContent) - if padding > 0 { - contentPadded = plainContent + 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 { - // Strip ANSI so highlight colors aren't mixed with content colors - lineText = stripANSI(lineText) - // Pad to full width for selection highlight - padding := fullWidth - len(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) - } - // Apply preview scroll offset - previewLines = m.applyPreviewOffset(previewLines, previewH) - // 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) - } - // Apply preview scroll offset - previewLines = m.applyPreviewOffset(previewLines, listHeight) - 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 -} diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go deleted file mode 100755 index 2503492..0000000 --- a/internal/ui/ui_test.go +++ /dev/null @@ -1,860 +0,0 @@ -package ui - -import ( - "context" - "testing" - "time" - - tea "github.com/charmbracelet/bubbletea" - "github.com/chenasraf/watchr/internal/runner" -) - -func TestConfig(t *testing.T) { - cfg := Config{ - Command: "echo test", - Shell: "sh", - PreviewSize: 40, - PreviewSizeIsPercent: true, - PreviewPosition: PreviewBottom, - ShowLineNums: true, - LineNumWidth: 6, - Prompt: "watchr> ", - RefreshInterval: 5 * time.Second, - } - - if cfg.Command != "echo test" { - t.Errorf("expected command 'echo test', got %q", cfg.Command) - } - - if cfg.Shell != "sh" { - t.Errorf("expected shell 'sh', got %q", cfg.Shell) - } - - if cfg.PreviewSize != 40 { - t.Errorf("expected preview size 40, got %d", cfg.PreviewSize) - } - - if !cfg.PreviewSizeIsPercent { - t.Error("expected PreviewSizeIsPercent to be true") - } - - if cfg.PreviewPosition != PreviewBottom { - t.Errorf("expected preview position 'bottom', got %q", cfg.PreviewPosition) - } - - if !cfg.ShowLineNums { - t.Error("expected ShowLineNums to be true") - } - - if cfg.LineNumWidth != 6 { - t.Errorf("expected line num width 6, got %d", cfg.LineNumWidth) - } - - if cfg.Prompt != "watchr> " { - t.Errorf("expected prompt 'watchr> ', got %q", cfg.Prompt) - } - - if cfg.RefreshInterval != 5*time.Second { - t.Errorf("expected refresh interval 5s, got %v", cfg.RefreshInterval) - } -} - -func TestPreviewPositionConstants(t *testing.T) { - tests := []struct { - pos PreviewPosition - want string - }{ - {PreviewBottom, "bottom"}, - {PreviewTop, "top"}, - {PreviewLeft, "left"}, - {PreviewRight, "right"}, - } - - for _, tt := range tests { - if string(tt.pos) != tt.want { - t.Errorf("PreviewPosition %v != %q", tt.pos, tt.want) - } - } -} - -func TestConfigDefaults(t *testing.T) { - // Test with zero values - cfg := Config{} - - if cfg.Command != "" { - t.Errorf("expected empty command, got %q", cfg.Command) - } - - if cfg.Shell != "" { - t.Errorf("expected empty shell, got %q", cfg.Shell) - } - - if cfg.PreviewSize != 0 { - t.Errorf("expected preview size 0, got %d", cfg.PreviewSize) - } - - if cfg.PreviewSizeIsPercent { - t.Error("expected PreviewSizeIsPercent to be false") - } - - if cfg.PreviewPosition != "" { - t.Errorf("expected empty preview position, got %q", cfg.PreviewPosition) - } - - if cfg.ShowLineNums { - t.Error("expected ShowLineNums to be false") - } - - if cfg.LineNumWidth != 0 { - t.Errorf("expected line num width 0, got %d", cfg.LineNumWidth) - } - - if cfg.Prompt != "" { - t.Errorf("expected empty prompt, got %q", cfg.Prompt) - } -} - -func TestInitialModel(t *testing.T) { - cfg := Config{ - Command: "echo test", - Shell: "sh", - PreviewSize: 40, - PreviewSizeIsPercent: true, - PreviewPosition: PreviewBottom, - ShowLineNums: true, - LineNumWidth: 6, - Prompt: "watchr> ", - } - - m := initialModel(cfg) - - if m.config.Command != cfg.Command { - t.Errorf("expected command %q, got %q", cfg.Command, m.config.Command) - } - - if m.cursor != 0 { - t.Errorf("expected cursor at 0, got %d", m.cursor) - } - - if m.offset != 0 { - t.Errorf("expected offset at 0, got %d", m.offset) - } - - if m.filterMode { - t.Error("expected filterMode to be false") - } - - if m.showPreview { - t.Error("expected showPreview to be false") - } - - if !m.loading { - t.Error("expected loading to be true initially") - } -} - -func TestModelUpdateFiltered(t *testing.T) { - cfg := Config{ - Command: "echo test", - Shell: "sh", - } - - m := initialModel(cfg) - - // Add some test lines - m.lines = []runner.Line{ - {Number: 1, Content: "hello world"}, - {Number: 2, Content: "foo bar"}, - {Number: 3, Content: "hello foo"}, - {Number: 4, Content: "baz qux"}, - } - - // Test with no filter - m.filter = "" - m.updateFiltered() - - if len(m.filtered) != 4 { - t.Errorf("expected 4 filtered lines, got %d", len(m.filtered)) - } - - // Test with filter - m.filter = "hello" - m.updateFiltered() - - if len(m.filtered) != 2 { - t.Errorf("expected 2 filtered lines for 'hello', got %d", len(m.filtered)) - } - - // Test case insensitive - m.filter = "HELLO" - m.updateFiltered() - - if len(m.filtered) != 2 { - t.Errorf("expected 2 filtered lines for 'HELLO' (case insensitive), got %d", len(m.filtered)) - } - - // Test no matches - m.filter = "xyz" - m.updateFiltered() - - if len(m.filtered) != 0 { - t.Errorf("expected 0 filtered lines for 'xyz', got %d", len(m.filtered)) - } -} - -func TestModelMoveCursor(t *testing.T) { - cfg := Config{ - Command: "echo test", - Shell: "sh", - } - - m := initialModel(cfg) - m.filtered = []int{0, 1, 2, 3, 4} - m.height = 100 // enough height for all lines - - // Move down - m.moveCursor(1) - if m.cursor != 1 { - t.Errorf("expected cursor at 1, got %d", m.cursor) - } - - // Move down more - m.moveCursor(2) - if m.cursor != 3 { - t.Errorf("expected cursor at 3, got %d", m.cursor) - } - - // Move past end - m.moveCursor(10) - if m.cursor != 4 { - t.Errorf("expected cursor at 4 (clamped), got %d", m.cursor) - } - - // Move up - m.moveCursor(-2) - if m.cursor != 2 { - t.Errorf("expected cursor at 2, got %d", m.cursor) - } - - // Move past beginning - m.moveCursor(-10) - if m.cursor != 0 { - t.Errorf("expected cursor at 0 (clamped), got %d", m.cursor) - } -} - -func TestVisibleLines(t *testing.T) { - cfg := Config{ - Command: "echo test", - Shell: "sh", - PreviewSize: 40, - PreviewSizeIsPercent: true, - PreviewPosition: PreviewBottom, - } - - m := initialModel(cfg) - m.height = 100 - - // Fixed lines: top border (1) + header (1) + separator (1) + bottom border (1) + prompt (1) = 5 - fixedLines := 5 - - // Without preview - m.showPreview = false - visible := m.visibleLines() - expected := 100 - fixedLines - if visible != expected { - t.Errorf("expected %d visible lines without preview, got %d", expected, visible) - } - - // With preview at bottom (percentage) - m.showPreview = true - visible = m.visibleLines() - previewHeight := 100 * 40 / 100 // 40% - // Add 1 for the separator between content and preview - expected = 100 - fixedLines - previewHeight - 1 - if visible != expected { - t.Errorf("expected %d visible lines with preview, got %d", expected, visible) - } - - // With preview using absolute size - m.config.PreviewSizeIsPercent = false - m.config.PreviewSize = 10 - visible = m.visibleLines() - // Add 1 for the separator between content and preview - expected = 100 - fixedLines - 10 - 1 - if visible != expected { - t.Errorf("expected %d visible lines with absolute preview size, got %d", expected, visible) - } -} - -func TestUpdateFilteredPreservesOffset(t *testing.T) { - cfg := Config{ - Command: "echo test", - Shell: "sh", - } - - m := initialModel(cfg) - m.height = 20 // Enough for visibleLines to return > 0 - - // Add many test lines - for i := 1; i <= 100; i++ { - m.lines = append(m.lines, runner.Line{Number: i, Content: "line content"}) - } - - // Set initial state with offset - m.filter = "" - m.updateFiltered() - m.offset = 50 - m.cursor = 55 - - // Simulate streaming update - add more lines without changing filter - m.lines = append(m.lines, runner.Line{Number: 101, Content: "new line"}) - m.updateFiltered() - - // Offset should be preserved (or clamped if necessary) - if m.offset < 50 { - t.Errorf("expected offset to be preserved (>= 50), got %d", m.offset) - } - - // Cursor should be preserved - if m.cursor != 55 { - t.Errorf("expected cursor to be preserved at 55, got %d", m.cursor) - } -} - -func TestUpdateFilteredClampsOffsetWhenNeeded(t *testing.T) { - cfg := Config{ - Command: "echo test", - Shell: "sh", - } - - m := initialModel(cfg) - m.height = 20 - - // Add test lines - for i := 1; i <= 100; i++ { - m.lines = append(m.lines, runner.Line{Number: i, Content: "line content"}) - } - - m.filter = "" - m.updateFiltered() - m.offset = 90 - m.cursor = 95 - - // Now filter to fewer lines - m.filter = "xyz" // No matches - m.updateFiltered() - - // Offset should be clamped to valid range - if m.offset != 0 { - t.Errorf("expected offset to be clamped to 0, got %d", m.offset) - } - - // Cursor should be clamped - if m.cursor != 0 { - t.Errorf("expected cursor to be clamped to 0, got %d", m.cursor) - } -} - -func TestConfigRefreshFromStart(t *testing.T) { - // Test with RefreshFromStart false (default) - cfg := Config{ - Command: "echo test", - Shell: "sh", - RefreshInterval: 5 * time.Second, - RefreshFromStart: false, - } - - if cfg.RefreshFromStart { - t.Error("expected RefreshFromStart to be false by default") - } - - // Test with RefreshFromStart true - cfg.RefreshFromStart = true - if !cfg.RefreshFromStart { - t.Error("expected RefreshFromStart to be true after setting") - } -} - -func TestModelUserScrolled(t *testing.T) { - cfg := Config{ - Command: "echo test", - Shell: "sh", - } - - m := initialModel(cfg) - - // Initially should be false - if m.userScrolled { - t.Error("expected userScrolled to be false initially") - } - - // After setting, should be true - m.userScrolled = true - if !m.userScrolled { - t.Error("expected userScrolled to be true after setting") - } -} - -func TestModelRefreshGeneration(t *testing.T) { - cfg := Config{ - Command: "echo test", - Shell: "sh", - } - - m := initialModel(cfg) - - // Initially should be 0 - if m.refreshGeneration != 0 { - t.Errorf("expected refreshGeneration to be 0 initially, got %d", m.refreshGeneration) - } - - // After incrementing - m.refreshGeneration++ - if m.refreshGeneration != 1 { - t.Errorf("expected refreshGeneration to be 1 after increment, got %d", m.refreshGeneration) - } -} - -func testModel(cfg Config) *model { - m := initialModel(cfg) - return &m -} - -func TestFilterCursorMovement(t *testing.T) { - cfg := Config{Command: "echo test", Shell: "sh"} - m := testModel(cfg) - m.filterMode = true - m.filter = "hello" - m.filterCursor = 5 - - // Left arrow moves cursor left - keyMsg := tea.KeyMsg{Type: tea.KeyLeft} - result, _ := m.handleKeyPress(keyMsg) - m = result.(*model) - if m.filterCursor != 4 { - t.Errorf("expected filterCursor 4 after left, got %d", m.filterCursor) - } - - // Left again - result, _ = m.handleKeyPress(keyMsg) - m = result.(*model) - if m.filterCursor != 3 { - t.Errorf("expected filterCursor 3 after second left, got %d", m.filterCursor) - } - - // Left doesn't go below 0 - m.filterCursor = 0 - result, _ = m.handleKeyPress(keyMsg) - m = result.(*model) - if m.filterCursor != 0 { - t.Errorf("expected filterCursor 0 (clamped), got %d", m.filterCursor) - } - - // Right arrow moves cursor right - m.filterCursor = 2 - keyMsg = tea.KeyMsg{Type: tea.KeyRight} - result, _ = m.handleKeyPress(keyMsg) - m = result.(*model) - if m.filterCursor != 3 { - t.Errorf("expected filterCursor 3 after right, got %d", m.filterCursor) - } - - // Right doesn't go past end - m.filterCursor = 5 - result, _ = m.handleKeyPress(keyMsg) - m = result.(*model) - if m.filterCursor != 5 { - t.Errorf("expected filterCursor 5 (clamped), got %d", m.filterCursor) - } -} - -func TestFilterAltLeftRight(t *testing.T) { - cfg := Config{Command: "echo test", Shell: "sh"} - - t.Run("alt+left jumps to previous word boundary", func(t *testing.T) { - m := testModel(cfg) - m.filterMode = true - m.filter = "foo bar baz" - m.filterCursor = 11 // end - - keyMsg := tea.KeyMsg{Type: tea.KeyLeft, Alt: true} - result, _ := m.handleKeyPress(keyMsg) - m = result.(*model) - if m.filterCursor != 8 { - t.Errorf("expected filterCursor 8, got %d", m.filterCursor) - } - - result, _ = m.handleKeyPress(keyMsg) - m = result.(*model) - if m.filterCursor != 4 { - t.Errorf("expected filterCursor 4, got %d", m.filterCursor) - } - - result, _ = m.handleKeyPress(keyMsg) - m = result.(*model) - if m.filterCursor != 0 { - t.Errorf("expected filterCursor 0, got %d", m.filterCursor) - } - - // Already at start, stays at 0 - result, _ = m.handleKeyPress(keyMsg) - m = result.(*model) - if m.filterCursor != 0 { - t.Errorf("expected filterCursor 0 (clamped), got %d", m.filterCursor) - } - }) - - t.Run("alt+right jumps to next word boundary", func(t *testing.T) { - m := testModel(cfg) - m.filterMode = true - m.filter = "foo bar baz" - m.filterCursor = 0 - - keyMsg := tea.KeyMsg{Type: tea.KeyRight, Alt: true} - result, _ := m.handleKeyPress(keyMsg) - m = result.(*model) - if m.filterCursor != 4 { - t.Errorf("expected filterCursor 4, got %d", m.filterCursor) - } - - result, _ = m.handleKeyPress(keyMsg) - m = result.(*model) - if m.filterCursor != 8 { - t.Errorf("expected filterCursor 8, got %d", m.filterCursor) - } - - result, _ = m.handleKeyPress(keyMsg) - m = result.(*model) - if m.filterCursor != 11 { - t.Errorf("expected filterCursor 11, got %d", m.filterCursor) - } - - // Already at end, stays at 11 - result, _ = m.handleKeyPress(keyMsg) - m = result.(*model) - if m.filterCursor != 11 { - t.Errorf("expected filterCursor 11 (clamped), got %d", m.filterCursor) - } - }) - - t.Run("alt+left skips trailing spaces", func(t *testing.T) { - m := testModel(cfg) - m.filterMode = true - m.filter = "foo bar" - m.filterCursor = 6 // middle of spaces, before "bar" - - keyMsg := tea.KeyMsg{Type: tea.KeyLeft, Alt: true} - result, _ := m.handleKeyPress(keyMsg) - m = result.(*model) - if m.filterCursor != 0 { - t.Errorf("expected filterCursor 0, got %d", m.filterCursor) - } - }) - - t.Run("alt+right skips trailing spaces", func(t *testing.T) { - m := testModel(cfg) - m.filterMode = true - m.filter = "foo bar" - m.filterCursor = 3 // end of "foo" - - keyMsg := tea.KeyMsg{Type: tea.KeyRight, Alt: true} - result, _ := m.handleKeyPress(keyMsg) - m = result.(*model) - if m.filterCursor != 6 { - t.Errorf("expected filterCursor 6, got %d", m.filterCursor) - } - }) -} - -func TestFilterInsertAtCursor(t *testing.T) { - cfg := Config{Command: "echo test", Shell: "sh"} - m := testModel(cfg) - m.filterMode = true - m.filter = "helo" - m.filterCursor = 3 - - // Insert 'l' at position 3 -> "hello" - keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}} - result, _ := m.handleKeyPress(keyMsg) - m = result.(*model) - if m.filter != "hello" { - t.Errorf("expected filter 'hello', got %q", m.filter) - } - if m.filterCursor != 4 { - t.Errorf("expected filterCursor 4, got %d", m.filterCursor) - } -} - -func TestFilterBackspaceAtCursor(t *testing.T) { - cfg := Config{Command: "echo test", Shell: "sh"} - m := testModel(cfg) - m.filterMode = true - m.filter = "hello" - m.filterCursor = 3 - - // Backspace at position 3 -> "helo" - keyMsg := tea.KeyMsg{Type: tea.KeyBackspace} - result, _ := m.handleKeyPress(keyMsg) - m = result.(*model) - if m.filter != "helo" { - t.Errorf("expected filter 'helo', got %q", m.filter) - } - if m.filterCursor != 2 { - t.Errorf("expected filterCursor 2, got %d", m.filterCursor) - } - - // Backspace at position 0 does nothing - m.filterCursor = 0 - result, _ = m.handleKeyPress(keyMsg) - m = result.(*model) - if m.filter != "helo" { - t.Errorf("expected filter 'helo' (unchanged), got %q", m.filter) - } - if m.filterCursor != 0 { - t.Errorf("expected filterCursor 0, got %d", m.filterCursor) - } -} - -func TestFilterAltBackspace(t *testing.T) { - cfg := Config{Command: "echo test", Shell: "sh"} - - tests := []struct { - name string - filter string - cursor int - expectedFilter string - expectedCursor int - }{ - {"delete last word", "hello world", 11, "hello ", 6}, - {"delete middle word", "foo bar baz", 7, "foo baz", 4}, - {"delete first word", "hello world", 5, " world", 0}, - {"delete with trailing spaces", "hello ", 8, "", 0}, - {"cursor at start", "hello", 0, "hello", 0}, - {"single word", "hello", 5, "", 0}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m := testModel(cfg) - m.filterMode = true - m.filter = tt.filter - m.filterCursor = tt.cursor - - keyMsg := tea.KeyMsg{Type: tea.KeyBackspace, Alt: true} - result, _ := m.handleKeyPress(keyMsg) - newModel := result.(*model) - - if newModel.filter != tt.expectedFilter { - t.Errorf("expected filter %q, got %q", tt.expectedFilter, newModel.filter) - } - if newModel.filterCursor != tt.expectedCursor { - t.Errorf("expected filterCursor %d, got %d", tt.expectedCursor, newModel.filterCursor) - } - }) - } -} - -func TestFilterRegexToggle(t *testing.T) { - cfg := Config{Command: "echo test", Shell: "sh"} - m := testModel(cfg) - m.filterMode = true - m.filter = "" - m.filterCursor = 0 - - // Type '/' on empty filter toggles regex mode on - keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}} - result, _ := m.handleKeyPress(keyMsg) - m = result.(*model) - if !m.filterRegex { - t.Error("expected filterRegex to be true after typing /") - } - if m.filter != "" { - t.Errorf("expected empty filter, got %q", m.filter) - } - - // Type '/' again on empty filter toggles regex mode off - result, _ = m.handleKeyPress(keyMsg) - m = result.(*model) - if m.filterRegex { - t.Error("expected filterRegex to be false after second /") - } - - // Type '/' when filter is non-empty adds it to filter - m.filterRegex = true - m.filter = "abc" - m.filterCursor = 3 - result, _ = m.handleKeyPress(keyMsg) - m = result.(*model) - if m.filter != "abc/" { - t.Errorf("expected filter 'abc/', got %q", m.filter) - } - if !m.filterRegex { - t.Error("expected filterRegex to remain true") - } -} - -func TestFilterRegexMatching(t *testing.T) { - cfg := Config{Command: "echo test", Shell: "sh"} - m := initialModel(cfg) - m.lines = []runner.Line{ - {Number: 1, Content: "hello world"}, - {Number: 2, Content: "foo bar"}, - {Number: 3, Content: "hello foo"}, - {Number: 4, Content: "baz 123 qux"}, - } - - // Regex filter matching - m.filterRegex = true - m.filter = "hello.*foo" - m.updateFiltered() - if len(m.filtered) != 1 { - t.Errorf("expected 1 match for regex 'hello.*foo', got %d", len(m.filtered)) - } - if len(m.filtered) > 0 && m.filtered[0] != 2 { - t.Errorf("expected match at index 2, got %d", m.filtered[0]) - } - - // Regex with character class - m.filter = "\\d+" - m.updateFiltered() - if len(m.filtered) != 1 { - t.Errorf("expected 1 match for regex '\\d+', got %d", len(m.filtered)) - } - - // Regex is case insensitive - m.filter = "HELLO" - m.updateFiltered() - if len(m.filtered) != 2 { - t.Errorf("expected 2 matches for case-insensitive regex 'HELLO', got %d", len(m.filtered)) - } -} - -func TestFilterRegexInvalid(t *testing.T) { - cfg := Config{Command: "echo test", Shell: "sh"} - m := initialModel(cfg) - m.lines = []runner.Line{ - {Number: 1, Content: "hello world"}, - {Number: 2, Content: "foo bar"}, - } - - m.filterRegex = true - m.filter = "[invalid" - m.updateFiltered() - - // Should have an error - if m.filterRegexErr == nil { - t.Error("expected filterRegexErr to be non-nil for invalid regex") - } - - // Should show all lines when regex is invalid - if len(m.filtered) != 2 { - t.Errorf("expected all 2 lines shown for invalid regex, got %d", len(m.filtered)) - } - - // Valid regex clears the error - m.filter = "hello" - m.updateFiltered() - if m.filterRegexErr != nil { - t.Errorf("expected filterRegexErr to be nil for valid regex, got %v", m.filterRegexErr) - } -} - -func TestFilterEscClearsRegex(t *testing.T) { - cfg := Config{Command: "echo test", Shell: "sh"} - m := testModel(cfg) - m.filterMode = true - m.filter = "test" - m.filterCursor = 4 - m.filterRegex = true - - // Esc in filter mode clears everything - keyMsg := tea.KeyMsg{Type: tea.KeyEsc} - result, _ := m.handleKeyPress(keyMsg) - m = result.(*model) - - if m.filterMode { - t.Error("expected filterMode to be false") - } - if m.filter != "" { - t.Errorf("expected empty filter, got %q", m.filter) - } - if m.filterCursor != 0 { - t.Errorf("expected filterCursor 0, got %d", m.filterCursor) - } - if m.filterRegex { - t.Error("expected filterRegex to be false") - } -} - -func TestStopCommandKeybinding(t *testing.T) { - cfg := Config{ - Command: "echo test", - Shell: "sh", - } - - t.Run("stops running command when streaming", func(t *testing.T) { - m := initialModel(cfg) - // Set up a cancellable context to track if cancel was called - ctx, cancel := context.WithCancel(context.Background()) - m.ctx = ctx - m.cancel = cancel - m.streaming = true - m.statusMsg = "" - - // Simulate pressing 'c' - keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}} - result, cmd := m.handleKeyPress(keyMsg) - newModel := result.(*model) - - // Should set status message - if newModel.statusMsg != "Command stopped" { - t.Errorf("expected statusMsg 'Command stopped', got %q", newModel.statusMsg) - } - - // Should return a command (the tick for clearing status) - if cmd == nil { - t.Error("expected a command to be returned for status message timeout") - } - - // Context should be cancelled - select { - case <-ctx.Done(): - // Good, context was cancelled - default: - t.Error("expected context to be cancelled") - } - }) - - t.Run("does nothing when not streaming", func(t *testing.T) { - m := initialModel(cfg) - ctx, cancel := context.WithCancel(context.Background()) - m.ctx = ctx - m.cancel = cancel - m.streaming = false - m.statusMsg = "" - - // Simulate pressing 'c' - keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}} - result, cmd := m.handleKeyPress(keyMsg) - newModel := result.(*model) - - // Should not set status message - if newModel.statusMsg != "" { - t.Errorf("expected empty statusMsg, got %q", newModel.statusMsg) - } - - // Should not return a command - if cmd != nil { - t.Error("expected no command to be returned when not streaming") - } - - // Context should NOT be cancelled - select { - case <-ctx.Done(): - t.Error("expected context to NOT be cancelled when not streaming") - default: - // Good, context is still active - } - }) -} diff --git a/internal/ui/update.go b/internal/ui/update.go new file mode 100644 index 0000000..601eb49 --- /dev/null +++ b/internal/ui/update.go @@ -0,0 +1,223 @@ +package ui + +import ( + "context" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/chenasraf/watchr/internal/runner" +) + +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} + }) +} diff --git a/internal/ui/update_test.go b/internal/ui/update_test.go new file mode 100644 index 0000000..f68ee85 --- /dev/null +++ b/internal/ui/update_test.go @@ -0,0 +1,87 @@ +package ui + +import ( + "fmt" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func TestUpdateWindowSize(t *testing.T) { + m := testModelWithLines() + msg := tea.WindowSizeMsg{Width: 120, Height: 40} + result, _ := m.Update(msg) + newModel := result.(*model) + + if newModel.width != 120 { + t.Errorf("expected width 120, got %d", newModel.width) + } + if newModel.height != 40 { + t.Errorf("expected height 40, got %d", newModel.height) + } +} + +func TestUpdateClearStatusMsg(t *testing.T) { + m := testModelWithLines() + m.statusMsg = "some status" + + result, _ := m.Update(clearStatusMsg{}) + newModel := result.(*model) + + if newModel.statusMsg != "" { + t.Errorf("expected empty statusMsg, got %q", newModel.statusMsg) + } +} + +func TestUpdateSpinnerTick(t *testing.T) { + m := testModelWithLines() + m.loading = true + m.spinnerFrame = 0 + + result, cmd := m.Update(spinnerTickMsg{}) + newModel := result.(*model) + + if newModel.spinnerFrame != 1 { + t.Errorf("expected spinnerFrame 1, got %d", newModel.spinnerFrame) + } + if cmd == nil { + t.Error("expected a command for next spinner tick") + } +} + +func TestUpdateSpinnerTickNotLoading(t *testing.T) { + m := testModelWithLines() + m.loading = false + m.streaming = false + m.spinnerFrame = 3 + + result, cmd := m.Update(spinnerTickMsg{}) + newModel := result.(*model) + + // Frame should not advance + if newModel.spinnerFrame != 3 { + t.Errorf("expected spinnerFrame 3 (unchanged), got %d", newModel.spinnerFrame) + } + if cmd != nil { + t.Error("expected no command when not loading/streaming") + } +} + +func TestUpdateErrMsg(t *testing.T) { + m := testModelWithLines() + m.loading = true + m.streaming = true + + result, _ := m.Update(errMsg{err: fmt.Errorf("test error")}) + newModel := result.(*model) + + if newModel.errorMsg != "test error" { + t.Errorf("expected errorMsg 'test error', got %q", newModel.errorMsg) + } + if newModel.loading { + t.Error("expected loading false after error") + } + if newModel.streaming { + t.Error("expected streaming false after error") + } +} diff --git a/internal/ui/view.go b/internal/ui/view.go new file mode 100644 index 0000000..455d24d --- /dev/null +++ b/internal/ui/view.go @@ -0,0 +1,585 @@ +package ui + +import ( + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// renderCmdPaletteOverlay creates the command palette overlay box +func (m model) renderCmdPaletteOverlay() (box string, boxWidth, boxHeight int) { + keyStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")) // dim + + nameStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")) + + selectedNameStyle := lipgloss.NewStyle(). + Background(lipgloss.Color("15")). + Foreground(lipgloss.Color("#000000")). + Bold(true) + + selectedKeyStyle := lipgloss.NewStyle(). + Background(lipgloss.Color("15")). + Foreground(lipgloss.Color("241")) + + filterStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("11")) + + borderColor := lipgloss.Color("12") + + allCommands := commands() + filtered := m.filteredCommands() + + // Compute column width + const paletteWidth = 40 + totalSlots := len(allCommands) // fixed height so box doesn't move + + var content strings.Builder + + // Filter input with bottom border + before := m.cmdPaletteFilter[:m.cmdPaletteCursor] + after := m.cmdPaletteFilter[m.cmdPaletteCursor:] + filterLine := filterStyle.Render(":"+before) + "█" + filterStyle.Render(after) + // Pad filter line to full width + filterVisual := lipgloss.Width(filterLine) + if filterVisual < paletteWidth { + filterLine += strings.Repeat(" ", paletteWidth-filterVisual) + } + content.WriteString(filterLine + "\n") + content.WriteString(lipgloss.NewStyle().Foreground(borderColor).Render(strings.Repeat("─", paletteWidth)) + "\n") + + // Command list (fixed number of rows) + for i := range totalSlots { + if i < len(filtered) { + cmd := filtered[i] + gap := max(paletteWidth-lipgloss.Width(cmd.name)-lipgloss.Width(cmd.shortcut), 2) + if i == m.cmdPaletteSelected { + line := selectedNameStyle.Render(cmd.name+strings.Repeat(" ", gap)) + selectedKeyStyle.Render(cmd.shortcut) + content.WriteString(line + "\n") + } else { + content.WriteString(nameStyle.Render(cmd.name) + strings.Repeat(" ", gap) + keyStyle.Render(cmd.shortcut) + "\n") + } + } else { + content.WriteString(strings.Repeat(" ", paletteWidth) + "\n") + } + } + + boxStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(borderColor) + + box = boxStyle.Render(content.String()) + boxWidth = lipgloss.Width(box) + boxHeight = lipgloss.Height(box) + + return box, boxWidth, boxHeight +} + +// 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"}, + {"+/-", "Resize preview pane"}, + {"J / K", "Scroll preview down / up"}, + {"/", "Enter filter mode"}, + {"//", "Toggle regex filter mode"}, + {"Esc", "Exit filter / clear"}, + {"", ""}, + {"r / Ctrl+r", "Reload command"}, + {"R", "Reload & clear lines"}, + {"d / Del", "Delete selected line"}, + {"D", "Clear all lines"}, + {"c", "Stop running command"}, + {"y", "Copy line to clipboard"}, + {"Y", "Copy line (plain text)"}, + {":", "Open command palette"}, + {"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) + fmt.Fprintf(&content, " %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 +} + +// renderConfirmOverlay creates a confirmation dialog overlay +func (m model) renderConfirmOverlay() (box string, boxWidth, boxHeight int) { + msgStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")) + + boxStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("11")). + Padding(1, 2) + + content := msgStyle.Render(m.confirmMessage) + box = boxStyle.Render(content) + boxWidth = lipgloss.Width(box) + boxHeight = lipgloss.Height(box) + + return box, boxWidth, boxHeight +} + +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) + } + + // Overlay command palette if active + if m.cmdPaletteMode { + box, boxWidth, boxHeight := m.renderCmdPaletteOverlay() + return overlayBox(mainView, box, boxWidth, boxHeight, m.width, m.height) + } + + // Overlay confirmation dialog if active + if m.confirmMode { + box, boxWidth, boxHeight := m.renderConfirmOverlay() + return overlayBox(mainView, box, boxWidth, boxHeight, m.width, m.height) + } + + return mainView +} + +// Box drawing characters (rounded) +const ( + boxTopLeft = "╭" + boxTopRight = "╮" + boxBottomLeft = "╰" + boxBottomRight = "╯" + boxHorizontal = "─" + boxVertical = "│" + boxLeftT = "├" + boxRightT = "┤" + boxTopT = "┬" + boxBottomT = "┴" +) + +// viewContext holds shared rendering state for a single View() call. +type viewContext struct { + innerWidth int + borderStyle lipgloss.Style +} + +func (vc viewContext) hLine(left, right string, splitPos int, junction string) string { + if splitPos > 0 && splitPos < vc.innerWidth { + return vc.borderStyle.Render(left + strings.Repeat(boxHorizontal, splitPos) + junction + strings.Repeat(boxHorizontal, vc.innerWidth-splitPos-1) + right) + } + return vc.borderStyle.Render(left + strings.Repeat(boxHorizontal, vc.innerWidth) + right) +} + +func (vc viewContext) padLine(content string) string { + contentWidth := lipgloss.Width(content) + if contentWidth < vc.innerWidth { + content += strings.Repeat(" ", vc.innerWidth-contentWidth) + } else if contentWidth > vc.innerWidth { + content = lipgloss.NewStyle().MaxWidth(vc.innerWidth-1).Render(content) + ellipsis + } + return vc.borderStyle.Render(boxVertical) + content + vc.borderStyle.Render(boxVertical) +} + +func (m model) renderMainView() string { + borderColor := lipgloss.Color("240") + vc := viewContext{ + innerWidth: m.width - 2, + borderStyle: lipgloss.NewStyle().Foreground(borderColor), + } + + commandLine := m.renderHeaderLine(vc.innerWidth) + promptLine := m.renderPromptLine() + listHeight, listWidth := m.listDimensions(vc.innerWidth) + listLines := m.renderListLines(listHeight, listWidth) + + // 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)) + } + + // Vertical split position for left/right preview + var vSplitPos int + if m.showPreview { + switch m.config.PreviewPosition { + case PreviewLeft: + vSplitPos = m.previewSize() + case PreviewRight: + vSplitPos = vc.innerWidth - m.previewSize() - 1 + } + } + + // Build the unified box + var lines []string + lines = append(lines, vc.hLine(boxTopLeft, boxTopRight, 0, boxTopT)) + lines = append(lines, vc.padLine(commandLine)) + lines = append(lines, vc.hLine(boxLeftT, boxRightT, vSplitPos, boxTopT)) + + // Content area + if !m.showPreview { + lines = append(lines, m.renderContentNoPreview(vc, listLines, listHeight)...) + } else { + lines = append(lines, m.renderContentWithPreview(vc, listLines, listHeight, previewContent)...) + } + + lines = append(lines, vc.hLine(boxBottomLeft, boxBottomRight, vSplitPos, boxBottomT)) + + return strings.Join(lines, "\n") + "\n" + promptLine +} + +func (m model) renderHeaderLine(innerWidth int) string { + titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("12")).Bold(true) + prefix := titleStyle.Render("watchr") + " • " + + var commandLine string + switch { + case m.streaming: + streamStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("14")) + commandLine = prefix + streamStyle.Render("◉ "+m.config.Command) + case m.loading: + commandLine = prefix + m.config.Command + case m.exitCode == 0: + successStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("10")) + commandLine = prefix + successStyle.Render("✓ "+m.config.Command) + default: + failStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9")) + commandLine = prefix + failStyle.Render(fmt.Sprintf("✗ [%d] %s", m.exitCode, m.config.Command)) + } + + 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")) + 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 + } + } + } + + return commandLine +} + +func (m model) renderPromptLine() string { + promptStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("14")) + filterStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("11")) + filterRegexStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("13")) + filterErrStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9")) + + 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")) + promptLine += " " + statusStyle.Render(m.statusMsg) + } + + 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 + } + + return promptLine +} + +func (m model) listDimensions(innerWidth int) (height, width int) { + height = m.visibleLines() + width = innerWidth - 1 + if m.showPreview && (m.config.PreviewPosition == PreviewLeft || m.config.PreviewPosition == PreviewRight) { + width = innerWidth - m.previewSize() - 2 + } + return height, width +} + +func (m model) renderListLines(listHeight, listWidth int) []string { + selectedStyle := lipgloss.NewStyle(). + Background(lipgloss.Color("15")). + Foreground(lipgloss.Color("#000000")). + Bold(true) + lineNumStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + + var listLines []string + for i := range listHeight { + lineIdx := m.offset + i + if lineIdx >= len(m.filtered) { + listLines = append(listLines, "") + continue + } + + idx := m.filtered[lineIdx] + if idx >= len(m.lines) { + listLines = append(listLines, "") + continue + } + line := m.lines[idx] + isSelected := lineIdx == m.cursor + fullWidth := listWidth + 1 + + var lineText string + if m.config.ShowLineNums { + lineNumStr := fmt.Sprintf("%*d ", m.config.LineNumWidth, line.Number) + lineNumWidth := len(lineNumStr) + contentWidth := listWidth - lineNumWidth + content := truncateToWidth(line.Content, contentWidth) + + if isSelected { + plainContent := stripANSI(content) + selectedLineNumStyle := lipgloss.NewStyle(). + Background(lipgloss.Color("15")). + Foreground(lipgloss.Color("241")) + selectedContentStyle := lipgloss.NewStyle(). + Background(lipgloss.Color("15")). + Foreground(lipgloss.Color("#000000")). + Bold(true) + contentPadded := plainContent + padding := fullWidth - lineNumWidth - len(plainContent) + if padding > 0 { + contentPadded = plainContent + strings.Repeat(" ", padding) + } + lineText = selectedLineNumStyle.Render(lineNumStr) + selectedContentStyle.Render(contentPadded) + } else { + lineText = lineNumStyle.Render(lineNumStr) + content + } + } else { + lineText = truncateToWidth(line.Content, listWidth) + if isSelected { + lineText = stripANSI(lineText) + padding := fullWidth - len(lineText) + if padding > 0 { + lineText += strings.Repeat(" ", padding) + } + lineText = selectedStyle.Render(lineText) + } + } + + listLines = append(listLines, lineText) + } + return listLines +} + +func (m model) renderContentNoPreview(vc viewContext, listLines []string, listHeight int) []string { + var lines []string + for i := range listHeight { + if i < len(listLines) { + lines = append(lines, vc.padLine(listLines[i])) + } else { + lines = append(lines, vc.padLine("")) + } + } + return lines +} + +func (m model) renderContentWithPreview(vc viewContext, listLines []string, listHeight int, previewContent string) []string { + switch m.config.PreviewPosition { + case PreviewTop, PreviewBottom: + return m.renderVerticalPreview(vc, listLines, listHeight, previewContent) + case PreviewLeft, PreviewRight: + return m.renderHorizontalPreview(vc, listLines, listHeight, previewContent) + } + return nil +} + +func (m *model) renderVerticalPreview(vc viewContext, listLines []string, listHeight int, previewContent string) []string { + previewH := m.previewSize() + + var previewLines []string + if previewContent != "" { + previewLines = wrapPreviewContent(previewContent, vc.innerWidth) + } + previewLines = m.applyPreviewOffset(previewLines, previewH) + for len(previewLines) < previewH { + previewLines = append(previewLines, "") + } + + paddedList := m.renderContentNoPreview(vc, listLines, listHeight) + var paddedPreview []string + for _, line := range previewLines[:previewH] { + paddedPreview = append(paddedPreview, vc.padLine(line)) + } + + separator := vc.hLine(boxLeftT, boxRightT, 0, boxTopT) + + if m.config.PreviewPosition == PreviewTop { + result := paddedPreview + result = append(result, separator) + result = append(result, paddedList...) + return result + } + // PreviewBottom + result := paddedList + result = append(result, separator) + result = append(result, paddedPreview...) + return result +} + +func (m *model) renderHorizontalPreview(vc viewContext, listLines []string, listHeight int, previewContent string) []string { + var leftW, rightW int + if m.config.PreviewPosition == PreviewLeft { + leftW = m.previewSize() + rightW = vc.innerWidth - leftW - 1 + } else { + rightW = m.previewSize() + leftW = vc.innerWidth - rightW - 1 + } + + var previewLines []string + if previewContent != "" { + previewW := leftW + if m.config.PreviewPosition == PreviewRight { + previewW = rightW + } + previewLines = wrapPreviewContent(previewContent, previewW) + } + previewLines = m.applyPreviewOffset(previewLines, listHeight) + for len(previewLines) < listHeight { + previewLines = append(previewLines, "") + } + + fitToWidth := func(s string, w int, isPreview bool) string { + sw := lipgloss.Width(s) + if sw > w { + if isPreview { + return s + strings.Repeat(" ", w-sw) + } + return lipgloss.NewStyle().MaxWidth(w-1).Render(s) + ellipsis + } + return s + strings.Repeat(" ", w-sw) + } + + var lines []string + 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 := vc.borderStyle.Render(boxVertical) + leftContent + vc.borderStyle.Render(boxVertical) + rightContent + vc.borderStyle.Render(boxVertical) + lines = append(lines, line) + } + return lines +} + +// 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 +} diff --git a/internal/ui/view_test.go b/internal/ui/view_test.go new file mode 100644 index 0000000..398d3d0 --- /dev/null +++ b/internal/ui/view_test.go @@ -0,0 +1,104 @@ +package ui + +import ( + "strings" + "testing" +) + +func TestRenderHelpOverlay(t *testing.T) { + m := testModelWithLines() + box, boxWidth, boxHeight := m.renderHelpOverlay() + + if boxWidth == 0 || boxHeight == 0 { + t.Error("expected non-zero overlay dimensions") + } + + // Should contain some keybinding text + if !strings.Contains(box, "Keybindings") { + t.Error("expected help overlay to contain 'Keybindings'") + } + if !strings.Contains(box, "Reload command") { + t.Error("expected help overlay to contain 'Reload command'") + } +} + +func TestRenderConfirmOverlay(t *testing.T) { + m := testModelWithLines() + m.confirmMessage = "Delete everything?" + + box, boxWidth, boxHeight := m.renderConfirmOverlay() + + if boxWidth == 0 || boxHeight == 0 { + t.Error("expected non-zero overlay dimensions") + } + + if !strings.Contains(box, "Delete everything?") { + t.Error("expected confirm overlay to contain the message") + } +} + +func TestRenderCmdPaletteOverlay(t *testing.T) { + m := testModelWithLines() + m.cmdPaletteMode = true + m.cmdPaletteFilter = "" + m.cmdPaletteCursor = 0 + m.cmdPaletteSelected = 0 + + box, boxWidth, boxHeight := m.renderCmdPaletteOverlay() + + if boxWidth == 0 || boxHeight == 0 { + t.Error("expected non-zero overlay dimensions") + } + + // Should contain command names + if !strings.Contains(box, "Reload command") { + t.Error("expected palette to contain 'Reload command'") + } + if !strings.Contains(box, "Quit") { + t.Error("expected palette to contain 'Quit'") + } +} + +func TestViewInitialLoading(t *testing.T) { + m := testModelWithLines() + m.width = 0 + m.height = 0 + + view := m.View() + if !strings.Contains(view, "Running command") { + t.Errorf("expected loading view, got %q", view) + } +} + +func TestViewWithHelpOverlay(t *testing.T) { + m := testModelWithLines() + m.showHelp = true + + view := m.View() + if !strings.Contains(view, "Keybindings") { + t.Error("expected help overlay in view") + } +} + +func TestViewWithConfirmOverlay(t *testing.T) { + m := testModelWithLines() + m.confirmMode = true + m.confirmMessage = "Are you sure?" + + view := m.View() + if !strings.Contains(view, "Are you sure?") { + t.Error("expected confirm overlay in view") + } +} + +func TestViewWithCmdPalette(t *testing.T) { + m := testModelWithLines() + m.cmdPaletteMode = true + m.cmdPaletteFilter = "" + m.cmdPaletteCursor = 0 + + view := m.View() + if !strings.Contains(view, "Reload command") { + t.Error("expected command palette in view") + } +} diff --git a/main.go b/main.go index 36af542..c644725 100755 --- a/main.go +++ b/main.go @@ -48,8 +48,8 @@ func main() { _, _ = fmt.Fprintf(w, "\nKeybindings:\n") _, _ = fmt.Fprintf(w, " r, Ctrl-r Reload (re-run command)\n") _, _ = fmt.Fprintf(w, " R Reload & clear all lines\n") - _, _ = fmt.Fprintf(w, " Del Delete selected line\n") - _, _ = fmt.Fprintf(w, " Ctrl-Del Clear all lines (with confirm)\n") + _, _ = fmt.Fprintf(w, " d, Del Delete selected line\n") + _, _ = fmt.Fprintf(w, " D Clear all lines\n") _, _ = fmt.Fprintf(w, " c Stop running command\n") _, _ = fmt.Fprintf(w, " q, Esc Quit\n") _, _ = fmt.Fprintf(w, " j, k Move down/up\n")