Skip to content

Commit eb12565

Browse files
feat(tui): add quit confirmation modal when Ralph loop is running
Pressing q/ctrl+c while a loop is active now shows a centered warning dialog instead of immediately killing the process. The modal defaults to Cancel for safety and supports keyboard navigation (↑/↓/Enter/Esc). When no loop is running, quit proceeds instantly as before.
1 parent 67e70f1 commit eb12565

2 files changed

Lines changed: 218 additions & 12 deletions

File tree

internal/tui/app.go

Lines changed: 62 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ const (
146146
ViewWorktreeSpinner
147147
ViewCompletion
148148
ViewSettings
149+
ViewQuitConfirm
149150
)
150151

151152
// App is the main Bubble Tea model for the Chief TUI.
@@ -213,6 +214,9 @@ type App struct {
213214
// Settings overlay
214215
settingsOverlay *SettingsOverlay
215216

217+
// Quit confirmation dialog
218+
quitConfirm *QuitConfirmation
219+
216220
// Completion notification callback
217221
onCompletion func(prdName string)
218222

@@ -334,6 +338,7 @@ func NewAppWithOptions(prdPath string, maxIter int) (*App, error) {
334338
worktreeSpinner: NewWorktreeSpinner(),
335339
completionScreen: NewCompletionScreen(),
336340
settingsOverlay: NewSettingsOverlay(),
341+
quitConfirm: NewQuitConfirmation(),
337342
}, nil
338343
}
339344

@@ -551,11 +556,14 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
551556
return a.handleCompletionKeys(msg)
552557
}
553558

559+
// Handle quit confirmation dialog
560+
if a.viewMode == ViewQuitConfirm {
561+
return a.handleQuitConfirmKeys(msg)
562+
}
563+
554564
switch msg.String() {
555565
case "q", "ctrl+c":
556-
a.stopAllLoops()
557-
a.stopWatcher()
558-
return a, tea.Quit
566+
return a.tryQuit()
559567

560568
// View switching
561569
case "t":
@@ -849,6 +857,52 @@ func (a *App) stopAllLoops() {
849857
}
850858
}
851859

860+
// tryQuit attempts to quit the app. If any loop is running, it shows the quit
861+
// confirmation dialog instead of quitting immediately.
862+
func (a App) tryQuit() (tea.Model, tea.Cmd) {
863+
if a.manager != nil && a.manager.IsAnyRunning() {
864+
a.previousViewMode = a.viewMode
865+
a.viewMode = ViewQuitConfirm
866+
a.quitConfirm.Reset()
867+
a.quitConfirm.SetSize(a.width, a.height)
868+
return a, nil
869+
}
870+
a.stopAllLoops()
871+
a.stopWatcher()
872+
return a, tea.Quit
873+
}
874+
875+
// handleQuitConfirmKeys handles keyboard input for the quit confirmation dialog.
876+
func (a App) handleQuitConfirmKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
877+
switch msg.String() {
878+
case "esc":
879+
a.viewMode = a.previousViewMode
880+
return a, nil
881+
case "up", "k":
882+
a.quitConfirm.MoveUp()
883+
return a, nil
884+
case "down", "j":
885+
a.quitConfirm.MoveDown()
886+
return a, nil
887+
case "enter":
888+
if a.quitConfirm.GetSelected() == QuitOptionQuit {
889+
a.stopAllLoops()
890+
a.stopWatcher()
891+
return a, tea.Quit
892+
}
893+
// Cancel
894+
a.viewMode = a.previousViewMode
895+
return a, nil
896+
}
897+
return a, nil
898+
}
899+
900+
// renderQuitConfirmView renders the quit confirmation dialog.
901+
func (a *App) renderQuitConfirmView() string {
902+
a.quitConfirm.SetSize(a.width, a.height)
903+
return a.quitConfirm.Render()
904+
}
905+
852906
// handleLoopEvent handles events from the manager.
853907
func (a App) handleLoopEvent(prdName string, event loop.Event) (tea.Model, tea.Cmd) {
854908
// Only update iteration and log if this is the currently viewed PRD
@@ -1013,6 +1067,8 @@ func (a App) View() string {
10131067
return a.renderCompletionView()
10141068
case ViewSettings:
10151069
return a.renderSettingsView()
1070+
case ViewQuitConfirm:
1071+
return a.renderQuitConfirmView()
10161072
default:
10171073
return a.renderDashboard()
10181074
}
@@ -1413,9 +1469,7 @@ func (a App) handleSettingsKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
14131469
a.viewMode = a.previousViewMode
14141470
return a, nil
14151471
case "q", "ctrl+c":
1416-
a.stopAllLoops()
1417-
a.stopWatcher()
1418-
return a, tea.Quit
1472+
return a.tryQuit()
14191473
case "up", "k":
14201474
a.settingsOverlay.MoveUp()
14211475
return a, nil
@@ -1479,9 +1533,7 @@ func (a App) handleSettingsGHCheck(msg settingsGHCheckResultMsg) (tea.Model, tea
14791533
func (a App) handleCompletionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
14801534
switch msg.String() {
14811535
case "q", "ctrl+c":
1482-
a.stopAllLoops()
1483-
a.stopWatcher()
1484-
return a, tea.Quit
1536+
return a.tryQuit()
14851537

14861538
case "l":
14871539
// Switch to the picker
@@ -1828,9 +1880,7 @@ func (a App) handlePickerKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
18281880
a.viewMode = ViewDashboard
18291881
return a, nil
18301882
case "q", "ctrl+c":
1831-
a.stopAllLoops()
1832-
a.stopWatcher()
1833-
return a, tea.Quit
1883+
return a.tryQuit()
18341884
case "up", "k":
18351885
a.picker.MoveUp()
18361886
a.picker.Refresh() // Refresh to get latest state

internal/tui/quit_confirm.go

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
package tui
2+
3+
import (
4+
"strings"
5+
6+
"github.com/charmbracelet/lipgloss"
7+
)
8+
9+
// QuitConfirmOption represents the user's choice in the quit confirmation dialog.
10+
type QuitConfirmOption int
11+
12+
const (
13+
QuitOptionQuit QuitConfirmOption = iota // Quit and stop loop
14+
QuitOptionCancel // Cancel
15+
)
16+
17+
// QuitConfirmation manages the quit confirmation dialog state.
18+
type QuitConfirmation struct {
19+
width int
20+
height int
21+
selectedIdx int
22+
}
23+
24+
// NewQuitConfirmation creates a new quit confirmation dialog.
25+
func NewQuitConfirmation() *QuitConfirmation {
26+
return &QuitConfirmation{
27+
selectedIdx: 1, // Default to Cancel (safe choice)
28+
}
29+
}
30+
31+
// SetSize sets the dialog dimensions.
32+
func (q *QuitConfirmation) SetSize(width, height int) {
33+
q.width = width
34+
q.height = height
35+
}
36+
37+
// MoveUp moves selection up.
38+
func (q *QuitConfirmation) MoveUp() {
39+
if q.selectedIdx > 0 {
40+
q.selectedIdx--
41+
}
42+
}
43+
44+
// MoveDown moves selection down.
45+
func (q *QuitConfirmation) MoveDown() {
46+
if q.selectedIdx < 1 {
47+
q.selectedIdx++
48+
}
49+
}
50+
51+
// GetSelected returns the currently selected option.
52+
func (q *QuitConfirmation) GetSelected() QuitConfirmOption {
53+
if q.selectedIdx == 0 {
54+
return QuitOptionQuit
55+
}
56+
return QuitOptionCancel
57+
}
58+
59+
// Reset resets the dialog state to defaults.
60+
func (q *QuitConfirmation) Reset() {
61+
q.selectedIdx = 1 // Default to Cancel
62+
}
63+
64+
// Render renders the quit confirmation dialog.
65+
func (q *QuitConfirmation) Render() string {
66+
modalWidth := min(55, q.width-10)
67+
if modalWidth < 40 {
68+
modalWidth = 40
69+
}
70+
71+
var content strings.Builder
72+
73+
// Title
74+
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(WarningColor)
75+
content.WriteString(titleStyle.Render("Quit Chief?"))
76+
content.WriteString("\n")
77+
content.WriteString(DividerStyle.Render(strings.Repeat("─", modalWidth-4)))
78+
content.WriteString("\n\n")
79+
80+
// Message
81+
messageStyle := lipgloss.NewStyle().Foreground(TextColor)
82+
content.WriteString(messageStyle.Render("A Ralph loop is currently running."))
83+
content.WriteString("\n")
84+
content.WriteString(messageStyle.Render("Exiting will stop the loop."))
85+
content.WriteString("\n\n")
86+
87+
// Options
88+
optionStyle := lipgloss.NewStyle().Foreground(TextColor)
89+
selectedStyle := lipgloss.NewStyle().Foreground(PrimaryColor).Bold(true)
90+
91+
options := []string{"Quit and stop loop", "Cancel"}
92+
for i, opt := range options {
93+
if i == q.selectedIdx {
94+
content.WriteString(selectedStyle.Render("▶ " + opt))
95+
} else {
96+
content.WriteString(optionStyle.Render(" " + opt))
97+
}
98+
content.WriteString("\n")
99+
}
100+
101+
// Footer
102+
content.WriteString("\n")
103+
content.WriteString(DividerStyle.Render(strings.Repeat("─", modalWidth-4)))
104+
content.WriteString("\n")
105+
footerStyle := lipgloss.NewStyle().Foreground(MutedColor)
106+
content.WriteString(footerStyle.Render("↑/↓: Navigate Enter: Select Esc: Cancel"))
107+
108+
// Modal box
109+
modalStyle := lipgloss.NewStyle().
110+
Border(lipgloss.RoundedBorder()).
111+
BorderForeground(WarningColor).
112+
Padding(1, 2).
113+
Width(modalWidth)
114+
115+
modal := modalStyle.Render(content.String())
116+
117+
// Center on screen
118+
return q.centerModal(modal)
119+
}
120+
121+
// centerModal centers the modal on the screen.
122+
func (q *QuitConfirmation) centerModal(modal string) string {
123+
lines := strings.Split(modal, "\n")
124+
modalHeight := len(lines)
125+
modalWidth := 0
126+
for _, line := range lines {
127+
if lipgloss.Width(line) > modalWidth {
128+
modalWidth = lipgloss.Width(line)
129+
}
130+
}
131+
132+
topPadding := (q.height - modalHeight) / 2
133+
leftPadding := (q.width - modalWidth) / 2
134+
135+
if topPadding < 0 {
136+
topPadding = 0
137+
}
138+
if leftPadding < 0 {
139+
leftPadding = 0
140+
}
141+
142+
var result strings.Builder
143+
144+
for i := 0; i < topPadding; i++ {
145+
result.WriteString("\n")
146+
}
147+
148+
leftPad := strings.Repeat(" ", leftPadding)
149+
for _, line := range lines {
150+
result.WriteString(leftPad)
151+
result.WriteString(line)
152+
result.WriteString("\n")
153+
}
154+
155+
return result.String()
156+
}

0 commit comments

Comments
 (0)