diff --git a/cmd/roborev/daemon_lifecycle.go b/cmd/roborev/daemon_lifecycle.go index c7afe16a..8053101c 100644 --- a/cmd/roborev/daemon_lifecycle.go +++ b/cmd/roborev/daemon_lifecycle.go @@ -122,9 +122,12 @@ func registerRepo(repoPath string) error { return nil } -// ensureDaemon checks if daemon is running, starts it if not -// If daemon is running but has different version, restart it +// ensureDaemon checks if daemon is running, starts it if not. +// If daemon is running but has different version, restart it. +// Set ROBOREV_SKIP_VERSION_CHECK=1 to accept any daemon version without +// restarting (useful for development with go run). func ensureDaemon() error { + skipVersionCheck := os.Getenv("ROBOREV_SKIP_VERSION_CHECK") == "1" client := &http.Client{Timeout: 500 * time.Millisecond} // First check runtime files for any running daemon @@ -140,10 +143,17 @@ func ensureDaemon() error { } decodeErr := json.NewDecoder(resp.Body).Decode(&status) - // Fail closed: restart if decode fails, version empty, or mismatch - if decodeErr != nil || status.Version == "" || status.Version != version.Version { + // Always fail on decode errors (response is not a valid daemon) + if decodeErr != nil { if verbose { - fmt.Printf("Daemon version mismatch or unreadable (daemon: %s, cli: %s), restarting...\n", status.Version, version.Version) + fmt.Printf("Daemon response unreadable, restarting...\n") + } + return restartDaemon() + } + // Skip version mismatch check when env var is set + if !skipVersionCheck && (status.Version == "" || status.Version != version.Version) { + if verbose { + fmt.Printf("Daemon version mismatch (daemon: %s, cli: %s), restarting...\n", status.Version, version.Version) } return restartDaemon() } @@ -162,10 +172,17 @@ func ensureDaemon() error { } decodeErr := json.NewDecoder(resp.Body).Decode(&status) - // Fail closed: restart if decode fails, version empty, or mismatch - if decodeErr != nil || status.Version == "" || status.Version != version.Version { + // Always fail on decode errors (response is not a valid daemon) + if decodeErr != nil { + if verbose { + fmt.Printf("Daemon response unreadable, restarting...\n") + } + return restartDaemon() + } + // Skip version mismatch check when env var is set + if !skipVersionCheck && (status.Version == "" || status.Version != version.Version) { if verbose { - fmt.Printf("Daemon version mismatch or unreadable (daemon: %s, cli: %s), restarting...\n", status.Version, version.Version) + fmt.Printf("Daemon version mismatch (daemon: %s, cli: %s), restarting...\n", status.Version, version.Version) } return restartDaemon() } diff --git a/cmd/roborev/tui/handlers.go b/cmd/roborev/tui/handlers.go index ff0cd12c..ed83fef5 100644 --- a/cmd/roborev/tui/handlers.go +++ b/cmd/roborev/tui/handlers.go @@ -121,6 +121,8 @@ func (m model) handleGlobalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleFixKey() case "T": return m.handleToggleTasksKey() + case "D": + return m.handleDistractionFreeKey() case "tab": return m.handleTabKey() } diff --git a/cmd/roborev/tui/handlers_queue.go b/cmd/roborev/tui/handlers_queue.go index 647b509b..a613386c 100644 --- a/cmd/roborev/tui/handlers_queue.go +++ b/cmd/roborev/tui/handlers_queue.go @@ -26,7 +26,11 @@ func (m *model) handleQueueMouseClick(_ int, y int) { start = max(end-visibleRows, 0) } } - row := y - 5 // rows start after title, status, update, header, separator + headerRows := 5 // title, status, update, header, separator + if m.queueCompact() { + headerRows = 1 // title only + } + row := y - headerRows if row < 0 || row >= visibleRows { return } @@ -75,6 +79,14 @@ func (m *model) handleTasksMouseClick(y int) { m.fixSelectedIdx = idx } +func (m model) handleDistractionFreeKey() (tea.Model, tea.Cmd) { + if m.currentView != viewQueue { + return m, nil + } + m.distractionFree = !m.distractionFree + return m, nil +} + func (m model) handleEnterKey() (tea.Model, tea.Cmd) { job, ok := m.selectedJob() if m.currentView != viewQueue || !ok { diff --git a/cmd/roborev/tui/queue_test.go b/cmd/roborev/tui/queue_test.go index d8c89413..25cfff5b 100644 --- a/cmd/roborev/tui/queue_test.go +++ b/cmd/roborev/tui/queue_test.go @@ -169,7 +169,7 @@ func TestTUIQueueMouseClickSelectsVisibleRow(t *testing.T) { m := newTuiModel("http://localhost") m.currentView = tuiViewQueue m.width = 120 - m.height = 14 + m.height = 20 m.jobs = []storage.ReviewJob{ makeJob(1), makeJob(2), @@ -195,7 +195,7 @@ func TestTUIQueueMouseHeaderClickDoesNotSort(t *testing.T) { m := newTuiModel("http://localhost") m.currentView = tuiViewQueue m.width = 120 - m.height = 14 + m.height = 20 m.jobs = []storage.ReviewJob{ makeJob(3), makeJob(1), @@ -259,7 +259,7 @@ func TestTUIQueueMouseClickScrolledWindow(t *testing.T) { m := newTuiModel("http://localhost") m.currentView = tuiViewQueue m.width = 120 - m.height = 12 // small terminal + m.height = 16 // small but not compact (compact hides headers, changing click offsets) // Create more jobs than fit on screen. for i := range 20 { @@ -307,6 +307,94 @@ func TestTUIQueueMouseClickScrolledWindow(t *testing.T) { } } +func TestTUIQueueCompactMode(t *testing.T) { + m := newTuiModel("http://localhost") + m.currentView = tuiViewQueue + m.width = 80 + m.height = 10 // compact mode (< 15) + + for i := range 5 { + m.jobs = append(m.jobs, makeJob(int64(i+1))) + } + m.selectedIdx = 0 + m.selectedJobID = 1 + + output := m.View() + + // Should have the title line + if !strings.Contains(output, "roborev queue") { + t.Error("compact mode should show title") + } + // Should NOT have the table header + if strings.Contains(output, "JobID") { + t.Error("compact mode should hide table header") + } + // Should NOT have help footer keys + if strings.Contains(output, "navigate") { + t.Error("compact mode should hide help footer") + } + // Should NOT have daemon status line + if strings.Contains(output, "Daemon:") { + t.Error("compact mode should hide status line") + } + + // Mouse click at y=1 (first data row in compact mode) should select first job + m.selectedIdx = 2 + m.selectedJobID = 3 + m2, _ := updateModel(t, m, mouseLeftClick(4, 1)) + if m2.selectedJobID != 1 { + t.Errorf("compact mouse click at y=1: expected job 1, got %d", m2.selectedJobID) + } +} + +func TestTUIQueueDistractionFreeToggle(t *testing.T) { + m := newTuiModel("http://localhost") + m.currentView = tuiViewQueue + m.width = 120 + m.height = 30 // tall enough for normal mode + + for i := range 5 { + m.jobs = append(m.jobs, makeJob(int64(i+1))) + } + m.selectedIdx = 0 + m.selectedJobID = 1 + + // Normal mode: should show chrome + output := m.View() + if !strings.Contains(output, "JobID") { + t.Error("normal mode should show table header") + } + if !strings.Contains(output, "navigate") { + t.Error("normal mode should show help footer") + } + + // Toggle distraction-free with 'D' + m2, _ := updateModel(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'D'}}) + if !m2.distractionFree { + t.Fatal("D should toggle distraction-free on") + } + output = m2.View() + if strings.Contains(output, "JobID") { + t.Error("distraction-free should hide table header") + } + if strings.Contains(output, "navigate") { + t.Error("distraction-free should hide help footer") + } + if strings.Contains(output, "Daemon:") { + t.Error("distraction-free should hide status line") + } + + // Toggle back off + m3, _ := updateModel(t, m2, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'D'}}) + if m3.distractionFree { + t.Fatal("D should toggle distraction-free off") + } + output = m3.View() + if !strings.Contains(output, "JobID") { + t.Error("should show table header after toggling off") + } +} + func TestTUITasksMouseClickSelectsRow(t *testing.T) { m := newTuiModel("http://localhost") m.currentView = tuiViewTasks diff --git a/cmd/roborev/tui/render_log.go b/cmd/roborev/tui/render_log.go index cf41bf7f..150ab671 100644 --- a/cmd/roborev/tui/render_log.go +++ b/cmd/roborev/tui/render_log.go @@ -131,6 +131,7 @@ func helpLines() []string { {"r", "Re-run completed/failed job"}, {"F", "Trigger fix for selected review"}, {"T", "Open Tasks view"}, + {"D", "Toggle distraction-free mode"}, }, }, { diff --git a/cmd/roborev/tui/render_queue.go b/cmd/roborev/tui/render_queue.go index 0e53124a..d630e3eb 100644 --- a/cmd/roborev/tui/render_queue.go +++ b/cmd/roborev/tui/render_queue.go @@ -36,13 +36,25 @@ func (m model) queueHelpRows() [][]helpItem { if !m.lockedRepoFilter || !m.lockedBranchFilter { row2 = append(row2, helpItem{"f", "filter"}) } - row2 = append(row2, helpItem{"h", "hide"}, helpItem{"T", "tasks"}, helpItem{"?", "help"}, helpItem{"q", "quit"}) + row2 = append(row2, helpItem{"h", "hide"}, helpItem{"D", "focus"}, helpItem{"T", "tasks"}, helpItem{"?", "help"}, helpItem{"q", "quit"}) return [][]helpItem{row1, row2} } func (m model) queueHelpLines() int { return len(reflowHelpRows(m.queueHelpRows(), m.width)) } + +// queueCompact returns true when chrome should be hidden +// (status line, table header, scroll indicator, flash, help footer). +// Triggered automatically for short terminals or manually via distraction-free mode. +func (m model) queueCompact() bool { + return m.height < 15 || m.distractionFree +} + func (m model) queueVisibleRows() int { + if m.queueCompact() { + // compact: title(1) only + return max(m.height-1, 1) + } // title(1) + status(2) + header(2) + scroll(1) + flash(1) + help(dynamic) reserved := 7 + m.queueHelpLines() visibleRows := max(m.height-reserved, 3) @@ -72,6 +84,7 @@ func (m model) getVisibleSelectedIdx() int { } func (m model) renderQueueView() string { var b strings.Builder + compact := m.queueCompact() // Title with version, optional update notification, and filter indicators (in stack order) var title strings.Builder @@ -93,71 +106,74 @@ func (m model) renderQueueView() string { title.WriteString(" [hiding addressed]") } b.WriteString(titleStyle.Render(title.String())) + // In compact mode, show version mismatch inline since the status area is hidden + if compact && m.versionMismatch { + errorStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "124", Dark: "196"}).Bold(true) + b.WriteString(" ") + b.WriteString(errorStyle.Render(fmt.Sprintf("MISMATCH: TUI %s != Daemon %s", version.Version, m.daemonVersion))) + } b.WriteString("\x1b[K\n") // Clear to end of line - // Status line - use server-side aggregate counts for paginated views, - // fall back to client-side counting for multi-repo filters (which load all jobs) - var statusLine string - var done, addressed, unaddressed int - if len(m.activeRepoFilter) > 1 || m.activeBranchFilter == branchNone { - // Client-side filtered views load all jobs, so count locally - for _, job := range m.jobs { - if len(m.activeRepoFilter) > 0 && !m.repoMatchesFilter(job.RepoPath) { - continue - } - if m.activeBranchFilter == branchNone && job.Branch != "" { - continue - } - if job.Status == storage.JobStatusDone { - done++ - if job.Addressed != nil { - if *job.Addressed { - addressed++ - } else { - unaddressed++ + if !compact { + // Status line - use server-side aggregate counts for paginated views, + // fall back to client-side counting for multi-repo filters (which load all jobs) + var statusLine string + var done, addressed, unaddressed int + if len(m.activeRepoFilter) > 1 || m.activeBranchFilter == branchNone { + // Client-side filtered views load all jobs, so count locally + for _, job := range m.jobs { + if len(m.activeRepoFilter) > 0 && !m.repoMatchesFilter(job.RepoPath) { + continue + } + if m.activeBranchFilter == branchNone && job.Branch != "" { + continue + } + if job.Status == storage.JobStatusDone { + done++ + if job.Addressed != nil { + if *job.Addressed { + addressed++ + } else { + unaddressed++ + } } } } + } else { + done = m.jobStats.Done + addressed = m.jobStats.Addressed + unaddressed = m.jobStats.Unaddressed } - } else { - done = m.jobStats.Done - addressed = m.jobStats.Addressed - unaddressed = m.jobStats.Unaddressed - } - if len(m.activeRepoFilter) > 0 || m.activeBranchFilter != "" { - statusLine = fmt.Sprintf("Daemon: %s | Done: %d | Addressed: %d | Unaddressed: %d", - m.daemonVersion, done, addressed, unaddressed) - } else { - statusLine = fmt.Sprintf("Daemon: %s | Workers: %d/%d | Done: %d | Addressed: %d | Unaddressed: %d", - m.daemonVersion, - m.status.ActiveWorkers, m.status.MaxWorkers, - done, addressed, unaddressed) - } - b.WriteString(statusStyle.Render(statusLine)) - b.WriteString("\x1b[K\n") // Clear status line - - // Update notification on line 3 (above the table) - if m.updateAvailable != "" { - updateStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "136", Dark: "226"}).Bold(true) - var updateMsg string - if m.updateIsDevBuild { - updateMsg = fmt.Sprintf("Dev build - latest release: %s - run 'roborev update --force'", m.updateAvailable) + if len(m.activeRepoFilter) > 0 || m.activeBranchFilter != "" { + statusLine = fmt.Sprintf("Daemon: %s | Done: %d | Addressed: %d | Unaddressed: %d", + m.daemonVersion, done, addressed, unaddressed) } else { - updateMsg = fmt.Sprintf("Update available: %s - run 'roborev update'", m.updateAvailable) + statusLine = fmt.Sprintf("Daemon: %s | Workers: %d/%d | Done: %d | Addressed: %d | Unaddressed: %d", + m.daemonVersion, + m.status.ActiveWorkers, m.status.MaxWorkers, + done, addressed, unaddressed) + } + b.WriteString(statusStyle.Render(statusLine)) + b.WriteString("\x1b[K\n") // Clear status line + + // Update notification on line 3 (above the table) + if m.updateAvailable != "" { + updateStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "136", Dark: "226"}).Bold(true) + var updateMsg string + if m.updateIsDevBuild { + updateMsg = fmt.Sprintf("Dev build - latest release: %s - run 'roborev update --force'", m.updateAvailable) + } else { + updateMsg = fmt.Sprintf("Update available: %s - run 'roborev update'", m.updateAvailable) + } + b.WriteString(updateStyle.Render(updateMsg)) } - b.WriteString(updateStyle.Render(updateMsg)) + b.WriteString("\x1b[K\n") // Clear line 3 } - b.WriteString("\x1b[K\n") // Clear line 3 visibleJobList := m.getVisibleJobs() visibleSelectedIdx := m.getVisibleSelectedIdx() - // Calculate visible job range based on terminal height - // title(1) + status(2) + header(2) + scroll(1) + flash(1) + help(dynamic) - reservedLines := 7 + m.queueHelpLines() - visibleRows := max(m.height-reservedLines, - // Show at least 3 jobs - 3) + visibleRows := m.queueVisibleRows() // Track scroll indicator state for later var scrollInfo string @@ -176,9 +192,13 @@ func (m model) renderQueueView() string { b.WriteString("\x1b[K\n") } // Pad empty queue to fill visibleRows (minus 1 for the message we just wrote) - // Also need header lines (2) to match non-empty case + // Also need header lines (2) to match non-empty case (skip in compact) linesWritten := 1 - for linesWritten < visibleRows+2 { // +2 for header lines we skipped + padTarget := visibleRows + if !compact { + padTarget += 2 // +2 for header lines we skipped + } + for linesWritten < padTarget { b.WriteString("\x1b[K\n") linesWritten++ } @@ -195,18 +215,20 @@ func (m model) renderQueueView() string { // Calculate column widths dynamically based on terminal width colWidths := m.calculateColumnWidths(idWidth) - // Header (with 2-char prefix to align with row selector) - header := fmt.Sprintf(" %-*s %-*s %-*s %-*s %-*s %-8s %-3s %-12s %-8s %s", - idWidth, "JobID", - colWidths.ref, "Ref", - colWidths.branch, "Branch", - colWidths.repo, "Repo", - colWidths.agent, "Agent", - "Status", "P/F", "Queued", "Elapsed", "Addressed") - b.WriteString(statusStyle.Render(header)) - b.WriteString("\x1b[K\n") // Clear to end of line - b.WriteString(" " + strings.Repeat("-", min(m.width-4, 200))) - b.WriteString("\x1b[K\n") // Clear to end of line + if !compact { + // Header (with 2-char prefix to align with row selector) + header := fmt.Sprintf(" %-*s %-*s %-*s %-*s %-*s %-8s %-3s %-12s %-8s %s", + idWidth, "JobID", + colWidths.ref, "Ref", + colWidths.branch, "Branch", + colWidths.repo, "Repo", + colWidths.agent, "Agent", + "Status", "P/F", "Queued", "Elapsed", "Addressed") + b.WriteString(statusStyle.Render(header)) + b.WriteString("\x1b[K\n") // Clear to end of line + b.WriteString(" " + strings.Repeat("-", min(m.width-4, 200))) + b.WriteString("\x1b[K\n") // Clear to end of line + } // Determine which jobs to show, keeping selected item visible start = 0 @@ -262,29 +284,38 @@ func (m model) renderQueueView() string { } } - // Always emit scroll indicator line (blank if no scroll info) to maintain consistent height - if scrollInfo != "" { - b.WriteString(statusStyle.Render(scrollInfo)) - } - b.WriteString("\x1b[K\n") // Clear scroll indicator line + if !compact { + // Always emit scroll indicator line (blank if no scroll info) to maintain consistent height + if scrollInfo != "" { + b.WriteString(statusStyle.Render(scrollInfo)) + } + b.WriteString("\x1b[K\n") // Clear scroll indicator line + + // Status line: flash message (temporary) + // Version mismatch takes priority over flash messages (it's persistent and important) + if m.versionMismatch { + errorStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "124", Dark: "196"}).Bold(true) // Red + b.WriteString(errorStyle.Render(fmt.Sprintf("VERSION MISMATCH: TUI %s != Daemon %s - restart TUI or daemon", version.Version, m.daemonVersion))) + } else if m.flashMessage != "" && time.Now().Before(m.flashExpiresAt) && m.flashView == viewQueue { + flashStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "28", Dark: "46"}) // Green + b.WriteString(flashStyle.Render(m.flashMessage)) + } + b.WriteString("\x1b[K\n") // Clear to end of line - // Status line: flash message (temporary) - // Version mismatch takes priority over flash messages (it's persistent and important) - if m.versionMismatch { - errorStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "124", Dark: "196"}).Bold(true) // Red - b.WriteString(errorStyle.Render(fmt.Sprintf("VERSION MISMATCH: TUI %s != Daemon %s - restart TUI or daemon", version.Version, m.daemonVersion))) - } else if m.flashMessage != "" && time.Now().Before(m.flashExpiresAt) && m.flashView == viewQueue { - flashStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "28", Dark: "46"}) // Green - b.WriteString(flashStyle.Render(m.flashMessage)) + // Help + b.WriteString(renderHelpTable(m.queueHelpRows(), m.width)) } - b.WriteString("\x1b[K\n") // Clear to end of line - // Help - b.WriteString(renderHelpTable(m.queueHelpRows(), m.width)) - b.WriteString("\x1b[K") // Clear to end of line (no newline at end) - b.WriteString("\x1b[J") // Clear to end of screen to prevent artifacts + output := b.String() + if compact { + // Trim trailing newline to avoid layout overflow (compact has no + // help footer to consume the final line). + output = strings.TrimSuffix(output, "\n") + } + output += "\x1b[K" // Clear to end of line (no newline at end) + output += "\x1b[J" // Clear to end of screen to prevent artifacts - return b.String() + return output } func (m model) calculateColumnWidths(idWidth int) columnWidths { // Fixed widths: ID (idWidth), Status (8), P/F (3), Queued (12), Elapsed (8), Addressed (9) diff --git a/cmd/roborev/tui/tui.go b/cmd/roborev/tui/tui.go index 30fffc06..1b6940df 100644 --- a/cmd/roborev/tui/tui.go +++ b/cmd/roborev/tui/tui.go @@ -352,7 +352,8 @@ type model struct { // Glamour markdown render cache (pointer so View's value receiver can update it) mdCache *markdownCache - clipboard ClipboardWriter + distractionFree bool // hide status line, headers, footer, scroll indicator + clipboard ClipboardWriter // Review view navigation reviewFromView viewKind // View to return to when exiting review (queue or tasks)