Skip to content

Commit c1d35b1

Browse files
feat: compact mode for short terminals and distraction-free toggle (#397)
## Summary - Auto-hide TUI chrome (status line, table headers, scroll indicator, help footer) when terminal height < 15 rows - Add `D` keybinding to manually toggle distraction-free mode at any terminal size - Add `ROBOREV_SKIP_VERSION_CHECK=1` env var to skip daemon version mismatch restarts (useful for `go run` development) - Update mouse click handling to account for compact mode header offset ## Test plan - [x] Existing tests updated and passing - [x] New tests for compact mode rendering and distraction-free toggle - [x] Manual: resize terminal below 15 rows, verify chrome is hidden - [x] Manual: press `D` in queue view, verify chrome toggles - [x] Manual: `ROBOREV_SKIP_VERSION_CHECK=1 go run ./cmd/roborev tui` connects to running daemon Closes #378 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f0fef72 commit c1d35b1

File tree

7 files changed

+251
-99
lines changed

7 files changed

+251
-99
lines changed

cmd/roborev/daemon_lifecycle.go

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,12 @@ func registerRepo(repoPath string) error {
122122
return nil
123123
}
124124

125-
// ensureDaemon checks if daemon is running, starts it if not
126-
// If daemon is running but has different version, restart it
125+
// ensureDaemon checks if daemon is running, starts it if not.
126+
// If daemon is running but has different version, restart it.
127+
// Set ROBOREV_SKIP_VERSION_CHECK=1 to accept any daemon version without
128+
// restarting (useful for development with go run).
127129
func ensureDaemon() error {
130+
skipVersionCheck := os.Getenv("ROBOREV_SKIP_VERSION_CHECK") == "1"
128131
client := &http.Client{Timeout: 500 * time.Millisecond}
129132

130133
// First check runtime files for any running daemon
@@ -140,10 +143,17 @@ func ensureDaemon() error {
140143
}
141144
decodeErr := json.NewDecoder(resp.Body).Decode(&status)
142145

143-
// Fail closed: restart if decode fails, version empty, or mismatch
144-
if decodeErr != nil || status.Version == "" || status.Version != version.Version {
146+
// Always fail on decode errors (response is not a valid daemon)
147+
if decodeErr != nil {
145148
if verbose {
146-
fmt.Printf("Daemon version mismatch or unreadable (daemon: %s, cli: %s), restarting...\n", status.Version, version.Version)
149+
fmt.Printf("Daemon response unreadable, restarting...\n")
150+
}
151+
return restartDaemon()
152+
}
153+
// Skip version mismatch check when env var is set
154+
if !skipVersionCheck && (status.Version == "" || status.Version != version.Version) {
155+
if verbose {
156+
fmt.Printf("Daemon version mismatch (daemon: %s, cli: %s), restarting...\n", status.Version, version.Version)
147157
}
148158
return restartDaemon()
149159
}
@@ -162,10 +172,17 @@ func ensureDaemon() error {
162172
}
163173
decodeErr := json.NewDecoder(resp.Body).Decode(&status)
164174

165-
// Fail closed: restart if decode fails, version empty, or mismatch
166-
if decodeErr != nil || status.Version == "" || status.Version != version.Version {
175+
// Always fail on decode errors (response is not a valid daemon)
176+
if decodeErr != nil {
177+
if verbose {
178+
fmt.Printf("Daemon response unreadable, restarting...\n")
179+
}
180+
return restartDaemon()
181+
}
182+
// Skip version mismatch check when env var is set
183+
if !skipVersionCheck && (status.Version == "" || status.Version != version.Version) {
167184
if verbose {
168-
fmt.Printf("Daemon version mismatch or unreadable (daemon: %s, cli: %s), restarting...\n", status.Version, version.Version)
185+
fmt.Printf("Daemon version mismatch (daemon: %s, cli: %s), restarting...\n", status.Version, version.Version)
169186
}
170187
return restartDaemon()
171188
}

cmd/roborev/tui/handlers.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ func (m model) handleGlobalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
121121
return m.handleFixKey()
122122
case "T":
123123
return m.handleToggleTasksKey()
124+
case "D":
125+
return m.handleDistractionFreeKey()
124126
case "tab":
125127
return m.handleTabKey()
126128
}

cmd/roborev/tui/handlers_queue.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@ func (m *model) handleQueueMouseClick(_ int, y int) {
2626
start = max(end-visibleRows, 0)
2727
}
2828
}
29-
row := y - 5 // rows start after title, status, update, header, separator
29+
headerRows := 5 // title, status, update, header, separator
30+
if m.queueCompact() {
31+
headerRows = 1 // title only
32+
}
33+
row := y - headerRows
3034
if row < 0 || row >= visibleRows {
3135
return
3236
}
@@ -75,6 +79,14 @@ func (m *model) handleTasksMouseClick(y int) {
7579
m.fixSelectedIdx = idx
7680
}
7781

82+
func (m model) handleDistractionFreeKey() (tea.Model, tea.Cmd) {
83+
if m.currentView != viewQueue {
84+
return m, nil
85+
}
86+
m.distractionFree = !m.distractionFree
87+
return m, nil
88+
}
89+
7890
func (m model) handleEnterKey() (tea.Model, tea.Cmd) {
7991
job, ok := m.selectedJob()
8092
if m.currentView != viewQueue || !ok {

cmd/roborev/tui/queue_test.go

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ func TestTUIQueueMouseClickSelectsVisibleRow(t *testing.T) {
169169
m := newTuiModel("http://localhost")
170170
m.currentView = tuiViewQueue
171171
m.width = 120
172-
m.height = 14
172+
m.height = 20
173173
m.jobs = []storage.ReviewJob{
174174
makeJob(1),
175175
makeJob(2),
@@ -195,7 +195,7 @@ func TestTUIQueueMouseHeaderClickDoesNotSort(t *testing.T) {
195195
m := newTuiModel("http://localhost")
196196
m.currentView = tuiViewQueue
197197
m.width = 120
198-
m.height = 14
198+
m.height = 20
199199
m.jobs = []storage.ReviewJob{
200200
makeJob(3),
201201
makeJob(1),
@@ -259,7 +259,7 @@ func TestTUIQueueMouseClickScrolledWindow(t *testing.T) {
259259
m := newTuiModel("http://localhost")
260260
m.currentView = tuiViewQueue
261261
m.width = 120
262-
m.height = 12 // small terminal
262+
m.height = 16 // small but not compact (compact hides headers, changing click offsets)
263263

264264
// Create more jobs than fit on screen.
265265
for i := range 20 {
@@ -307,6 +307,94 @@ func TestTUIQueueMouseClickScrolledWindow(t *testing.T) {
307307
}
308308
}
309309

310+
func TestTUIQueueCompactMode(t *testing.T) {
311+
m := newTuiModel("http://localhost")
312+
m.currentView = tuiViewQueue
313+
m.width = 80
314+
m.height = 10 // compact mode (< 15)
315+
316+
for i := range 5 {
317+
m.jobs = append(m.jobs, makeJob(int64(i+1)))
318+
}
319+
m.selectedIdx = 0
320+
m.selectedJobID = 1
321+
322+
output := m.View()
323+
324+
// Should have the title line
325+
if !strings.Contains(output, "roborev queue") {
326+
t.Error("compact mode should show title")
327+
}
328+
// Should NOT have the table header
329+
if strings.Contains(output, "JobID") {
330+
t.Error("compact mode should hide table header")
331+
}
332+
// Should NOT have help footer keys
333+
if strings.Contains(output, "navigate") {
334+
t.Error("compact mode should hide help footer")
335+
}
336+
// Should NOT have daemon status line
337+
if strings.Contains(output, "Daemon:") {
338+
t.Error("compact mode should hide status line")
339+
}
340+
341+
// Mouse click at y=1 (first data row in compact mode) should select first job
342+
m.selectedIdx = 2
343+
m.selectedJobID = 3
344+
m2, _ := updateModel(t, m, mouseLeftClick(4, 1))
345+
if m2.selectedJobID != 1 {
346+
t.Errorf("compact mouse click at y=1: expected job 1, got %d", m2.selectedJobID)
347+
}
348+
}
349+
350+
func TestTUIQueueDistractionFreeToggle(t *testing.T) {
351+
m := newTuiModel("http://localhost")
352+
m.currentView = tuiViewQueue
353+
m.width = 120
354+
m.height = 30 // tall enough for normal mode
355+
356+
for i := range 5 {
357+
m.jobs = append(m.jobs, makeJob(int64(i+1)))
358+
}
359+
m.selectedIdx = 0
360+
m.selectedJobID = 1
361+
362+
// Normal mode: should show chrome
363+
output := m.View()
364+
if !strings.Contains(output, "JobID") {
365+
t.Error("normal mode should show table header")
366+
}
367+
if !strings.Contains(output, "navigate") {
368+
t.Error("normal mode should show help footer")
369+
}
370+
371+
// Toggle distraction-free with 'D'
372+
m2, _ := updateModel(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'D'}})
373+
if !m2.distractionFree {
374+
t.Fatal("D should toggle distraction-free on")
375+
}
376+
output = m2.View()
377+
if strings.Contains(output, "JobID") {
378+
t.Error("distraction-free should hide table header")
379+
}
380+
if strings.Contains(output, "navigate") {
381+
t.Error("distraction-free should hide help footer")
382+
}
383+
if strings.Contains(output, "Daemon:") {
384+
t.Error("distraction-free should hide status line")
385+
}
386+
387+
// Toggle back off
388+
m3, _ := updateModel(t, m2, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'D'}})
389+
if m3.distractionFree {
390+
t.Fatal("D should toggle distraction-free off")
391+
}
392+
output = m3.View()
393+
if !strings.Contains(output, "JobID") {
394+
t.Error("should show table header after toggling off")
395+
}
396+
}
397+
310398
func TestTUITasksMouseClickSelectsRow(t *testing.T) {
311399
m := newTuiModel("http://localhost")
312400
m.currentView = tuiViewTasks

cmd/roborev/tui/render_log.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ func helpLines() []string {
131131
{"r", "Re-run completed/failed job"},
132132
{"F", "Trigger fix for selected review"},
133133
{"T", "Open Tasks view"},
134+
{"D", "Toggle distraction-free mode"},
134135
},
135136
},
136137
{

0 commit comments

Comments
 (0)