From b6692b18efa96662a52a2eefa5e537ad4689f6da Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Fri, 6 Mar 2026 13:09:38 -0500 Subject: [PATCH 1/3] Add Ctrl-D quit shortcut to TUI --- cmd/roborev/tui/handlers.go | 4 +++ cmd/roborev/tui/review_views_test.go | 38 ++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/cmd/roborev/tui/handlers.go b/cmd/roborev/tui/handlers.go index 7c831748..82c8716e 100644 --- a/cmd/roborev/tui/handlers.go +++ b/cmd/roborev/tui/handlers.go @@ -11,6 +11,10 @@ import ( // handleKeyMsg dispatches key events to view-specific handlers. func (m model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if msg.String() == "ctrl+d" { + return m, tea.Quit + } + // Fix panel captures input when focused in review view if m.currentView == viewReview && m.reviewFixPanelOpen && m.reviewFixPanelFocused { return m.handleReviewFixPanelKey(msg) diff --git a/cmd/roborev/tui/review_views_test.go b/cmd/roborev/tui/review_views_test.go index a71b00ce..9be8df8a 100644 --- a/cmd/roborev/tui/review_views_test.go +++ b/cmd/roborev/tui/review_views_test.go @@ -120,6 +120,44 @@ func TestTUICommitMsgViewNavigationWithQ(t *testing.T) { } } +func TestTUICtrlDQuitsFromReviewView(t *testing.T) { + job := makeJob(1) + m := initTestModel( + withCurrentView(viewReview), + withReview(&storage.Review{JobID: 1, Job: &job}), + withReviewFromView(viewQueue), + ) + + got, cmd := pressSpecial(m, tea.KeyCtrlD) + + assertView(t, got, viewReview) + if cmd == nil { + t.Fatal("Expected quit command") + } + assertMsgType[tea.QuitMsg](t, cmd()) +} + +func TestTUICtrlDQuitsFromCommentModal(t *testing.T) { + m := initTestModel(withCurrentView(viewKindComment)) + m.commentFromView = viewQueue + m.commentText = "draft comment" + m.commentJobID = 42 + + got, cmd := pressSpecial(m, tea.KeyCtrlD) + + assertView(t, got, viewKindComment) + if got.commentText != "draft comment" { + t.Errorf("Expected comment text to remain unchanged, got %q", got.commentText) + } + if got.commentJobID != 42 { + t.Errorf("Expected comment job ID to remain unchanged, got %d", got.commentJobID) + } + if cmd == nil { + t.Fatal("Expected quit command") + } + assertMsgType[tea.QuitMsg](t, cmd()) +} + func TestFetchCommitMsgJobTypeDetection(t *testing.T) { // Test that fetchCommitMsg correctly identifies job types and returns appropriate errors // This is critical: Prompt field is populated for ALL jobs (stores review prompt), From 790afadff62a3e81ea42bafaecde38700ccc0fcc Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Fri, 6 Mar 2026 12:25:03 -0600 Subject: [PATCH 2/3] Gate Ctrl-D quit on focus state to avoid dropping input Move Ctrl-D from the top of handleKeyMsg (unconditional quit) into handleGlobalKey alongside Ctrl-C/q. Modal views with text inputs (comment, filter, fix prompt, patch save) now handle keys first, so Ctrl-D is a no-op while editing. From non-input views it behaves like q: navigates back before quitting. Co-Authored-By: Claude Opus 4.6 --- cmd/roborev/tui/handlers.go | 6 +----- cmd/roborev/tui/review_views_test.go | 32 +++++++++++++++++----------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/cmd/roborev/tui/handlers.go b/cmd/roborev/tui/handlers.go index 82c8716e..f085395d 100644 --- a/cmd/roborev/tui/handlers.go +++ b/cmd/roborev/tui/handlers.go @@ -11,10 +11,6 @@ import ( // handleKeyMsg dispatches key events to view-specific handlers. func (m model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - if msg.String() == "ctrl+d" { - return m, tea.Quit - } - // Fix panel captures input when focused in review view if m.currentView == viewReview && m.reviewFixPanelOpen && m.reviewFixPanelFocused { return m.handleReviewFixPanelKey(msg) @@ -79,7 +75,7 @@ func (m model) handleMouseMsg(msg tea.MouseMsg) (tea.Model, tea.Cmd) { // handleGlobalKey handles keys shared across queue, review, prompt, commit msg, and help views. func (m model) handleGlobalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { - case "ctrl+c", "q": + case "ctrl+c", "ctrl+d", "q": return m.handleQuitKey() case "home", "g": return m.handleHomeKey() diff --git a/cmd/roborev/tui/review_views_test.go b/cmd/roborev/tui/review_views_test.go index 9be8df8a..9d167fc8 100644 --- a/cmd/roborev/tui/review_views_test.go +++ b/cmd/roborev/tui/review_views_test.go @@ -120,7 +120,18 @@ func TestTUICommitMsgViewNavigationWithQ(t *testing.T) { } } -func TestTUICtrlDQuitsFromReviewView(t *testing.T) { +func TestTUICtrlDQuitsFromQueueView(t *testing.T) { + m := initTestModel(withCurrentView(viewQueue)) + + _, cmd := pressSpecial(m, tea.KeyCtrlD) + + if cmd == nil { + t.Fatal("Expected quit command") + } + assertMsgType[tea.QuitMsg](t, cmd()) +} + +func TestTUICtrlDNavigatesBackFromReviewView(t *testing.T) { job := makeJob(1) m := initTestModel( withCurrentView(viewReview), @@ -128,16 +139,12 @@ func TestTUICtrlDQuitsFromReviewView(t *testing.T) { withReviewFromView(viewQueue), ) - got, cmd := pressSpecial(m, tea.KeyCtrlD) + got, _ := pressSpecial(m, tea.KeyCtrlD) - assertView(t, got, viewReview) - if cmd == nil { - t.Fatal("Expected quit command") - } - assertMsgType[tea.QuitMsg](t, cmd()) + assertView(t, got, viewQueue) } -func TestTUICtrlDQuitsFromCommentModal(t *testing.T) { +func TestTUICtrlDNoOpInCommentModal(t *testing.T) { m := initTestModel(withCurrentView(viewKindComment)) m.commentFromView = viewQueue m.commentText = "draft comment" @@ -147,15 +154,14 @@ func TestTUICtrlDQuitsFromCommentModal(t *testing.T) { assertView(t, got, viewKindComment) if got.commentText != "draft comment" { - t.Errorf("Expected comment text to remain unchanged, got %q", got.commentText) + t.Errorf("Expected comment text preserved, got %q", got.commentText) } if got.commentJobID != 42 { - t.Errorf("Expected comment job ID to remain unchanged, got %d", got.commentJobID) + t.Errorf("Expected comment job ID preserved, got %d", got.commentJobID) } - if cmd == nil { - t.Fatal("Expected quit command") + if cmd != nil { + t.Fatal("Expected no command from Ctrl-D in comment modal") } - assertMsgType[tea.QuitMsg](t, cmd()) } func TestFetchCommitMsgJobTypeDetection(t *testing.T) { From 7c64f349709cffbe4b375b291b774d1dcde4c3ad Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Fri, 6 Mar 2026 12:36:23 -0600 Subject: [PATCH 3/3] Add Ctrl-D to non-input modal view handlers Add ctrl+d alongside existing q/esc quit/back behavior in log, tasks, patch (non-input mode), worktree confirm, and column options handlers so the shortcut works consistently across all non-input views. Co-Authored-By: Claude Opus 4.6 --- cmd/roborev/tui/handlers_modal.go | 8 ++++---- cmd/roborev/tui/handlers_queue.go | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/roborev/tui/handlers_modal.go b/cmd/roborev/tui/handlers_modal.go index 681dbeb7..be83e4fe 100644 --- a/cmd/roborev/tui/handlers_modal.go +++ b/cmd/roborev/tui/handlers_modal.go @@ -253,7 +253,7 @@ func (m model) handleLogKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "ctrl+c": return m, tea.Quit - case "esc", "q": + case "ctrl+d", "esc", "q": m.currentView = m.logFromView m.logStreaming = false return m, nil @@ -329,7 +329,7 @@ func (m model) handleWorktreeConfirmKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "ctrl+c": return m, tea.Quit - case "esc", "n": + case "ctrl+d", "esc", "n": m.currentView = viewTasks m.worktreeConfirmJobID = 0 m.worktreeConfirmBranch = "" @@ -347,7 +347,7 @@ func (m model) handleWorktreeConfirmKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // handleTasksKey handles key input in the tasks view. func (m model) handleTasksKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { - case "ctrl+c", "q": + case "ctrl+c", "ctrl+d", "q": return m, tea.Quit case "esc", "T": m.currentView = viewQueue @@ -504,7 +504,7 @@ func (m model) handlePatchKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "ctrl+c": return m, tea.Quit - case "esc", "q": + case "ctrl+d", "esc", "q": m.currentView = viewTasks m.patchText = "" m.patchScroll = 0 diff --git a/cmd/roborev/tui/handlers_queue.go b/cmd/roborev/tui/handlers_queue.go index 1ee08022..4652750a 100644 --- a/cmd/roborev/tui/handlers_queue.go +++ b/cmd/roborev/tui/handlers_queue.go @@ -197,7 +197,7 @@ func (m model) handleColumnOptionsInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } switch msg.String() { - case "esc": + case "ctrl+d", "esc": m.currentView = m.colOptionsReturnView if m.colOptionsDirty { m.colOptionsDirty = false