on main help sign in
feat(dialog): track last closed dialog in open with grace
f6a841eKieran Klukas
internal/ui/dialog/dialog.go+27
@@ -69,6 +69,13 @@ type Overlay struct {
69 // dialog was opened via OpenDialogWithGrace.
69 // dialog was opened via OpenDialogWithGrace.
70 graceOpenedAt time.Time
70 graceOpenedAt time.Time
71 graceLastInputAt time.Time
71 graceLastInputAt time.Time
72+
73+ // Track recently closed dialog IDs so that reopening the same
74+ // dialog type can skip the grace period. This prevents rapid
75+ // successive dialogs (e.g. multiple permission prompts) from
76+ // each eating a keystroke.
77+ lastClosedID string
78+ lastClosedAt time.Time
72}
79}
73
80
74// NewOverlay creates a new [Overlay] instance.
81// NewOverlay creates a new [Overlay] instance.
@@ -106,9 +113,21 @@ func (d *Overlay) OpenDialog(dialog Dialog) {
106// comes first. Use this for dialogs that open asynchronously (e.g.
113// comes first. Use this for dialogs that open asynchronously (e.g.
107// permission prompts) where in-flight keystrokes from a previously
114// permission prompts) where in-flight keystrokes from a previously
108// focused component could act on the dialog before the user sees it.
115// focused component could act on the dialog before the user sees it.
116+//
117+// If the same dialog ID was just closed (within reopenGraceWindow),
118+// the grace period is skipped — the user is already focused on this
119+// dialog type and rapid successive prompts should not eat keystrokes.
109func (d *Overlay) OpenDialogWithGrace(dialog Dialog) {
120func (d *Overlay) OpenDialogWithGrace(dialog Dialog) {
110 now := time.Now()
121 now := time.Now()
111 d.dialogs = append(d.dialogs, dialog)
122 d.dialogs = append(d.dialogs, dialog)
123+
124+ // Skip grace when reopening the same dialog type immediately.
125+ if dialog.ID() == d.lastClosedID && now.Sub(d.lastClosedAt) < reopenGraceWindow {
126+ d.graceOpenedAt = time.Time{}
127+ d.graceLastInputAt = time.Time{}
128+ return
129+ }
130+
112 d.graceOpenedAt = now
131 d.graceOpenedAt = now
113 d.graceLastInputAt = now
132 d.graceLastInputAt = now
114}
133}
@@ -146,7 +165,15 @@ func (d *Overlay) CloseFrontDialog() {
146 d.removeDialog(len(d.dialogs) - 1)
165 d.removeDialog(len(d.dialogs) - 1)
147}
166}
148
167
168+// reopenGraceWindow is how long after closing a dialog we consider
169+// a reopen of the same dialog ID to be "immediate" and skip grace.
170+const reopenGraceWindow = 500 * time.Millisecond
171+
149func (d *Overlay) removeDialog(idx int) {
172func (d *Overlay) removeDialog(idx int) {
173+ if idx == len(d.dialogs)-1 {
174+ d.lastClosedID = d.dialogs[idx].ID()
175+ d.lastClosedAt = time.Now()
176+ }
150 d.dialogs = append(d.dialogs[:idx], d.dialogs[idx+1:]...)
177 d.dialogs = append(d.dialogs[:idx], d.dialogs[idx+1:]...)
151 // Clear grace state when the front dialog changes.
178 // Clear grace state when the front dialog changes.
152 if idx == len(d.dialogs) {
179 if idx == len(d.dialogs) {
internal/ui/dialog/overlay_test.go+58
@@ -129,3 +129,61 @@ func TestOverlay_NonKeyMessagesPassDuringGrace(t *testing.T) {
129 o.Update(tea.MouseWheelMsg{})
129 o.Update(tea.MouseWheelMsg{})
130 require.Len(t, d.received, 1, "non-key messages should pass through during grace")
130 require.Len(t, d.received, 1, "non-key messages should pass through during grace")
131}
131}
132+
133+// TestOverlay_ReopenSameDialogSkipsGrace verifies that when a dialog is
134+// closed and immediately reopened with the same ID (e.g. successive
135+// permission prompts), the grace period is skipped so the user can
136+// continue interacting without lost keystrokes.
137+func TestOverlay_ReopenSameDialogSkipsGrace(t *testing.T) {
138+ t.Parallel()
139+
140+ d1 := &stubDialog{id: "permissions"}
141+ o := NewOverlay()
142+ o.OpenDialogWithGrace(d1)
143+
144+ // Close and immediately reopen with the same ID.
145+ o.CloseDialog("permissions")
146+ d2 := &stubDialog{id: "permissions"}
147+ o.OpenDialogWithGrace(d2)
148+
149+ // Keys should reach the new dialog immediately — no grace.
150+ o.Update(keyMsg('a'))
151+ require.Len(t, d2.received, 1, "reopened dialog with same ID should skip grace")
152+}
153+
154+// TestOverlay_ReopenDifferentDialogKeepsGrace verifies that reopening a
155+// different dialog ID still gets the normal grace period.
156+func TestOverlay_ReopenDifferentDialogKeepsGrace(t *testing.T) {
157+ t.Parallel()
158+
159+ d1 := &stubDialog{id: "permissions"}
160+ o := NewOverlay()
161+ o.OpenDialogWithGrace(d1)
162+ o.CloseDialog("permissions")
163+
164+ d2 := &stubDialog{id: "other"}
165+ o.OpenDialogWithGrace(d2)
166+
167+ o.Update(keyMsg('a'))
168+ require.Empty(t, d2.received, "different dialog ID should still have grace period")
169+}
170+
171+// TestOverlay_ReopenAfterWindowExpiresKeepsGrace verifies that if enough
172+// time passes between close and reopen, the grace period applies normally.
173+func TestOverlay_ReopenAfterWindowExpiresKeepsGrace(t *testing.T) {
174+ t.Parallel()
175+
176+ d1 := &stubDialog{id: "permissions"}
177+ o := NewOverlay()
178+ o.OpenDialogWithGrace(d1)
179+ o.CloseDialog("permissions")
180+
181+ // Backdate the close time so it falls outside the reopen window.
182+ o.lastClosedAt = time.Now().Add(-reopenGraceWindow - time.Millisecond)
183+
184+ d2 := &stubDialog{id: "permissions"}
185+ o.OpenDialogWithGrace(d2)
186+
187+ o.Update(keyMsg('a'))
188+ require.Empty(t, d2.received, "reopened dialog after window expires should have grace")
189+}